315 lines
9.2 KiB
JavaScript
315 lines
9.2 KiB
JavaScript
const $ = (sel) => document.querySelector(sel);
|
||
|
||
const loginScreen = $("#login-screen");
|
||
const dashboardScreen = $("#dashboard-screen");
|
||
const loginForm = $("#login-form");
|
||
const loginError = $("#login-error");
|
||
const logoutBtn = $("#logout-btn");
|
||
const subscriptionUrlInput = $("#subscription-url");
|
||
const saveConfigBtn = $("#save-config-btn");
|
||
const configMsg = $("#config-msg");
|
||
const runBtn = $("#run-btn");
|
||
const runMsg = $("#run-msg");
|
||
const runningIndicator = $("#running-indicator");
|
||
const currentStep = $("#current-step");
|
||
const lastRunEl = $("#last-run");
|
||
|
||
const POLL_INTERVAL = 10000;
|
||
|
||
let pollTimer = null;
|
||
let pingResults = [];
|
||
|
||
function showScreen(screen) {
|
||
loginScreen.classList.add("hidden");
|
||
dashboardScreen.classList.add("hidden");
|
||
screen.classList.remove("hidden");
|
||
}
|
||
|
||
function formatTime(iso) {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
return d.toLocaleString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
});
|
||
}
|
||
|
||
const stepLabels = {
|
||
initializing: "Инициализация...",
|
||
novavps_fetching: "Получение данных подписки...",
|
||
parsing: "Чтение панели Xray...",
|
||
syncing: "Синхронизация Xray...",
|
||
pinging: "Проверка соединений (Xray)...",
|
||
done: "Завершено",
|
||
subscription_url_missing: "Ошибка: ссылка подписки не задана",
|
||
no_xray_panel: "Ошибка: не настроена панель Xray",
|
||
fatal: "Критическая ошибка",
|
||
};
|
||
|
||
function updatePanelStatus(prefix, data) {
|
||
const badge = $(`#${prefix}-status`);
|
||
const detail = $(`#${prefix}-detail`);
|
||
const time = $(`#${prefix}-time`);
|
||
|
||
const statusMap = { idle: "ожидание", success: "успешно", error: "ошибка" };
|
||
badge.textContent = statusMap[data.status] || data.status;
|
||
badge.className = "status-badge " + data.status;
|
||
|
||
let detailText = "";
|
||
if (prefix === "novavps" && data.count != null) {
|
||
detailText = `${data.count} подключений`;
|
||
} else if (data.outboundsCount != null) {
|
||
detailText = `${data.outboundsCount} исходящих`;
|
||
if (data.synced) detailText += " · синхр.";
|
||
}
|
||
if (data.error) detailText += (detailText ? " · " : "") + data.error;
|
||
detail.textContent = detailText;
|
||
time.textContent = formatTime(data.timestamp);
|
||
}
|
||
|
||
function formatLatency(latency) {
|
||
if (latency == null) return '<span class="latency-cell latency-error">ошибка</span>';
|
||
if (latency < 100) return `<span class="latency-cell latency-good">${latency} мс</span>`;
|
||
if (latency < 300) return `<span class="latency-cell latency-fair">${latency} мс</span>`;
|
||
return `<span class="latency-cell latency-poor">${latency} мс</span>`;
|
||
}
|
||
|
||
async function fetchStatus() {
|
||
try {
|
||
const res = await fetch("/api/status");
|
||
if (res.status === 401) {
|
||
showScreen(loginScreen);
|
||
stopPolling();
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
|
||
const isRunning = data.status === "running";
|
||
runBtn.disabled = isRunning;
|
||
runningIndicator.classList.toggle("hidden", !isRunning);
|
||
currentStep.textContent = stepLabels[data.currentStep] || data.currentStep;
|
||
|
||
updatePanelStatus("novavps", data.novavps);
|
||
updatePanelStatus("xray", data.xray);
|
||
|
||
pingResults = data.xray?.pingResults || [];
|
||
|
||
lastRunEl.textContent = data.lastRun ? formatTime(data.lastRun) : "Никогда";
|
||
|
||
|
||
} catch (err) {
|
||
console.error("Status fetch error:", err);
|
||
}
|
||
}
|
||
|
||
async function fetchConfig() {
|
||
try {
|
||
const res = await fetch("/api/config");
|
||
if (res.status === 401) {
|
||
showScreen(loginScreen);
|
||
stopPolling();
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
subscriptionUrlInput.value = data.subscriptionUrl || "";
|
||
} catch (err) {
|
||
console.error("Config fetch error:", err);
|
||
}
|
||
}
|
||
|
||
async function fetchNovavpsData() {
|
||
try {
|
||
const res = await fetch("/api/data/novavps");
|
||
if (res.status === 401) return;
|
||
const data = await res.json();
|
||
renderNovavpsTable(data);
|
||
$("#novavps-data-count").textContent = data.length;
|
||
} catch {}
|
||
}
|
||
|
||
async function fetchXrayData(syncedTags) {
|
||
try {
|
||
const res = await fetch("/api/data/xray");
|
||
if (res.status === 401) return;
|
||
const data = await res.json();
|
||
renderXrayTable("xray", data, syncedTags || [], pingResults);
|
||
$("#xray-data-count").textContent = data.length;
|
||
} catch {}
|
||
}
|
||
|
||
function renderNovavpsTable(connections) {
|
||
const tbody = $("#novavps-tbody");
|
||
if (!connections.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem;">Нет данных</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = connections
|
||
.map((c) => {
|
||
const name = escapeHtml(c.name || "—");
|
||
const proto = escapeHtml(c.protocol || "—");
|
||
const addr = escapeHtml(c.address || "—");
|
||
const port = escapeHtml(c.port || "—");
|
||
const sec = escapeHtml(c.security || "—");
|
||
return `<tr><td>${name}</td><td>${proto}</td><td>${addr}</td><td>${port}</td><td>${sec}</td></tr>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function renderXrayTable(prefix, outbounds, syncedTags, pingList) {
|
||
const tbody = $(`#${prefix}-tbody`);
|
||
const colspan = 6;
|
||
|
||
if (!outbounds.length) {
|
||
tbody.innerHTML = `<tr><td colspan="${colspan}" style="color:var(--text-muted);text-align:center;padding:2rem;">Нет данных</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
const pingMap = {};
|
||
pingList.forEach((p) => { pingMap[p.tag] = p; });
|
||
|
||
tbody.innerHTML = outbounds
|
||
.map((o) => {
|
||
const tag = escapeHtml(o.remark || o.tag || "—");
|
||
const proto = escapeHtml(o.protocol || "—");
|
||
const addr = escapeHtml(o.address || "—");
|
||
const port = escapeHtml(o.port || "—");
|
||
const isSynced = syncedTags.includes(tag);
|
||
const syncBadge = isSynced
|
||
? '<span class="synced-badge yes">да</span>'
|
||
: '<span class="synced-badge no">—</span>';
|
||
|
||
let latencyCell = '<span class="latency-cell latency-error">—</span>';
|
||
if (pingMap[tag]) {
|
||
latencyCell = formatLatency(pingMap[tag].latency);
|
||
}
|
||
|
||
const latencyCol = `<td>${latencyCell}</td>`;
|
||
return `<tr><td>${tag}</td><td>${proto}</td><td>${addr}</td><td>${port}</td>${latencyCol}<td>${syncBadge}</td></tr>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement("div");
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function startPolling() {
|
||
fetchStatus();
|
||
fetchConfig();
|
||
fetchNovavpsData();
|
||
fetchXrayData([]);
|
||
if (pollTimer) clearInterval(pollTimer);
|
||
pollTimer = setInterval(() => {
|
||
fetchStatus();
|
||
fetchNovavpsData();
|
||
fetchXrayData([]);
|
||
}, POLL_INTERVAL);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
}
|
||
|
||
function showMsg(el, text, type) {
|
||
el.textContent = text;
|
||
el.className = "msg " + type;
|
||
el.classList.remove("hidden");
|
||
setTimeout(() => el.classList.add("hidden"), 5000);
|
||
}
|
||
|
||
window.toggleSection = function(id) {
|
||
const content = $(`#${id}`);
|
||
const arrow = $(`#${id}-arrow`);
|
||
content.classList.toggle("collapsed");
|
||
arrow.classList.toggle("collapsed");
|
||
};
|
||
|
||
loginForm.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
loginError.classList.add("hidden");
|
||
|
||
const username = $("#username").value;
|
||
const password = $("#password").value;
|
||
|
||
try {
|
||
const res = await fetch("/api/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
loginError.textContent = data.error || "Вход невозможен";
|
||
loginError.classList.remove("hidden");
|
||
return;
|
||
}
|
||
showScreen(dashboardScreen);
|
||
startPolling();
|
||
} catch (err) {
|
||
loginError.textContent = "Ошибка сети";
|
||
loginError.classList.remove("hidden");
|
||
}
|
||
});
|
||
|
||
logoutBtn.addEventListener("click", async () => {
|
||
try {
|
||
await fetch("/api/logout", { method: "POST" });
|
||
} catch {}
|
||
stopPolling();
|
||
showScreen(loginScreen);
|
||
});
|
||
|
||
saveConfigBtn.addEventListener("click", async () => {
|
||
try {
|
||
const res = await fetch("/api/config", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ subscriptionUrl: subscriptionUrlInput.value }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
showMsg(configMsg, data.error || "Ошибка сохранения", "error");
|
||
return;
|
||
}
|
||
showMsg(configMsg, "Сохранено", "success");
|
||
} catch (err) {
|
||
showMsg(configMsg, "Ошибка сети", "error");
|
||
}
|
||
});
|
||
|
||
runBtn.addEventListener("click", async () => {
|
||
runMsg.classList.add("hidden");
|
||
try {
|
||
const res = await fetch("/api/run", { method: "POST" });
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
showMsg(runMsg, data.error || "Не удалось запустить", "error");
|
||
return;
|
||
}
|
||
showMsg(runMsg, "Парсер запущен", "success");
|
||
fetchStatus();
|
||
} catch (err) {
|
||
showMsg(runMsg, "Ошибка сети", "error");
|
||
}
|
||
});
|
||
|
||
fetch("/api/status")
|
||
.then((res) => {
|
||
if (res.ok) {
|
||
showScreen(dashboardScreen);
|
||
startPolling();
|
||
} else {
|
||
showScreen(loginScreen);
|
||
}
|
||
})
|
||
.catch(() => showScreen(loginScreen));
|