diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/public/app.js b/public/app.js index 4a62f6d..85a76ca 100644 --- a/public/app.js +++ b/public/app.js @@ -18,7 +18,7 @@ const xray2Section = $("#xray2-data-section"); const POLL_INTERVAL = 10000; let pollTimer = null; -let xray2Visible = false; +let pingResults = []; function showScreen(screen) { loginScreen.classList.add("hidden"); @@ -40,15 +40,16 @@ function formatTime(iso) { } const stepLabels = { - initializing: "Иніціализація...", - novavps_fetching: "Полученіе данныхъ NOVAVPS...", - xray1_parsing: "Чтеніе панели Xray 1...", - xray1_syncing: "Синхронизація Xray 1...", - xray2_parsing: "Чтеніе панели Xray 2...", - xray2_syncing: "Синхронизація Xray 2...", + initializing: "Инициализация...", + novavps_fetching: "Получение данных NOVAVPS...", + xray1_parsing: "Чтение панели Xray 1...", + xray1_syncing: "Синхронизация Xray 1...", + pinging: "Проверка соединений...", + xray2_parsing: "Чтение панели Xray 2...", + xray2_syncing: "Синхронизация Xray 2...", done: "Завершено", novavps_url_missing: "Ошибка: ссылка NOVAVPS не задана", - no_xray_panels: "Ошибка: нѣтъ настроенныхъ панелей Xray", + no_xray_panels: "Ошибка: нет настроенных панелей Xray", fatal: "Критическая ошибка", }; @@ -57,15 +58,15 @@ function updatePanelStatus(prefix, data) { const detail = $(`#${prefix}-detail`); const time = $(`#${prefix}-time`); - const statusMap = { idle: "ожиданіе", success: "успѣшно", error: "ошибка" }; + 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} подключеній`; + detailText = `${data.count} подключений`; } else if (data.outboundsCount != null) { - detailText = `${data.outboundsCount} исходящихъ`; + detailText = `${data.outboundsCount} исходящих`; if (data.synced) detailText += " · синхр."; } if (data.error) detailText += (detailText ? " · " : "") + data.error; @@ -73,6 +74,13 @@ function updatePanelStatus(prefix, data) { time.textContent = formatTime(data.timestamp); } +function formatLatency(latency) { + if (latency == null) return 'ошибка'; + if (latency < 100) return `${latency} мс`; + if (latency < 300) return `${latency} мс`; + return `${latency} мс`; +} + async function fetchStatus() { try { const res = await fetch("/api/status"); @@ -92,6 +100,8 @@ async function fetchStatus() { updatePanelStatus("xray1", data.xray1); updatePanelStatus("xray2", data.xray2); + pingResults = data.xray1?.pingResults || []; + lastRunEl.textContent = data.lastRun ? formatTime(data.lastRun) : "Никогда"; if (data.xray2.status !== "idle" || data.xray2.outboundsCount > 0) { @@ -134,10 +144,10 @@ async function fetchXrayData(panel, syncedTags) { const data = await res.json(); if (panel === "xray1") { - renderXrayTable("xray1", data, syncedTags || []); + renderXrayTable("xray1", data, syncedTags || [], pingResults); $("#xray1-data-count").textContent = data.length; } else if (panel === "xray2") { - renderXrayTable("xray2", data, syncedTags || []); + renderXrayTable("xray2", data, syncedTags || [], []); $("#xray2-data-count").textContent = data.length; } } catch {} @@ -146,7 +156,7 @@ async function fetchXrayData(panel, syncedTags) { function renderNovavpsTable(connections) { const tbody = $("#novavps-tbody"); if (!connections.length) { - tbody.innerHTML = 'Нѣтъ данныхъ'; + tbody.innerHTML = 'Нет данных'; return; } tbody.innerHTML = connections @@ -161,12 +171,19 @@ function renderNovavpsTable(connections) { .join(""); } -function renderXrayTable(prefix, outbounds, syncedTags) { +function renderXrayTable(prefix, outbounds, syncedTags, pingList) { const tbody = $(`#${prefix}-tbody`); + const hasLatency = prefix === "xray1"; + const colspan = hasLatency ? 6 : 5; + if (!outbounds.length) { - tbody.innerHTML = 'Нѣтъ данныхъ'; + tbody.innerHTML = `Нет данных`; return; } + + const pingMap = {}; + pingList.forEach((p) => { pingMap[p.tag] = p; }); + tbody.innerHTML = outbounds .map((o) => { const tag = escapeHtml(o.remark || o.tag || "—"); @@ -177,7 +194,14 @@ function renderXrayTable(prefix, outbounds, syncedTags) { const syncBadge = isSynced ? 'да' : ''; - return `${tag}${proto}${addr}${port}${syncBadge}`; + + let latencyCell = ''; + if (hasLatency && pingMap[tag]) { + latencyCell = formatLatency(pingMap[tag].latency); + } + + const latencyCol = hasLatency ? `${latencyCell}` : ""; + return `${tag}${proto}${addr}${port}${latencyCol}${syncBadge}`; }) .join(""); } @@ -239,14 +263,14 @@ loginForm.addEventListener("submit", async (e) => { }); const data = await res.json(); if (!res.ok) { - loginError.textContent = data.error || "Входъ невозможенъ"; + loginError.textContent = data.error || "Вход невозможен"; loginError.classList.remove("hidden"); return; } showScreen(dashboardScreen); startPolling(); } catch (err) { - loginError.textContent = "Ошибка сѣти"; + loginError.textContent = "Ошибка сети"; loginError.classList.remove("hidden"); } }); @@ -268,12 +292,12 @@ saveConfigBtn.addEventListener("click", async () => { }); const data = await res.json(); if (!res.ok) { - showMsg(configMsg, data.error || "Ошибка сохраненія", "error"); + showMsg(configMsg, data.error || "Ошибка сохранения", "error"); return; } showMsg(configMsg, "Сохранено", "success"); } catch (err) { - showMsg(configMsg, "Ошибка сѣти", "error"); + showMsg(configMsg, "Ошибка сети", "error"); } }); @@ -286,10 +310,10 @@ runBtn.addEventListener("click", async () => { showMsg(runMsg, data.error || "Не удалось запустить", "error"); return; } - showMsg(runMsg, "Парсеръ запущенъ", "success"); + showMsg(runMsg, "Парсер запущен", "success"); fetchStatus(); } catch (err) { - showMsg(runMsg, "Ошибка сѣти", "error"); + showMsg(runMsg, "Ошибка сети", "error"); } }); diff --git a/public/index.html b/public/index.html index 95f0506..09d62b7 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,7 @@ - VPN Parser — Панель управленія + VPN Parser — Панель управления @@ -33,9 +33,9 @@
- VPN Parser — Панель управленія + VPN Parser — Панель управления
- +
@@ -52,7 +52,7 @@
@@ -60,20 +60,20 @@
-

Статусъ панелей

+

Статус панелей

Novavps

-
ожиданіе
+
ожидание
@@ -82,7 +82,7 @@

Xray 1

-
ожиданіе
+
ожидание
@@ -91,7 +91,7 @@

Xray 2

-
ожиданіе
+
ожидание
@@ -100,7 +100,7 @@
-

Подключенія Novavps

+

Подключения Novavps

@@ -110,9 +110,9 @@ Имя - Протоколъ - Адресъ - Портъ + Протокол + Адрес + Порт Безопасность @@ -124,7 +124,7 @@
-

Исходящія Xray 1

+

Исходящие Xray 1

@@ -133,10 +133,11 @@ - - - - + + + + + @@ -148,7 +149,7 @@
МѣткаПротоколъАдресъПортъМеткаПротоколАдресПортЗадержка Синхр.
- - - - + + + + @@ -171,7 +172,7 @@
- Послѣдній запускъ: + Последний запуск: Никогда
diff --git a/public/style.css b/public/style.css index 59a29ba..4ce8d35 100644 --- a/public/style.css +++ b/public/style.css @@ -617,6 +617,28 @@ body::before { background: var(--text-muted); } +/* Latency */ +.latency-cell { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.latency-good { + color: var(--accent-green); +} + +.latency-fair { + color: var(--accent-amber); +} + +.latency-poor { + color: var(--accent-red); +} + +.latency-error { + color: var(--text-muted); +} + /* Last run */ .last-run-section { display: flex; diff --git a/src/data-store.ts b/src/data-store.ts index d36e112..c3a95e3 100644 --- a/src/data-store.ts +++ b/src/data-store.ts @@ -8,12 +8,23 @@ export interface AppConfig { novavpsUrl: string; } +export interface PingResult { + tag: string; + name: string; + address: string; + port: string; + latency: number | null; + error: string | null; +} + export interface PanelStatus { status: "idle" | "success" | "error"; count?: number; outboundsCount?: number; synced?: boolean; syncedOutbounds?: string[]; + pingResults?: PingResult[]; + allPingsOk?: boolean; timestamp: string | null; error: string | null; } @@ -39,7 +50,7 @@ const defaultState: AppState = { status: "idle", currentStep: "", novavps: { status: "idle", count: 0, timestamp: null, error: null }, - xray1: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], timestamp: null, error: null }, + xray1: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], pingResults: [], allPingsOk: false, timestamp: null, error: null }, xray2: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], timestamp: null, error: null }, }; @@ -90,7 +101,7 @@ export async function resetState(): Promise { status: "running", currentStep: "initializing", novavps: { status: "idle", count: 0, timestamp: null, error: null }, - xray1: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], timestamp: null, error: null }, + xray1: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], pingResults: [], allPingsOk: false, timestamp: null, error: null }, xray2: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], timestamp: null, error: null }, }; await saveState(state); diff --git a/src/ping.ts b/src/ping.ts new file mode 100644 index 0000000..9120e10 --- /dev/null +++ b/src/ping.ts @@ -0,0 +1,37 @@ +import net from "net"; + +export interface PingResult { + tag: string; + name: string; + address: string; + port: string; + latency: number | null; + error: string | null; +} + +export function tcpPing(host: string, port: number, timeout = 5000): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const socket = new net.Socket(); + + socket.setTimeout(timeout); + + socket.on("connect", () => { + const latency = Date.now() - start; + socket.destroy(); + resolve(latency); + }); + + socket.on("timeout", () => { + socket.destroy(); + resolve(null); + }); + + socket.on("error", () => { + socket.destroy(); + resolve(null); + }); + + socket.connect(port, host); + }); +} diff --git a/src/server.ts b/src/server.ts index 76b8872..08aa635 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import fs from "fs/promises"; import { parseNovavps, getValidConnections } from "./novavps"; import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel"; import { closeBrowser } from "./browser"; +import { tcpPing } from "./ping"; import { loadConfig, saveConfig, @@ -107,6 +108,7 @@ async function runParser(): Promise { isRunning = true; const state = await resetState(); + const existingNovavpsPath = path.resolve(__dirname, "..", "data", "novavps-connections.json"); try { const config = await loadConfig(process.env.NOVAVPS_URL || ""); @@ -133,11 +135,23 @@ async function runParser(): Promise { let novavpsConnections: Awaited> = []; try { novavpsConnections = await parseNovavps(novavpsUrl); + + if (novavpsConnections.length === 0) { + try { + const existingRaw = await fs.readFile(existingNovavpsPath, "utf-8"); + const existing = JSON.parse(existingRaw); + if (Array.isArray(existing) && existing.length > 0) { + console.log(`[novavps] Страница недоступна, сохранено ${existing.length} предыдущих подключений`); + novavpsConnections = existing; + } + } catch {} + } + state.novavps = { - status: "success", + status: novavpsConnections.length > 0 ? "success" : "error", count: novavpsConnections.length, timestamp: new Date().toISOString(), - error: null, + error: novavpsConnections.length === 0 ? "Нет данных" : null, }; } catch (err) { state.novavps = { @@ -163,15 +177,18 @@ async function runParser(): Promise { outboundsCount: xray.length, synced: false, syncedOutbounds: [], + pingResults: panelStateKey === "xray1" ? [] : undefined, + allPingsOk: panelStateKey === "xray1" ? false : undefined, timestamp: new Date().toISOString(), error: null, }; } catch (err) { - const errorState = { - status: "error" as const, + const errorState: import("./data-store").PanelStatus = { + status: "error", outboundsCount: 0, synced: false, syncedOutbounds: [], + allPingsOk: false, timestamp: new Date().toISOString(), error: err instanceof Error ? err.message : String(err), }; @@ -193,6 +210,29 @@ async function runParser(): Promise { const panelStateKey = panel.label as "xray1" | "xray2"; state[panelStateKey].synced = true; state[panelStateKey].syncedOutbounds = syncedTags; + + if (panel.label === "xray1") { + state.currentStep = "pinging"; + await saveState(state); + + const pingResults = []; + for (let i = 0; i < valid.length; i++) { + const conn = valid[i]; + const tag = `novavps${i + 1}`; + const port = parseInt(conn.port) || 443; + const latency = await tcpPing(conn.address, port); + pingResults.push({ + tag, + name: conn.name, + address: conn.address, + port: conn.port || String(port), + latency, + error: latency === null ? "Таймаут или ошибка" : null, + }); + } + state.xray1.pingResults = pingResults; + state.xray1.allPingsOk = pingResults.every((p) => p.latency !== null); + } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); if (panel.label === "xray1") { @@ -203,6 +243,31 @@ async function runParser(): Promise { } await saveState(state); } + + if (panel.label === "xray2" && valid.length > 0 && state.xray1?.allPingsOk === true) { + state.currentStep = "xray2_syncing"; + await saveState(state); + + try { + await syncNovavpsToXray(valid, panel.url, panel.username, panel.password, panel.label); + state.xray2.synced = true; + state.xray2.syncedOutbounds = valid.map((_, idx) => `novavps${idx + 1}`); + } catch (err) { + state.xray2.error = err instanceof Error ? err.message : String(err); + } + await saveState(state); + } else if (panel.label === "xray2" && valid.length > 0) { + console.log("[xray2] Пропускаю синхронизацию: не все пинги успешны"); + state.xray2 = { + status: "error", + outboundsCount: state.xray2.outboundsCount || 0, + synced: false, + syncedOutbounds: [], + timestamp: new Date().toISOString(), + error: "Пинг не пройден — синхронизация пропущена", + }; + await saveState(state); + } } state.status = "completed"; @@ -300,6 +365,15 @@ app.get("/api/data/xray", authMiddleware, async (_req: Request, res: Response) = (async () => { const config = await loadConfig(process.env.NOVAVPS_URL || ""); + // Сброс stale-состояния "running" после перезапуска сервера + const state = await loadState(); + if (state.status === "running") { + state.status = "idle"; + state.currentStep = ""; + await saveState(state); + console.log("[server] Сброшено состояние 'running' после перезапуска"); + } + app.listen(PORT, "0.0.0.0", () => { console.log(`[server] Web interface running on http://0.0.0.0:${PORT}`); console.log(`[server] NOVAVPS_URL: ${config.novavpsUrl || "(not set)"}`);
МѣткаПротоколъАдресъПортъМеткаПротоколАдресПорт Синхр.