Compare commits

...

2 Commits

Author SHA1 Message Date
22968084b4 v2 2026-05-21 22:58:44 +05:00
0cb111d6d3 v2 2026-05-21 22:09:45 +05:00
8 changed files with 261 additions and 59 deletions

View File

View File

@@ -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 '<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");
@@ -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 = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem;">Нѣтъ данныхъ</td></tr>';
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem;">Нет данных</td></tr>';
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 = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem;">Нѣтъ данныхъ</td></tr>';
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 || "—");
@@ -177,7 +194,14 @@ function renderXrayTable(prefix, outbounds, syncedTags) {
const syncBadge = isSynced
? '<span class="synced-badge yes">да</span>'
: '<span class="synced-badge no">—</span>';
return `<tr><td>${tag}</td><td>${proto}</td><td>${addr}</td><td>${port}</td><td>${syncBadge}</td></tr>`;
let latencyCell = '<span class="latency-cell latency-error">—</span>';
if (hasLatency && pingMap[tag]) {
latencyCell = formatLatency(pingMap[tag].latency);
}
const latencyCol = hasLatency ? `<td>${latencyCell}</td>` : "";
return `<tr><td>${tag}</td><td>${proto}</td><td>${addr}</td><td>${port}</td>${latencyCol}<td>${syncBadge}</td></tr>`;
})
.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");
}
});

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VPN Parser — Панель управленія</title>
<title>VPN Parser — Панель управления</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
@@ -33,9 +33,9 @@
<header class="topbar">
<div class="topbar-left">
<div class="logo-icon small"></div>
<span class="topbar-title">VPN Parser — Панель управленія</span>
<span class="topbar-title">VPN Parser — Панель управления</span>
</div>
<button id="logout-btn" class="btn btn-ghost">Выходъ</button>
<button id="logout-btn" class="btn btn-ghost">Выход</button>
</header>
<main class="dashboard">
@@ -52,7 +52,7 @@
<section class="card actions-card">
<button id="run-btn" class="btn btn-success btn-lg">
<span class="btn-icon"></span>
Обновить сейчасъ
Обновить сейчас
</button>
<div id="run-msg" class="msg hidden"></div>
</section>
@@ -60,20 +60,20 @@
<section id="running-indicator" class="card running-card hidden">
<div class="spinner"></div>
<div class="running-text">
<span class="running-label">Выполненіе</span>
<span id="current-step" class="running-step">Иніціализація...</span>
<span class="running-label">Выполнение</span>
<span id="current-step" class="running-step">Инициализация...</span>
</div>
</section>
<section class="status-section">
<h2 class="section-title">Статусъ панелей</h2>
<h2 class="section-title">Статус панелей</h2>
<div class="status-grid">
<div class="status-card" id="novavps-card">
<div class="status-card-header">
<span class="panel-icon novavps-icon"></span>
<h3>Novavps</h3>
</div>
<div class="status-badge" id="novavps-status">ожиданіе</div>
<div class="status-badge" id="novavps-status">ожидание</div>
<div class="status-detail" id="novavps-detail"></div>
<div class="status-time" id="novavps-time"></div>
</div>
@@ -82,7 +82,7 @@
<span class="panel-icon xray-icon"></span>
<h3>Xray 1</h3>
</div>
<div class="status-badge" id="xray1-status">ожиданіе</div>
<div class="status-badge" id="xray1-status">ожидание</div>
<div class="status-detail" id="xray1-detail"></div>
<div class="status-time" id="xray1-time"></div>
</div>
@@ -91,7 +91,7 @@
<span class="panel-icon xray-icon"></span>
<h3>Xray 2</h3>
</div>
<div class="status-badge" id="xray2-status">ожиданіе</div>
<div class="status-badge" id="xray2-status">ожидание</div>
<div class="status-detail" id="xray2-detail"></div>
<div class="status-time" id="xray2-time"></div>
</div>
@@ -100,7 +100,7 @@
<section class="card data-section" id="novavps-data-section">
<div class="data-section-header" onclick="toggleSection('novavps-data')">
<h2>Подключенія Novavps</h2>
<h2>Подключения Novavps</h2>
<span class="data-count" id="novavps-data-count"></span>
<span class="collapse-arrow" id="novavps-data-arrow"></span>
</div>
@@ -110,9 +110,9 @@
<thead>
<tr>
<th>Имя</th>
<th>Протоколъ</th>
<th>Адресъ</th>
<th>Портъ</th>
<th>Протокол</th>
<th>Адрес</th>
<th>Порт</th>
<th>Безопасность</th>
</tr>
</thead>
@@ -124,7 +124,7 @@
<section class="card data-section" id="xray1-data-section">
<div class="data-section-header" onclick="toggleSection('xray1-data')">
<h2>Исходящія Xray 1</h2>
<h2>Исходящие Xray 1</h2>
<span class="data-count" id="xray1-data-count"></span>
<span class="collapse-arrow" id="xray1-data-arrow"></span>
</div>
@@ -133,10 +133,11 @@
<table class="data-table" id="xray1-table">
<thead>
<tr>
<th>Мѣтка</th>
<th>Протоколъ</th>
<th>Адресъ</th>
<th>Портъ</th>
<th>Метка</th>
<th>Протокол</th>
<th>Адрес</th>
<th>Порт</th>
<th>Задержка</th>
<th>Синхр.</th>
</tr>
</thead>
@@ -148,7 +149,7 @@
<section class="card data-section hidden" id="xray2-data-section">
<div class="data-section-header" onclick="toggleSection('xray2-data')">
<h2>Исходящія Xray 2</h2>
<h2>Исходящие Xray 2</h2>
<span class="data-count" id="xray2-data-count"></span>
<span class="collapse-arrow" id="xray2-data-arrow"></span>
</div>
@@ -157,10 +158,10 @@
<table class="data-table" id="xray2-table">
<thead>
<tr>
<th>Мѣтка</th>
<th>Протоколъ</th>
<th>Адресъ</th>
<th>Портъ</th>
<th>Метка</th>
<th>Протокол</th>
<th>Адрес</th>
<th>Порт</th>
<th>Синхр.</th>
</tr>
</thead>
@@ -171,7 +172,7 @@
</section>
<section class="last-run-section">
<span class="last-run-label">Послѣдній запускъ:</span>
<span class="last-run-label">Последний запуск:</span>
<span id="last-run" class="last-run-value">Никогда</span>
</section>
</main>

