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 @@
- Выполненіе
- Иніціализація...
+ Выполнение
+ Инициализация...
- Статусъ панелей
+ Статус панелей
- ожиданіе
+ ожидание
@@ -91,7 +91,7 @@
Xray 2
- ожиданіе
+ ожидание
@@ -100,7 +100,7 @@
@@ -110,9 +110,9 @@
| Имя |
- Протоколъ |
- Адресъ |
- Портъ |
+ Протокол |
+ Адрес |
+ Порт |
Безопасность |
@@ -124,7 +124,7 @@
@@ -133,10 +133,11 @@
- | Мѣтка |
- Протоколъ |
- Адресъ |
- Портъ |
+ Метка |
+ Протокол |
+ Адрес |
+ Порт |
+ Задержка |
Синхр. |
@@ -148,7 +149,7 @@
@@ -157,10 +158,10 @@
- | Мѣтка |
- Протоколъ |
- Адресъ |
- Портъ |
+ Метка |
+ Протокол |
+ Адрес |
+ Порт |
Синхр. |
@@ -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)"}`);