View File

@@ -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;

View File

@@ -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<AppState> {
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);

37
src/ping.ts Normal file
View File

@@ -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<number | null> {
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);
});
}

View File

@@ -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<void> {
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<void> {
let novavpsConnections: Awaited<ReturnType<typeof parseNovavps>> = [];
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<void> {
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<void> {
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<void> {
}
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)"}`);

View File

@@ -316,6 +316,9 @@ export async function syncNovavpsToXray(
}
const outbounds = (config.outbounds as Record<string, unknown>[]) || [];
console.log(`[${panelLabel}] Всего outbound'ов в конфиге ДО: ${outbounds.length}`);
const existingNovavpsTags = outbounds.filter((o) => String(o.tag || "").startsWith("novavps"));
console.log(`[${panelLabel}] Из них с тегом novavps*: ${existingNovavpsTags.length}`);
connections.forEach((conn, idx) => {
const tag = `novavps${idx + 1}`;
@@ -324,15 +327,17 @@ export async function syncNovavpsToXray(
const existingIdx = outbounds.findIndex((o) => o.tag === tag);
if (existingIdx >= 0) {
outbounds[existingIdx] = newOutbound;
console.log(`[${panelLabel}] Outbound ${tag} обновлён`);
console.log(`[${panelLabel}] Outbound ${tag} обновлён (${conn.address})`);
} else {
outbounds.push(newOutbound);
console.log(`[${panelLabel}] Outbound ${tag} добавлен`);
console.log(`[${panelLabel}] Outbound ${tag} добавлен (${conn.address})`);
}
});
config.outbounds = outbounds;
const modifiedJson = JSON.stringify(config, null, 2);
const novavpsCountAfter = (outbounds.filter((o) => String(o.tag || "").startsWith("novavps"))).length;
console.log(`[${panelLabel}] После модификации: всего outbound'ов ${outbounds.length}, novavps*: ${novavpsCountAfter}`);
await page.evaluate((json) => {
const cm = document.querySelector(".CodeMirror") as unknown as { CodeMirror?: { setValue: (v: string) => void } };
@@ -350,7 +355,7 @@ export async function syncNovavpsToXray(
});
}, modifiedJson);
await page.waitForTimeout(500);
await page.waitForTimeout(1500);
const saved = await page.evaluate(() => {
let clicked = false;
@@ -366,13 +371,41 @@ export async function syncNovavpsToXray(
});
if (saved) {
console.log(`[${panelLabel}] Конфиг сохранён`);
await page.waitForTimeout(2000);
console.log(`[${panelLabel}] Кнопка 'Сохранить' нажата, жду сохранения...`);
await page.waitForTimeout(3000);
await page.waitForLoadState("networkidle");
} else {
console.log(`[${panelLabel}] Кнопка 'Сохранить' не найдена`);
}
// Верификация — читаем JSON из редактора и проверяем novavps-теги
console.log(`[${panelLabel}] Проверка сохранённого JSON...`);
const verifyJson = await page.evaluate(() => {
const cm = document.querySelector(".CodeMirror") as unknown as { CodeMirror?: { getValue: () => string } };
if (cm?.CodeMirror) return cm.CodeMirror.getValue();
const ta = document.querySelector("textarea");
return ta?.value || "";
});
if (verifyJson) {
try {
const verifyConfig = JSON.parse(verifyJson);
const verifyOutbounds = (verifyConfig.outbounds as Record<string, unknown>[]) || [];
const verifyNovavps = verifyOutbounds.filter((o) => String(o.tag || "").startsWith("novavps"));
console.log(`[${panelLabel}] После сохранения: outbound'ов ${verifyOutbounds.length}, novavps*: ${verifyNovavps.length}`);
verifyNovavps.forEach((o) => {
const settings = (o as Record<string, unknown>).settings as Record<string, unknown> | undefined;
const vnext = settings?.vnext as Record<string, unknown>[] | undefined;
const addr = vnext?.[0]?.address as string | undefined;
console.log(`[${panelLabel}] ${o.tag}: ${addr || "N/A"}`);
});
} catch (e) {
console.log(`[${panelLabel}] Ошибка парсинга JSON при верификации: ${e}`);
}
} else {
console.log(`[${panelLabel}] Не удалось прочитать JSON после сохранения`);
}
await page.goto(`${baseUrl}/panel/`, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(3000);