Add shaping, Prometheus, monitor tab and peer audit
Baseline TypeScript version before the Go rewrite: CAKE fair sharing with automatic deprioritisation of heavy devices, per-key priority, /metrics with a metrics token, the built-in Prometheus install/remove, the Monitor tab, grouped sidebar tabs and the dead-peer audit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,6 @@ yarn-error.log*
|
|||||||
# Все markdown, кроме README.md
|
# Все markdown, кроме README.md
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
# Go: бинарники и прогоны дифференциального стенда
|
||||||
|
tools/diffbench/bin/
|
||||||
|
tools/diffbench/out/
|
||||||
|
|||||||
+234
-11
@@ -1,15 +1,16 @@
|
|||||||
// Copyright (c) 2026 Ivan Vasilev
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, closeSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { execSync, spawnSync } from "child_process";
|
import { execSync, spawnSync, execFile, spawn } from "child_process";
|
||||||
import * as zlib from "zlib";
|
import * as zlib from "zlib";
|
||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import winston from "winston";
|
import winston from "winston";
|
||||||
|
import { createShaping } from "./shaping";
|
||||||
|
|
||||||
const logsDir = path.join(process.cwd(), "logs");
|
const logsDir = path.join(process.cwd(), "logs");
|
||||||
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
||||||
@@ -575,6 +576,26 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
|
|||||||
} catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; }
|
} catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Шейпинг (CAKE) и сэмплер тяжёлых устройств. Счётчики читаем асинхронно: цикл
|
||||||
|
// не должен блокировать event loop, как это делают остальные вызовы awg.
|
||||||
|
const shaping = createShaping({
|
||||||
|
iface: CONFIG.interface,
|
||||||
|
getCfg, setCfg, logger,
|
||||||
|
// CAKE видит только внутренний IP-пакет, на проводе к нему добавляются
|
||||||
|
// IPv4 (20) + UDP (8) + заголовок WG (32) = 60 байт и случайный префикс S4.
|
||||||
|
// Формулу сверять iperf3-замером на стенде (SHAPE_OVERHEAD перекрывает её).
|
||||||
|
overhead: () => 60 + (Number(CONFIG.awgParams.S4) || 0),
|
||||||
|
peers: () => new Promise((resolve, reject) => {
|
||||||
|
execFile("awg", ["show", CONFIG.interface, "dump"], { encoding: "utf8", timeout: 10_000 }, (err, out) => {
|
||||||
|
if (err) { reject(err); return; }
|
||||||
|
const rows = out.split("\n").slice(1).map(l => l.split("\t"))
|
||||||
|
.filter(f => f[0] && f[3])
|
||||||
|
.map(f => ({ ip: f[3].split(",")[0].split("/")[0], rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 }));
|
||||||
|
resolve(rows);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
function rebuildConf() {
|
function rebuildConf() {
|
||||||
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
|
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
|
||||||
const devices = stmts.peersAll.all();
|
const devices = stmts.peersAll.all();
|
||||||
@@ -680,6 +701,7 @@ function restartAwg() {
|
|||||||
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
|
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
|
||||||
const gen = reloadAwgParams();
|
const gen = reloadAwgParams();
|
||||||
syncPeers();
|
syncPeers();
|
||||||
|
void shaping.apply(); // tc-правила жили на интерфейсе, который мы только что пересоздали
|
||||||
const versions = readAwgVersions();
|
const versions = readAwgVersions();
|
||||||
CONFIG.awgModule = versions.module;
|
CONFIG.awgModule = versions.module;
|
||||||
CONFIG.awgTools = versions.tools;
|
CONFIG.awgTools = versions.tools;
|
||||||
@@ -803,6 +825,7 @@ function startInterface() {
|
|||||||
const r = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
const r = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
||||||
if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`);
|
if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`);
|
||||||
syncPeers();
|
syncPeers();
|
||||||
|
void shaping.apply();
|
||||||
return getInterfaceStatus();
|
return getInterfaceStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,12 +872,30 @@ function addUser(username: string): UserRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Снять пира с интерфейса и убедиться, что его там нет. Раньше результат `awg set … remove`
|
||||||
|
// не проверялся: строку из базы удаляли, а пир в ядре оставался живым, и про него уже никто не
|
||||||
|
// знал. Теперь порядок такой: сначала ядро, потом база — при отказе строка остаётся, и
|
||||||
|
// повторная попытка (надгробие панели, следующий sync) найдёт пира.
|
||||||
|
function kernelPeers(): Set<string> | null {
|
||||||
|
const r = spawnSync("awg", ["show", CONFIG.interface, "peers"]);
|
||||||
|
return r.status === 0 ? new Set(r.stdout.toString().split("\n").map(l => l.trim()).filter(Boolean)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropFromKernel(pub: string): boolean {
|
||||||
|
const r = spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
|
||||||
|
if (r.status === 0) return true;
|
||||||
|
const left = kernelPeers();
|
||||||
|
if (left && !left.has(pub)) return true; // ответ странный, но пира уже нет
|
||||||
|
logger.error("peer remove from kernel failed", { pub, stderr: r.stderr?.toString() });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function removeUser(username: string) {
|
function removeUser(username: string) {
|
||||||
const user = stmts.get.get(username);
|
const user = stmts.get.get(username);
|
||||||
if (!user) throw new Error("Пользователь не найден");
|
if (!user) throw new Error("Пользователь не найден");
|
||||||
|
|
||||||
|
if (!dropFromKernel(user.pub_key)) throw new Error("Не удалось снять пира с интерфейса");
|
||||||
stmts.delete.run(username);
|
stmts.delete.run(username);
|
||||||
spawnSync("awg", ["set", CONFIG.interface, "peer", user.pub_key, "remove"]);
|
|
||||||
rebuildConf();
|
rebuildConf();
|
||||||
|
|
||||||
for (const ext of [".conf", ".key"]) {
|
for (const ext of [".conf", ".key"]) {
|
||||||
@@ -920,7 +961,9 @@ function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: str
|
|||||||
stmts.peerRekey.run(oldPub, newPub);
|
stmts.peerRekey.run(oldPub, newPub);
|
||||||
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||||
}
|
}
|
||||||
spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]);
|
// Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа не откатываем:
|
||||||
|
// новый уже стоит, а клиент к этому моменту получил бы ошибку.
|
||||||
|
if (!dropFromKernel(oldPub)) logger.error("peer rekey: старый ключ остался в ядре", { owner: old.owner });
|
||||||
rebuildConf();
|
rebuildConf();
|
||||||
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
|
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
|
||||||
return { ip: old.ip, psk_key: old.psk_key };
|
return { ip: old.ip, psk_key: old.psk_key };
|
||||||
@@ -941,8 +984,8 @@ function rotatePeerPsk(pub: string): { ip: string; psk_key: string } {
|
|||||||
function removePeer(pub: string) {
|
function removePeer(pub: string) {
|
||||||
const peer = stmts.peerGet.get(pub);
|
const peer = stmts.peerGet.get(pub);
|
||||||
if (!peer) throw new HttpError(404, "Пир не найден");
|
if (!peer) throw new HttpError(404, "Пир не найден");
|
||||||
|
if (!dropFromKernel(pub)) throw new Error("Не удалось снять пира с интерфейса");
|
||||||
stmts.peerDelete.run(pub);
|
stmts.peerDelete.run(pub);
|
||||||
spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
|
|
||||||
rebuildConf();
|
rebuildConf();
|
||||||
logger.info("peer removed", { owner: peer.owner });
|
logger.info("peer removed", { owner: peer.owner });
|
||||||
}
|
}
|
||||||
@@ -964,18 +1007,15 @@ function syncOwnedPeers(panel: string, keep: string[]): {
|
|||||||
const prefix = panel + "/";
|
const prefix = panel + "/";
|
||||||
const removed: PeerRow[] = [];
|
const removed: PeerRow[] = [];
|
||||||
let claimed = 0;
|
let claimed = 0;
|
||||||
db.transaction(() => {
|
|
||||||
for (const p of stmts.peersAll.all()) {
|
for (const p of stmts.peersAll.all()) {
|
||||||
if (p.owner.startsWith(prefix)) {
|
if (p.owner.startsWith(prefix)) {
|
||||||
if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
|
// Ядро первым: не снялся — строка остаётся, следующий sync попробует снова.
|
||||||
|
if (!want.has(p.pub_key) && dropFromKernel(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
|
||||||
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
|
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
|
||||||
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
|
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
|
||||||
claimed++;
|
claimed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]);
|
|
||||||
if (removed.length || claimed) rebuildConf();
|
if (removed.length || claimed) rebuildConf();
|
||||||
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
|
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
|
||||||
|
|
||||||
@@ -992,7 +1032,9 @@ const app = express();
|
|||||||
const SYNC_PATH = "/api/peers/sync";
|
const SYNC_PATH = "/api/peers/sync";
|
||||||
const jsonSmall = express.json({ limit: "1kb" });
|
const jsonSmall = express.json({ limit: "1kb" });
|
||||||
const jsonSync = express.json({ limit: "512kb" });
|
const jsonSync = express.json({ limit: "512kb" });
|
||||||
app.use((req, res, next) => (req.path === SYNC_PATH ? jsonSync : jsonSmall)(req, res, next));
|
// /api/audit/clean тоже несёт до SYNC_MAX ключей — под 1kb он принимал бы около двадцати.
|
||||||
|
const BIG_BODY_PATHS = new Set([SYNC_PATH, "/api/audit/clean"]);
|
||||||
|
app.use((req, res, next) => (BIG_BODY_PATHS.has(req.path) ? jsonSync : jsonSmall)(req, res, next));
|
||||||
|
|
||||||
function verifyInternalToken(token: string): boolean {
|
function verifyInternalToken(token: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -1075,6 +1117,8 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
|
|||||||
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
|
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
|
||||||
rx: peers[u.pub_key]?.rx ?? 0,
|
rx: peers[u.pub_key]?.rx ?? 0,
|
||||||
tx: peers[u.pub_key]?.tx ?? 0,
|
tx: peers[u.pub_key]?.tx ?? 0,
|
||||||
|
// «normal» | «background»: фоновый — пока канал занят, а устройство давно качает больше своей доли
|
||||||
|
priority: shaping.priorityOf(u.ip),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
@@ -1111,10 +1155,43 @@ app.get("/api/peers", auth, handler((_req, res) => {
|
|||||||
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
|
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
|
||||||
rx: stats[p.pub_key]?.rx ?? 0,
|
rx: stats[p.pub_key]?.rx ?? 0,
|
||||||
tx: stats[p.pub_key]?.tx ?? 0,
|
tx: stats[p.pub_key]?.tx ?? 0,
|
||||||
|
priority: shaping.priorityOf(p.ip),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять их
|
||||||
|
// после сбоя `awg set … remove` некому. Только чтение — панель (core) сама решает, что делать.
|
||||||
|
app.get("/api/audit", auth, handler((_req, res) => {
|
||||||
|
const r = spawnSync("awg", ["show", CONFIG.interface, "dump"]);
|
||||||
|
if (r.status !== 0) throw new HttpError(503, "awg show не ответил");
|
||||||
|
const known = new Set<string>([
|
||||||
|
...(db.prepare("SELECT pub_key FROM users").all() as { pub_key: string }[]).map(u => u.pub_key),
|
||||||
|
...stmts.peersAll.all().map(p => p.pub_key),
|
||||||
|
]);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const ghosts = r.stdout.toString().split("\n").slice(1).map(l => l.split("\t")).filter(f => f[0] && !known.has(f[0])).map(f => {
|
||||||
|
const lastHandshake = Number(f[4]) || 0;
|
||||||
|
return { pub_key: f[0], lastHandshake, online: lastHandshake > 0 && now - lastHandshake < 180, rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 };
|
||||||
|
});
|
||||||
|
res.json({ ghosts });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется: пир, который
|
||||||
|
// успел появиться в users/peers, не трогаем. Строк в базе у «призраков» нет — снимается только ядро.
|
||||||
|
app.post("/api/audit/clean", auth, handler((req, res) => {
|
||||||
|
const { pub_keys } = (req.body ?? {}) as { pub_keys?: unknown };
|
||||||
|
if (!Array.isArray(pub_keys) || pub_keys.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
|
||||||
|
const removed: string[] = [], skipped: string[] = [];
|
||||||
|
for (const k of pub_keys.map(checkPubKey)) {
|
||||||
|
const known = stmts.peerGet.get(k) || (db.prepare("SELECT 1 FROM users WHERE pub_key = ?").get(k));
|
||||||
|
if (known || k === getServerPublicKey() || !kernelPeers()?.has(k)) { skipped.push(k); continue; }
|
||||||
|
(dropFromKernel(k) ? removed : skipped).push(k);
|
||||||
|
}
|
||||||
|
if (removed.length) logger.warn("ghost peers removed from kernel", { count: removed.length, removed });
|
||||||
|
res.json({ removed, skipped });
|
||||||
|
}));
|
||||||
|
|
||||||
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
|
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
|
||||||
app.put(SYNC_PATH, auth, handler((req, res) => {
|
app.put(SYNC_PATH, auth, handler((req, res) => {
|
||||||
const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown };
|
const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown };
|
||||||
@@ -1175,6 +1252,148 @@ app.post("/awg/upgrade", auth, handler((_req, res) => {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не пропустит
|
||||||
|
// (и без PATCH: агент отвечает на него 400).
|
||||||
|
app.get("/api/shaping", auth, handler(async (_req, res) => {
|
||||||
|
res.json(await shaping.status());
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.put("/api/shaping", auth, handler(async (req, res) => {
|
||||||
|
const b = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const mbit = (v: unknown, what: string): number => {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n > 100_000) throw new HttpError(400, `${what}: целое число Мбит/с от 1 до 100000`);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
if (typeof b.enabled !== "boolean" || typeof b.auto !== "boolean")
|
||||||
|
throw new HttpError(400, "enabled и auto должны быть true/false");
|
||||||
|
const downMbit = mbit(b.downMbit ?? 0, "Загрузка");
|
||||||
|
const upMbit = mbit(b.upMbit ?? 0, "Отдача");
|
||||||
|
if (b.enabled && (downMbit < 1 || upMbit < 1))
|
||||||
|
throw new HttpError(400, "Для включения укажите ширину канала в обе стороны");
|
||||||
|
try {
|
||||||
|
await shaping.update({ enabled: b.enabled, auto: b.auto, downMbit, upMbit });
|
||||||
|
} catch (e) {
|
||||||
|
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
|
||||||
|
throw new HttpError(500, e instanceof Error ? e.message : "Не удалось применить шейпинг");
|
||||||
|
}
|
||||||
|
res.json(await shaping.status());
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
|
||||||
|
// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с —
|
||||||
|
// несколько скрейперов не должны гонять `awg show` чаще.
|
||||||
|
const METRICS_TTL_MS = 5_000;
|
||||||
|
let metricsCache: { at: number; data: Promise<unknown> } | null = null;
|
||||||
|
|
||||||
|
function linkCounter(name: string): number {
|
||||||
|
try { return Number(readFileSync(`/sys/class/net/${CONFIG.interface}/statistics/${name}`, "utf8")) || 0; }
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у node_exporter): долю считает
|
||||||
|
// Prometheus через rate(), здесь мгновенного «процента» нет. Память — MemAvailable, а не MemFree: кэш ядра свободен.
|
||||||
|
const CPU_MODES = ["user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"] as const;
|
||||||
|
function readHost() {
|
||||||
|
try {
|
||||||
|
const first = readFileSync("/proc/stat", "utf8").split("\n")[0].trim().split(/\s+/).slice(1).map(Number);
|
||||||
|
const cpu: Record<string, number> = {};
|
||||||
|
CPU_MODES.forEach((m, i) => { cpu[m] = (first[i] || 0) / 100; }); // USER_HZ = 100 на всех обычных ядрах
|
||||||
|
const mem = readFileSync("/proc/meminfo", "utf8");
|
||||||
|
const kb = (k: string) => Number(mem.match(new RegExp(`^${k}:\\s+(\\d+) kB`, "m"))?.[1] ?? 0) * 1024;
|
||||||
|
return {
|
||||||
|
cpu, cores: os.cpus().length, load: os.loadavg(),
|
||||||
|
mem: { total: kb("MemTotal"), available: kb("MemAvailable") },
|
||||||
|
};
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectMetrics() {
|
||||||
|
const st = getInterfaceStatus();
|
||||||
|
const stats = st.up ? getPeersData() : {};
|
||||||
|
const list: { kind: "user" | "device"; name: string; ip: string; online: boolean; rx: number; tx: number; priority: string }[] = [
|
||||||
|
...(db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[]).map(u => ({
|
||||||
|
kind: "user" as const, name: u.name, ip: u.ip,
|
||||||
|
online: stats[u.pub_key]?.online ?? false, rx: stats[u.pub_key]?.rx ?? 0, tx: stats[u.pub_key]?.tx ?? 0,
|
||||||
|
priority: shaping.priorityOf(u.ip),
|
||||||
|
})),
|
||||||
|
...stmts.peersAll.all().map(p => ({
|
||||||
|
kind: "device" as const, name: "", ip: p.ip,
|
||||||
|
online: stats[p.pub_key]?.online ?? false, rx: stats[p.pub_key]?.rx ?? 0, tx: stats[p.pub_key]?.tx ?? 0,
|
||||||
|
priority: shaping.priorityOf(p.ip),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const all = Object.values(stats);
|
||||||
|
return {
|
||||||
|
up: st.up,
|
||||||
|
gen: CONFIG.gen,
|
||||||
|
peers: { total: st.peers, online: all.filter(p => p.online).length },
|
||||||
|
// С точки зрения сервера: rx — принято от клиентов (их отдача), tx — отправлено клиентам (их загрузка).
|
||||||
|
rx_bytes: all.reduce((a, p) => a + p.rx, 0),
|
||||||
|
tx_bytes: all.reduce((a, p) => a + p.tx, 0),
|
||||||
|
link: {
|
||||||
|
rx_bytes: linkCounter("rx_bytes"), tx_bytes: linkCounter("tx_bytes"),
|
||||||
|
rx_packets: linkCounter("rx_packets"), tx_packets: linkCounter("tx_packets"),
|
||||||
|
rx_dropped: linkCounter("rx_dropped"), tx_dropped: linkCounter("tx_dropped"),
|
||||||
|
rx_errors: linkCounter("rx_errors"), tx_errors: linkCounter("tx_errors"),
|
||||||
|
},
|
||||||
|
shaping: await shaping.status(),
|
||||||
|
host: readHost(),
|
||||||
|
list,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/metrics", auth, handler(async (_req, res) => {
|
||||||
|
if (!metricsCache || Date.now() - metricsCache.at > METRICS_TTL_MS) {
|
||||||
|
const data = collectMetrics();
|
||||||
|
metricsCache = { at: Date.now(), data };
|
||||||
|
data.catch(() => { metricsCache = null; }); // ошибку не кэшируем
|
||||||
|
}
|
||||||
|
res.json(await metricsCache.data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие одно из двух и без
|
||||||
|
// параметров: awg-ctrl запускает ту же команду, что оператор набрал бы руками (`awg-ctrl monitor install|remove`,
|
||||||
|
// root), так что код один. Панель сама пакетов не ставит и не удаляет — она только просит об этом здесь.
|
||||||
|
const MONITOR_CLI = process.env.AWG_CLI ?? "/usr/local/bin/awg-ctrl";
|
||||||
|
const MONITOR_LOG = process.env.MONITOR_LOG ?? path.resolve(process.cwd(), "..", "logs", "monitor-job.log");
|
||||||
|
type MonitorAction = "install" | "remove";
|
||||||
|
let monitorJob: { action: MonitorAction; startedAt: number; exit: number | null } | null = null;
|
||||||
|
|
||||||
|
const logTail = (): string => {
|
||||||
|
try {
|
||||||
|
const t = readFileSync(MONITOR_LOG, "utf8").slice(-4000);
|
||||||
|
// apt рисует прогресс возвратом каретки и цветами — в панели это мусор
|
||||||
|
return t.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\r/g, "\n");
|
||||||
|
} catch { return ""; }
|
||||||
|
};
|
||||||
|
|
||||||
|
function startMonitorJob(action: MonitorAction, res: Response) {
|
||||||
|
if (monitorJob && monitorJob.exit === null) { res.status(409).json({ error: "Операция уже идёт" }); return; }
|
||||||
|
if (!existsSync(MONITOR_CLI)) throw new HttpError(501, `CLI не найден (${MONITOR_CLI}): выполните на сервере awg-ctrl monitor ${action}`);
|
||||||
|
mkdirSync(path.dirname(MONITOR_LOG), { recursive: true });
|
||||||
|
const fd = openSync(MONITOR_LOG, "w");
|
||||||
|
const job = { action, startedAt: Date.now(), exit: null as number | null };
|
||||||
|
monitorJob = job;
|
||||||
|
const done = (code: number) => { job.exit = code; try { closeSync(fd); } catch { /* уже закрыт */ } };
|
||||||
|
const child = spawn(MONITOR_CLI, ["monitor", action], { stdio: ["ignore", fd, fd] });
|
||||||
|
child.on("error", e => { logger.error("monitor: не запустился", { action, error: e.message }); done(127); });
|
||||||
|
child.on("exit", code => { logger.info("monitor: завершён", { action, code }); done(code ?? 1); });
|
||||||
|
res.status(202).json({ running: true, action });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/api/monitor/install", auth, handler((_req, res) => { startMonitorJob("install", res); }));
|
||||||
|
app.post("/api/monitor/remove", auth, handler((_req, res) => { startMonitorJob("remove", res); }));
|
||||||
|
|
||||||
|
app.get("/api/monitor/job", auth, handler((_req, res) => {
|
||||||
|
res.json({
|
||||||
|
action: monitorJob?.action ?? null,
|
||||||
|
running: !!monitorJob && monitorJob.exit === null,
|
||||||
|
exit: monitorJob?.exit ?? null,
|
||||||
|
log: monitorJob ? logTail() : "",
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
app.get("/awg/status", auth, handler((_req, res) => {
|
app.get("/awg/status", auth, handler((_req, res) => {
|
||||||
res.json(getInterfaceStatus());
|
res.json(getInterfaceStatus());
|
||||||
}));
|
}));
|
||||||
@@ -1188,3 +1407,7 @@ app.listen(SERVER.port, "127.0.0.1", () => {
|
|||||||
});
|
});
|
||||||
ensureInterfaceUp();
|
ensureInterfaceUp();
|
||||||
syncPeers();
|
syncPeers();
|
||||||
|
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
|
||||||
|
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
|
||||||
|
void shaping.apply();
|
||||||
|
shaping.start();
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
|
||||||
|
//
|
||||||
|
// Схема:
|
||||||
|
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
|
||||||
|
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
|
||||||
|
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
|
||||||
|
//
|
||||||
|
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
|
||||||
|
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому apply() зовётся
|
||||||
|
// на старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
|
||||||
|
//
|
||||||
|
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по
|
||||||
|
// времени, ни по неактивности (правило проекта: удаление только по явному списку
|
||||||
|
// от панели).
|
||||||
|
import { execFile, spawn } from "child_process";
|
||||||
|
import type { Logger } from "winston";
|
||||||
|
|
||||||
|
export interface ShapingSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
downMbit: number;
|
||||||
|
upMbit: number;
|
||||||
|
auto: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerCounters {
|
||||||
|
ip: string;
|
||||||
|
rx: number; // принято от клиента (его отдача)
|
||||||
|
tx: number; // отправлено клиенту (его загрузка)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShapingDeps {
|
||||||
|
iface: string;
|
||||||
|
getCfg: (key: string, fallback: string) => string;
|
||||||
|
setCfg: (key: string, value: string) => void;
|
||||||
|
logger: Pick<Logger, "info" | "warn" | "error">;
|
||||||
|
// Байт на пакет, которые туннель добавляет на проводе (CAKE видит только внутренний IP-пакет).
|
||||||
|
overhead: () => number;
|
||||||
|
peers: () => Promise<PeerCounters[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QdiscStats { packets: number; bytes: number; drops: number; backlog: number }
|
||||||
|
|
||||||
|
export interface ShapingStatus {
|
||||||
|
settings: ShapingSettings;
|
||||||
|
active: boolean; // CAKE реально стоит на интерфейсе
|
||||||
|
congested: boolean;
|
||||||
|
heavy: string[]; // IP устройств в bulk-классе
|
||||||
|
devices: number; // активных устройств в окне
|
||||||
|
rateDown: number; // Мбит/с за последний тик
|
||||||
|
rateUp: number;
|
||||||
|
down?: QdiscStats;
|
||||||
|
up?: QdiscStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Окна и пороги. Переопределение через env — только для тестов на стенде.
|
||||||
|
const num = (v: string | undefined, d: number) => (v && Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
|
||||||
|
const SAMPLE_S = num(process.env.SHAPE_SAMPLE_S, 10);
|
||||||
|
const WINDOW_S = num(process.env.SHAPE_WINDOW_S, 600);
|
||||||
|
const CONGEST_ON = 0.85; // доля ширины, выше которой канал считается перегруженным
|
||||||
|
const CONGEST_OFF = 0.70; // и ниже которой перегрузка снимается (гистерезис)
|
||||||
|
const ACTIVE_MBIT = 0.1; // меньше — устройство не считается активным
|
||||||
|
const HEAVY_MULT = 1.5; // тяжёлое: ≥ 1.5 честных долей…
|
||||||
|
const HEAVY_MIN_FRAC = 0.05; // …и не меньше 5% ширины канала
|
||||||
|
const CHECK_EVERY = 3; // страховочная проверка qdisc — раз в N тиков
|
||||||
|
|
||||||
|
const IFB = "ifb-awg";
|
||||||
|
const NFT_TABLE = "awg_shape";
|
||||||
|
|
||||||
|
function exec(cmd: string, args: string[]): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(cmd, args, { encoding: "utf8", timeout: 15_000 }, (err, stdout, stderr) => {
|
||||||
|
if (err) reject(new Error(`${cmd} ${args.join(" ")}: ${(stderr || err.message).toString().trim()}`));
|
||||||
|
else resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function execStdin(cmd: string, args: string[], input: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const p = spawn(cmd, args, { stdio: ["pipe", "ignore", "pipe"] });
|
||||||
|
let err = "";
|
||||||
|
p.stderr.on("data", d => { err += d; });
|
||||||
|
p.stdin.on("error", () => undefined); // процесс умер раньше, чем дочитал stdin — причина придёт в close
|
||||||
|
p.on("error", reject);
|
||||||
|
p.on("close", code => code === 0 ? resolve() : reject(new Error(`${cmd}: ${err.trim() || `exit ${code}`}`)));
|
||||||
|
p.stdin.end(input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tick { dt: number; bytes: number }
|
||||||
|
|
||||||
|
export function createShaping(deps: ShapingDeps) {
|
||||||
|
const { iface, logger } = deps;
|
||||||
|
|
||||||
|
// ───────────── настройки ─────────────
|
||||||
|
function settings(): ShapingSettings {
|
||||||
|
return {
|
||||||
|
enabled: deps.getCfg("shapeEnabled", "0") === "1",
|
||||||
|
downMbit: Number(deps.getCfg("shapeDownMbit", "0")) || 0,
|
||||||
|
upMbit: Number(deps.getCfg("shapeUpMbit", "0")) || 0,
|
||||||
|
auto: deps.getCfg("shapeAuto", "1") === "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(s: ShapingSettings) {
|
||||||
|
deps.setCfg("shapeEnabled", s.enabled ? "1" : "0");
|
||||||
|
deps.setCfg("shapeDownMbit", String(s.downMbit));
|
||||||
|
deps.setCfg("shapeUpMbit", String(s.upMbit));
|
||||||
|
deps.setCfg("shapeAuto", s.auto ? "1" : "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── применение ─────────────
|
||||||
|
// Все изменения tc/nft идут строго по одному: два параллельных apply() дали бы
|
||||||
|
// полусобранные qdisc.
|
||||||
|
let chain: Promise<unknown> = Promise.resolve();
|
||||||
|
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
|
||||||
|
const r = chain.then(fn, fn);
|
||||||
|
chain = r.catch(() => undefined);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastError = "";
|
||||||
|
const noteError = (e: unknown) => {
|
||||||
|
const m = e instanceof Error ? e.message : String(e);
|
||||||
|
if (m !== lastError) logger.warn("shaping: " + m); // одинаковую ошибку в лог не спамим
|
||||||
|
lastError = m;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cakeOverhead = () => {
|
||||||
|
const o = Number(process.env.SHAPE_OVERHEAD);
|
||||||
|
return Number.isFinite(o) && o >= 0 && process.env.SHAPE_OVERHEAD ? o : deps.overhead();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function nftApply(heavy: string[]) {
|
||||||
|
const elements = heavy.length ? `\n elements = { ${heavy.join(", ")} }` : "";
|
||||||
|
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
|
||||||
|
await execStdin("nft", ["-f", "-"], `
|
||||||
|
add table inet ${NFT_TABLE}
|
||||||
|
delete table inet ${NFT_TABLE}
|
||||||
|
table inet ${NFT_TABLE} {
|
||||||
|
set heavy {
|
||||||
|
type ipv4_addr${elements}
|
||||||
|
}
|
||||||
|
chain postrouting {
|
||||||
|
type filter hook postrouting priority -150; policy accept;
|
||||||
|
oifname "${iface}" ip daddr @heavy ip dscp set cs1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasCake(dev: string): Promise<boolean> {
|
||||||
|
try { return /\bcake\b/.test(await exec("tc", ["qdisc", "show", "dev", dev, "root"])); }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasIngressRedirect(): Promise<boolean> {
|
||||||
|
try { return /mirred/.test(await exec("tc", ["filter", "show", "dev", iface, "parent", "ffff:"])); }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyNow() {
|
||||||
|
const s = settings();
|
||||||
|
if (!s.enabled || s.downMbit <= 0 || s.upMbit <= 0) { await removeNow(); return; }
|
||||||
|
const oh = cakeOverhead();
|
||||||
|
|
||||||
|
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
|
||||||
|
await exec("tc", ["qdisc", "replace", "dev", iface, "root", "cake",
|
||||||
|
"bandwidth", `${s.downMbit}mbit`, "diffserv3", "dual-dsthost", "overhead", String(oh)]);
|
||||||
|
|
||||||
|
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
|
||||||
|
try { await exec("ip", ["link", "show", IFB]); }
|
||||||
|
catch { await exec("ip", ["link", "add", IFB, "type", "ifb"]); }
|
||||||
|
await exec("ip", ["link", "set", IFB, "up"]);
|
||||||
|
await exec("tc", ["qdisc", "replace", "dev", IFB, "root", "cake",
|
||||||
|
"bandwidth", `${s.upMbit}mbit`, "besteffort", "dual-srchost", "overhead", String(oh)]);
|
||||||
|
if (!(await hasIngressRedirect())) {
|
||||||
|
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
|
||||||
|
await exec("tc", ["qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"]);
|
||||||
|
await exec("tc", ["filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
|
||||||
|
"matchall", "action", "mirred", "egress", "redirect", "dev", IFB]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nftApply([...heavySet]);
|
||||||
|
lastError = "";
|
||||||
|
logger.info("shaping applied", { down: s.downMbit, up: s.upMbit, overhead: oh, heavy: heavySet.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeNow() {
|
||||||
|
await exec("tc", ["qdisc", "del", "dev", iface, "root"]).catch(() => undefined);
|
||||||
|
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
|
||||||
|
await exec("ip", ["link", "del", IFB]).catch(() => undefined);
|
||||||
|
await exec("nft", ["delete", "table", "inet", NFT_TABLE]).catch(() => undefined);
|
||||||
|
heavySet.clear(); history.clear(); totals.length = 0; congested = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от ensureInterfaceUp).
|
||||||
|
function apply(): Promise<void> {
|
||||||
|
return serial(applyNow).catch(noteError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(s: ShapingSettings): Promise<void> {
|
||||||
|
save(s);
|
||||||
|
await serial(applyNow); // пользователю нужна честная ошибка, а не молчаливый warn
|
||||||
|
lastError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── сэмплер (RFC 6057) ─────────────
|
||||||
|
const prev = new Map<string, { rx: number; tx: number }>();
|
||||||
|
const history = new Map<string, Tick[]>(); // загрузка по IP
|
||||||
|
const totals: Tick[] = []; // суммарная загрузка (всё, что ушло клиентам)
|
||||||
|
const heavySet = new Set<string>();
|
||||||
|
let congested = false;
|
||||||
|
let lastRateDown = 0, lastRateUp = 0;
|
||||||
|
let lastTickAt = 0;
|
||||||
|
let tickNo = 0;
|
||||||
|
const windowTicks = Math.max(3, Math.round(WINDOW_S / SAMPLE_S));
|
||||||
|
|
||||||
|
const push = (arr: Tick[], t: Tick) => { arr.push(t); while (arr.length > windowTicks) arr.shift(); };
|
||||||
|
const mbit = (arr: Tick[]) => {
|
||||||
|
const dt = arr.reduce((a, t) => a + t.dt, 0);
|
||||||
|
return dt > 0 ? arr.reduce((a, t) => a + t.bytes, 0) * 8 / dt / 1e6 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
const s = settings();
|
||||||
|
if (!s.enabled) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const dt = lastTickAt ? (now - lastTickAt) / 1000 : SAMPLE_S;
|
||||||
|
lastTickAt = now;
|
||||||
|
tickNo++;
|
||||||
|
|
||||||
|
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
|
||||||
|
if (tickNo % CHECK_EVERY === 0 && !(await hasCake(iface))) {
|
||||||
|
logger.warn("shaping: qdisc на интерфейсе пропал, применяю заново");
|
||||||
|
await apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
let peers: PeerCounters[];
|
||||||
|
try { peers = await deps.peers(); } catch (e) { noteError(e); return; }
|
||||||
|
|
||||||
|
let totalDown = 0, totalUp = 0;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const p of peers) {
|
||||||
|
seen.add(p.ip);
|
||||||
|
const before = prev.get(p.ip);
|
||||||
|
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира → отрицательная дельта = 0.
|
||||||
|
const dTx = before && p.tx >= before.tx ? p.tx - before.tx : 0;
|
||||||
|
const dRx = before && p.rx >= before.rx ? p.rx - before.rx : 0;
|
||||||
|
prev.set(p.ip, { rx: p.rx, tx: p.tx });
|
||||||
|
totalDown += dTx; totalUp += dRx;
|
||||||
|
let h = history.get(p.ip);
|
||||||
|
if (!h) history.set(p.ip, h = []);
|
||||||
|
push(h, { dt, bytes: dTx });
|
||||||
|
}
|
||||||
|
for (const ip of [...prev.keys()]) if (!seen.has(ip)) { prev.delete(ip); history.delete(ip); heavySet.delete(ip); }
|
||||||
|
push(totals, { dt, bytes: totalDown });
|
||||||
|
lastRateDown = totalDown * 8 / dt / 1e6;
|
||||||
|
lastRateUp = totalUp * 8 / dt / 1e6;
|
||||||
|
|
||||||
|
if (!s.auto) {
|
||||||
|
if (heavySet.size || congested) { heavySet.clear(); congested = false; await serial(() => nftApply([])).catch(noteError); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (totals.length < windowTicks) return; // пока окно не набралось, решений не принимаем
|
||||||
|
|
||||||
|
const avg = mbit(totals);
|
||||||
|
const wasCongested = congested;
|
||||||
|
if (!congested && avg >= CONGEST_ON * s.downMbit) congested = true;
|
||||||
|
if ( congested && avg < CONGEST_OFF * s.downMbit) congested = false;
|
||||||
|
|
||||||
|
const nextHeavy = new Set<string>();
|
||||||
|
let active = 0;
|
||||||
|
if (congested) {
|
||||||
|
const rates = [...history.entries()].filter(([, h]) => h.length >= windowTicks).map(([ip, h]) => [ip, mbit(h)] as const);
|
||||||
|
active = rates.filter(([, r]) => r >= ACTIVE_MBIT).length;
|
||||||
|
const share = s.downMbit / Math.max(1, active);
|
||||||
|
for (const [ip, r] of rates) {
|
||||||
|
const wasHeavy = heavySet.has(ip);
|
||||||
|
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
|
||||||
|
if (r >= Math.max(HEAVY_MULT * share, HEAVY_MIN_FRAC * s.downMbit) || (wasHeavy && r >= share)) nextHeavy.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = nextHeavy.size !== heavySet.size || [...nextHeavy].some(ip => !heavySet.has(ip));
|
||||||
|
if (changed || wasCongested !== congested) {
|
||||||
|
heavySet.clear(); nextHeavy.forEach(ip => heavySet.add(ip));
|
||||||
|
logger.info("shaping: класс тяжёлых обновлён", { congested, avgMbit: Math.round(avg), heavy: [...heavySet] });
|
||||||
|
await serial(() => nftApply([...heavySet])).catch(noteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
function start() {
|
||||||
|
if (timer) return;
|
||||||
|
let busy = false;
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (busy) return; // предыдущий тик ещё идёт — пропускаем, а не копим очередь
|
||||||
|
busy = true;
|
||||||
|
tick().catch(noteError).finally(() => { busy = false; });
|
||||||
|
}, SAMPLE_S * 1000);
|
||||||
|
timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── состояние ─────────────
|
||||||
|
async function qdiscStats(dev: string): Promise<QdiscStats | undefined> {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(await exec("tc", ["-s", "-j", "qdisc", "show", "dev", dev, "root"])) as Record<string, unknown>[];
|
||||||
|
const q = arr.find(x => x.kind === "cake");
|
||||||
|
if (!q) return undefined;
|
||||||
|
return {
|
||||||
|
packets: Number(q.packets) || 0, bytes: Number(q.bytes) || 0,
|
||||||
|
drops: Number(q.drops) || 0, backlog: Number(q.backlog) || 0,
|
||||||
|
};
|
||||||
|
} catch { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function status(): Promise<ShapingStatus> {
|
||||||
|
const s = settings();
|
||||||
|
const [active, down, up] = s.enabled
|
||||||
|
? await Promise.all([hasCake(iface), qdiscStats(iface), qdiscStats(IFB)])
|
||||||
|
: [false, undefined, undefined];
|
||||||
|
return {
|
||||||
|
settings: s, active, congested, heavy: [...heavySet],
|
||||||
|
devices: [...history.values()].filter(h => mbit(h) >= ACTIVE_MBIT).length,
|
||||||
|
rateDown: Math.round(lastRateDown * 10) / 10,
|
||||||
|
rateUp: Math.round(lastRateUp * 10) / 10,
|
||||||
|
down, up,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текущий приоритет устройства по его внутреннему IP: «фоновый» — пока он в bulk-классе.
|
||||||
|
const priorityOf = (ip: string): "normal" | "background" => (heavySet.has(ip) ? "background" : "normal");
|
||||||
|
|
||||||
|
return { settings, update, apply, start, status, priorityOf };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Shaping = ReturnType<typeof createShaping>;
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
//
|
||||||
|
// Prometheus: GET /metrics на ядре. Метрики ядра и всех нод собираются здесь же —
|
||||||
|
// у нод через уже открытый WSS (awg-agent пропускает только /api/*, поэтому на самих
|
||||||
|
// нодах отдельного порта для скрейпа не нужно). Доступ — отдельный токен «только
|
||||||
|
// чтение метрик»; он не открывает ни /api/v1, ни панель.
|
||||||
|
import crypto from "crypto";
|
||||||
|
import fs from "fs";
|
||||||
|
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||||
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
|
interface ServerInfo {
|
||||||
|
id: number; name: string; local: boolean; online: boolean; pending: boolean;
|
||||||
|
version: string | null;
|
||||||
|
health: { gen: string; module: string; tools: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricsDeps {
|
||||||
|
uidb: Database.Database;
|
||||||
|
version: string;
|
||||||
|
servers: {
|
||||||
|
list: () => ServerInfo[];
|
||||||
|
isOnline: (id: number) => boolean;
|
||||||
|
ctrlFor: (id: number) => (method: string, urlPath: string) => Promise<{ status: number; data: any }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRAPE_DEADLINE_MS = 5_000;
|
||||||
|
const TOUCH_EVERY_S = 60; // last_used пишем не чаще раза в минуту, а не на каждый скрейп
|
||||||
|
|
||||||
|
const hashToken = (t: string) => crypto.createHash("sha256").update(t).digest();
|
||||||
|
|
||||||
|
// Токен встроенного Prometheus (`awg-ctrl monitor install`): сам токен лежит только у Prometheus,
|
||||||
|
// здесь — его хэш. Файл читаем при каждой проверке, пересчитывая по mtime: установка не требует перезапуска панели.
|
||||||
|
const LOCAL_HASH_FILE = process.env.MONITOR_HASH_FILE ?? "/etc/amnezia/amneziawg/monitor_token.sha256";
|
||||||
|
let localHash: { mtime: number; hash: Buffer | null } = { mtime: -1, hash: null };
|
||||||
|
function localTokenHash(): Buffer | null {
|
||||||
|
try {
|
||||||
|
const mtime = fs.statSync(LOCAL_HASH_FILE).mtimeMs;
|
||||||
|
if (mtime !== localHash.mtime) {
|
||||||
|
const hex = fs.readFileSync(LOCAL_HASH_FILE, "utf8").trim();
|
||||||
|
localHash = { mtime, hash: /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null };
|
||||||
|
}
|
||||||
|
return localHash.hash;
|
||||||
|
} catch { localHash = { mtime: -1, hash: null }; return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пер-пировые серии (метка peer = внутренний IP, name = имя vpn://-пользователя) выключены:
|
||||||
|
// это и приватность, и кардинальность. Включается явно.
|
||||||
|
const PER_PEER = (process.env.METRICS_PEERS ?? "off").toLowerCase() === "on";
|
||||||
|
|
||||||
|
type Labels = Record<string, string | number>;
|
||||||
|
type Sample = [Labels, number];
|
||||||
|
|
||||||
|
const esc = (v: string | number) => String(v).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
|
||||||
|
const fmtLabels = (l: Labels) => {
|
||||||
|
const parts = Object.entries(l).map(([k, v]) => `${k}="${esc(v)}"`);
|
||||||
|
return parts.length ? `{${parts.join(",")}}` : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
class Exposition {
|
||||||
|
private lines: string[] = [];
|
||||||
|
private seen = new Set<string>();
|
||||||
|
add(name: string, type: "gauge" | "counter", help: string, samples: Sample[]) {
|
||||||
|
if (!samples.length) return;
|
||||||
|
if (!this.seen.has(name)) {
|
||||||
|
this.seen.add(name);
|
||||||
|
this.lines.push(`# HELP ${name} ${help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n")}`, `# TYPE ${name} ${type}`);
|
||||||
|
}
|
||||||
|
for (const [l, v] of samples) this.lines.push(`${name}${fmtLabels(l)} ${Number.isFinite(v) ? v : 0}`);
|
||||||
|
}
|
||||||
|
toString() { return this.lines.join("\n") + "\n"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMetrics(deps: MetricsDeps) {
|
||||||
|
const { uidb } = deps;
|
||||||
|
|
||||||
|
uidb.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS metrics_token (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
token_hash BLOB NOT NULL,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used INTEGER
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const q = {
|
||||||
|
get: uidb.prepare("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1"),
|
||||||
|
set: uidb.prepare("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)"),
|
||||||
|
del: uidb.prepare("DELETE FROM metrics_token WHERE id = 1"),
|
||||||
|
touch: uidb.prepare("UPDATE metrics_token SET last_used = ? WHERE id = 1"),
|
||||||
|
};
|
||||||
|
type TokenRow = { token_hash: Buffer; prefix: string; created_at: number; last_used: number | null };
|
||||||
|
|
||||||
|
// ── Управление токеном (роуты панели, за JWT) ──
|
||||||
|
const router = Router();
|
||||||
|
const view = () => {
|
||||||
|
const r = q.get.get() as TokenRow | undefined;
|
||||||
|
return r ? { exists: true, prefix: r.prefix, created_at: r.created_at, last_used: r.last_used, per_peer: PER_PEER }
|
||||||
|
: { exists: false, per_peer: PER_PEER };
|
||||||
|
};
|
||||||
|
router.get("/", (_req: Request, res: Response) => { res.json(view()); });
|
||||||
|
// Выпуск и перевыпуск — одно и то же: старый токен перестаёт работать. Значение отдаётся один раз.
|
||||||
|
router.post("/", (_req: Request, res: Response) => {
|
||||||
|
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
|
||||||
|
q.set.run(hashToken(token), token.slice(0, 13) + "…", Math.floor(Date.now() / 1000));
|
||||||
|
res.status(201).json({ ...view(), token });
|
||||||
|
});
|
||||||
|
router.delete("/", (_req: Request, res: Response) => { q.del.run(); res.json(view()); });
|
||||||
|
|
||||||
|
// ── Доступ к /metrics ──
|
||||||
|
// Нет токена или он неверный — одинаковый 401: не подсказываем, выключены метрики или токен не тот.
|
||||||
|
function requireToken(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const h = req.headers.authorization ?? "";
|
||||||
|
const given = h.startsWith("Bearer ") ? h.slice(7) : "";
|
||||||
|
const row = q.get.get() as TokenRow | undefined;
|
||||||
|
const gh = given.startsWith("awgm_") ? hashToken(given) : null;
|
||||||
|
const panelOk = !!row && !!gh && crypto.timingSafeEqual(gh, row.token_hash);
|
||||||
|
const local = localTokenHash();
|
||||||
|
const localOk = !!gh && !!local && crypto.timingSafeEqual(gh, local);
|
||||||
|
if (!panelOk && !localOk) { res.status(401).set("WWW-Authenticate", "Bearer").type("text/plain").send("unauthorized\n"); return; }
|
||||||
|
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (panelOk && row && (!row.last_used || now - row.last_used >= TOUCH_EVERY_S)) q.touch.run(now);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
|
||||||
|
async function scrapeServer(s: ServerInfo): Promise<any | null> {
|
||||||
|
if (s.pending || !deps.servers.isOnline(s.id)) return null;
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
const r = await Promise.race([
|
||||||
|
deps.servers.ctrlFor(s.id)("GET", "/api/metrics"),
|
||||||
|
new Promise<never>((_, rej) => { timer = setTimeout(() => rej(new Error("deadline")), SCRAPE_DEADLINE_MS); }),
|
||||||
|
]);
|
||||||
|
return r.status === 200 ? r.data : null;
|
||||||
|
} catch { return null; }
|
||||||
|
finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(): Promise<string> {
|
||||||
|
const t0 = process.hrtime.bigint();
|
||||||
|
const list = deps.servers.list();
|
||||||
|
const data = await Promise.all(list.map(scrapeServer));
|
||||||
|
const out = new Exposition();
|
||||||
|
const L = (s: ServerInfo, extra: Labels = {}): Labels => ({ server: s.name, server_id: s.id, ...extra });
|
||||||
|
// Серии только тех серверов, чьи метрики получены; fn всегда отдаёт массив серий.
|
||||||
|
const per = (fn: (s: ServerInfo, d: any) => Sample[]): Sample[] =>
|
||||||
|
list.flatMap((s, i) => (data[i] ? fn(s, data[i]) : []));
|
||||||
|
|
||||||
|
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", [[{ version: deps.version }, 1]]);
|
||||||
|
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", [[{}, list.length]]);
|
||||||
|
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", list.map(s => [L(s), s.online ? 1 : 0] as Sample));
|
||||||
|
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", list.map((s, i) => [L(s), data[i] ? 1 : 0] as Sample));
|
||||||
|
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.",
|
||||||
|
list.filter(s => !s.pending).map(s => [L(s, { version: s.version ?? "", gen: s.health?.gen ?? "", module: s.health?.module ?? "", tools: s.health?.tools ?? "" }), 1] as Sample));
|
||||||
|
|
||||||
|
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per((s, d) => [[L(s), d.up ? 1 : 0] as Sample]));
|
||||||
|
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per((s, d) => [[L(s), d.peers?.total ?? 0] as Sample]));
|
||||||
|
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per((s, d) => [[L(s), d.peers?.online ?? 0] as Sample]));
|
||||||
|
// Сумма по пирам — gauge, а не counter: при удалении пира или смене его ключа сумма уменьшается,
|
||||||
|
// и rate() принял бы это за сброс счётчика с ложным всплеском. Для скорости берите awg_link_*_bytes_total.
|
||||||
|
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.rx_bytes ?? 0] as Sample]));
|
||||||
|
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.tx_bytes ?? 0] as Sample]));
|
||||||
|
for (const [k, name, help] of [
|
||||||
|
["rx_bytes", "receive_bytes", "байт"], ["tx_bytes", "transmit_bytes", "байт"],
|
||||||
|
["rx_packets", "receive_packets", "пакетов"], ["tx_packets", "transmit_packets", "пакетов"],
|
||||||
|
["rx_dropped", "receive_dropped", "потерянных пакетов"], ["tx_dropped", "transmit_dropped", "потерянных пакетов"],
|
||||||
|
["rx_errors", "receive_errors", "ошибок"], ["tx_errors", "transmit_errors", "ошибок"],
|
||||||
|
] as const) {
|
||||||
|
out.add(`awg_link_${name}_total`, "counter", `Интерфейс awg1: ${help} (${k}).`, per((s, d) => [[L(s), d.link?.[k] ?? 0] as Sample]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нагрузка хоста (ядро и ноды). CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
|
||||||
|
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
|
||||||
|
per((s, d) => Object.entries(d.host?.cpu ?? {}).map(([mode, v]) => [L(s, { mode }), Number(v)] as Sample)));
|
||||||
|
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per((s, d) => d.host ? [[L(s), d.host.cores ?? 0] as Sample] : []));
|
||||||
|
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.total ?? 0] as Sample] : []));
|
||||||
|
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.available ?? 0] as Sample] : []));
|
||||||
|
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).",
|
||||||
|
per((s, d) => d.host ? (["1m", "5m", "15m"] as const).map((p, i) => [L(s, { period: p }), d.host.load?.[i] ?? 0] as Sample) : []));
|
||||||
|
|
||||||
|
// Деление канала (CAKE)
|
||||||
|
const sh = (fn: (d: any) => number) => per((s, d) => d.shaping ? [[L(s), fn(d.shaping)] as Sample] : []);
|
||||||
|
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(x => x.settings?.enabled ? 1 : 0));
|
||||||
|
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(x => x.active ? 1 : 0));
|
||||||
|
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(x => x.congested ? 1 : 0));
|
||||||
|
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(x => x.heavy?.length ?? 0));
|
||||||
|
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(x => x.devices ?? 0));
|
||||||
|
const dirs = (fn: (x: any, dir: "down" | "up") => number | undefined) =>
|
||||||
|
per((s, d) => d.shaping ? (["down", "up"] as const).flatMap(dir => {
|
||||||
|
const v = fn(d.shaping, dir);
|
||||||
|
return v === undefined ? [] : [[L(s, { direction: dir }), v] as Sample];
|
||||||
|
}) : []);
|
||||||
|
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs((x, dir) => (dir === "down" ? x.settings?.downMbit : x.settings?.upMbit) * 1e6));
|
||||||
|
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs((x, dir) => (dir === "down" ? x.rateDown : x.rateUp) * 1e6));
|
||||||
|
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs((x, dir) => x[dir]?.drops));
|
||||||
|
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs((x, dir) => x[dir]?.backlog));
|
||||||
|
|
||||||
|
if (PER_PEER) {
|
||||||
|
const peers = (fn: (p: any) => number) => per((s, d) => (d.list ?? []).map((p: any) =>
|
||||||
|
[L(s, { peer: p.ip, name: p.name ?? "", kind: p.kind }), fn(p)] as Sample));
|
||||||
|
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(p => p.online ? 1 : 0));
|
||||||
|
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(p => p.priority === "background" ? 1 : 0));
|
||||||
|
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(p => p.rx));
|
||||||
|
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(p => p.tx));
|
||||||
|
}
|
||||||
|
|
||||||
|
let masters = 0, devices = 0;
|
||||||
|
// Таблицы создаёт sub: готовим запросы здесь, а не при создании модуля, — порядок
|
||||||
|
// инициализации в server.ts не должен решать, упадёт ли панель на старте.
|
||||||
|
try {
|
||||||
|
masters = (uidb.prepare("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").get() as { n: number }).n;
|
||||||
|
devices = (uidb.prepare("SELECT COUNT(*) AS n FROM devices").get() as { n: number }).n;
|
||||||
|
} catch { /* sub ещё не создал таблицы */ }
|
||||||
|
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", [[{}, masters]]);
|
||||||
|
out.add("awg_devices", "gauge", "Устройств sen://.", [[{}, devices]]);
|
||||||
|
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", [[{}, Number(process.hrtime.bigint() - t0) / 1e9]]);
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(_req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
res.type("text/plain; version=0.0.4; charset=utf-8").send(await render());
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`metrics: ${(e as Error).message}`);
|
||||||
|
res.status(500).type("text/plain").send("error\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { router, requireToken, handler };
|
||||||
|
}
|
||||||
+98
-2
@@ -57,7 +57,7 @@ const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семь
|
|||||||
const signPub = crypto.randomBytes(32);
|
const signPub = crypto.randomBytes(32);
|
||||||
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
||||||
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
||||||
const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
|
const devOut = (m: MK, d: Dev) => ({ ...d, priority: shapingOf[0]?.enabled && d.id === masters[0]?.devs[0]?.id ? "background" : "normal", servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
|
||||||
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
|
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
|
||||||
|
|
||||||
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
|
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
|
||||||
@@ -95,8 +95,91 @@ app.delete("/ui/nodes/:id", (q, r) => {
|
|||||||
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
|
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
|
||||||
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
|
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
|
||||||
|
|
||||||
|
// Монитор: «установка» и «удаление» Prometheus с ходом процесса (как лог CLI на реальном сервере).
|
||||||
|
const MON_LOGS = {
|
||||||
|
install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 15d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."],
|
||||||
|
remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."],
|
||||||
|
};
|
||||||
|
let monJob: { action: "install" | "remove"; start: number } | null = null;
|
||||||
|
let monReady = false;
|
||||||
|
const MON_MS = 6000;
|
||||||
|
const monRunning = () => !!monJob && Date.now() - monJob.start < MON_MS;
|
||||||
|
app.get("/ui/monitor", (_q, r) => {
|
||||||
|
if (monJob && !monRunning()) monReady = monJob.action === "install";
|
||||||
|
let job = null;
|
||||||
|
if (monJob) {
|
||||||
|
const lines = MON_LOGS[monJob.action];
|
||||||
|
const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length;
|
||||||
|
job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") };
|
||||||
|
}
|
||||||
|
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", job });
|
||||||
|
});
|
||||||
|
for (const action of ["install", "remove"] as const) {
|
||||||
|
app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); });
|
||||||
|
}
|
||||||
|
app.get("/ui/monitor/overview", (q, r) => {
|
||||||
|
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300] };
|
||||||
|
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
|
||||||
|
const to = Math.floor(Date.now() / step) * step, from = to - sec;
|
||||||
|
const pts = (f: (i: number, t: number) => number): [number, number][] => Array.from({ length: Math.floor(sec / step) + 1 }, (_, i) => [from + i * step, Math.max(0, f(i, from + i * step))]);
|
||||||
|
const wave = (t: number, per: number, ph = 0) => Math.sin((t / per) * 2 * Math.PI + ph);
|
||||||
|
const per = sec / 3;
|
||||||
|
const down = pts((_, t) => 110e6 + 70e6 * wave(t, per) + 25e6 * wave(t, per / 7) + rnd(12e6));
|
||||||
|
const up = pts((_, t) => 14e6 + 7e6 * wave(t, per, 1) + rnd(3e6));
|
||||||
|
const online = pts((_, t) => Math.round(9 + 3 * wave(t, per * 1.6) + (rnd(100) > 90 ? 1 : 0)));
|
||||||
|
const heavy = pts((_, t) => (wave(t, per) > 0.55 ? 1 : 0));
|
||||||
|
const drops = pts((_, t) => (wave(t, per) > 0.55 ? 3 + rnd(6) : rnd(10) > 8 ? 0.4 : 0));
|
||||||
|
const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6));
|
||||||
|
const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2));
|
||||||
|
const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]);
|
||||||
|
// Один сервер — тот, что выбран в сервер-баре (?server=<id>): ядро (0) или нода
|
||||||
|
const id = Number(q.query.server ?? 0);
|
||||||
|
const k = id === 0 ? 0.7 : 0.3;
|
||||||
|
const down1 = scale(down, k), up1 = scale(up, k);
|
||||||
|
if (id === 3) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] } });
|
||||||
|
r.json({ range: q.query.range ?? "1h", step, from, to,
|
||||||
|
servers: [{ id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: down1.at(-1)![1], up: up1.at(-1)![1], online: id === 0 ? 7 : 3,
|
||||||
|
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
|
||||||
|
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30 }],
|
||||||
|
total: { down: down1, up: up1, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
|
||||||
|
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prometheus: токен метрик и сам /metrics (без проверки токена — мок).
|
||||||
|
let promTok: { prefix: string; created_at: number; last_used: number | null } | null = null;
|
||||||
|
const promView = () => promTok ? { exists: true, ...promTok, per_peer: false } : { exists: false, per_peer: false };
|
||||||
|
app.get("/ui/metrics-token", (_q, r) => r.json(promView()));
|
||||||
|
app.post("/ui/metrics-token", (_q, r) => {
|
||||||
|
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
|
||||||
|
promTok = { prefix: token.slice(0, 13) + "…", created_at: now(), last_used: null };
|
||||||
|
r.status(201).json({ ...promView(), token });
|
||||||
|
});
|
||||||
|
app.delete("/ui/metrics-token", (_q, r) => { promTok = null; r.json(promView()); });
|
||||||
|
app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n'));
|
||||||
|
|
||||||
|
// Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»).
|
||||||
|
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = {};
|
||||||
|
const shapingView = (id: number) => {
|
||||||
|
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
|
||||||
|
const on = settings.enabled;
|
||||||
|
return {
|
||||||
|
settings, active: on, congested: on && id === 0, heavy: on && id === 0 ? ["10.9.0.2"] : [],
|
||||||
|
devices: on ? 7 : 0, rateDown: on ? Math.round(settings.downMbit * 0.9 * 10) / 10 : 0, rateUp: on ? 12.4 : 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
app.get("/api/shaping", (q, r) => srv(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(shapingView(srv(q))));
|
||||||
|
app.put("/api/shaping", (q, r) => {
|
||||||
|
const id = srv(q), b = q.body ?? {};
|
||||||
|
if (id === 1) return r.status(404).json({ error: "Not found" });
|
||||||
|
if (b.enabled && !(b.downMbit >= 1 && b.upMbit >= 1)) return r.status(400).json({ error: "Для включения укажите ширину канала в обе стороны" });
|
||||||
|
shapingOf[id] = { enabled: !!b.enabled, downMbit: Number(b.downMbit) || 0, upMbit: Number(b.upMbit) || 0, auto: !!b.auto };
|
||||||
|
r.json(shapingView(id));
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
|
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
|
||||||
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) }));
|
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
|
||||||
|
const prioOf = (q: express.Request, ip: string) => (srv(q) === 0 && shapingOf[0]?.enabled && ip === "10.9.0.2" ? "background" : "normal");
|
||||||
|
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => ({ ...u, priority: prioOf(q, u.ip) })) }));
|
||||||
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
|
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
|
||||||
app.post("/api/users", (q, r) => {
|
app.post("/api/users", (q, r) => {
|
||||||
const users = U_(q);
|
const users = U_(q);
|
||||||
@@ -119,6 +202,19 @@ app.patch("/ui/apikeys/:id", (q, r) => {
|
|||||||
});
|
});
|
||||||
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
|
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
|
||||||
|
|
||||||
|
let mockAuditSettings = { clean: false, every_h: 1, max: 10 };
|
||||||
|
let mockCleaned: unknown[] = [];
|
||||||
|
let mockGhost = true;
|
||||||
|
const mockAudit = () => ({ enabled: true, settings: mockAuditSettings, cleaned: mockCleaned, report: { at: Math.floor(Date.now() / 1000) - 600, servers: [
|
||||||
|
{ server_id: 0, status: "ok", ghosts_checked: true, foreign: 0, orphans: [], ghosts: mockGhost ? [
|
||||||
|
{ pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", online: true, lastHandshake: Math.floor(Date.now() / 1000) - 30, seen_before: true } ] : [] } ] } });
|
||||||
|
app.get("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
|
||||||
|
app.post("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
|
||||||
|
app.post("/ui/masterkeys/audit/clean", (_q, r) => {
|
||||||
|
if (mockGhost) mockCleaned = [{ at: Math.floor(Date.now() / 1000), server_id: 0, pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", kind: "ghost", auto: false }, ...mockCleaned];
|
||||||
|
mockGhost = false; r.json(mockAudit());
|
||||||
|
});
|
||||||
|
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
|
||||||
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
|
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
|
||||||
app.post("/ui/masterkeys", (q, r) => {
|
app.post("/ui/masterkeys", (q, r) => {
|
||||||
const limit = q.body.device_limit ?? 3;
|
const limit = q.body.device_limit ?? 3;
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
//
|
||||||
|
// Вкладка «Монитор»: встроенный Prometheus и основные графики прямо в панели.
|
||||||
|
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
|
||||||
|
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
|
||||||
|
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove` (удаление вместе с накопленными данными)
|
||||||
|
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч
|
||||||
|
// Панель сама пакетов не ставит и в Prometheus лезет только фиксированными запросами (не PromQL от клиента).
|
||||||
|
import axios from "axios";
|
||||||
|
import { Router, type Request, type Response } from "express";
|
||||||
|
|
||||||
|
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
|
||||||
|
|
||||||
|
const PROM_URL = process.env.PROM_URL ?? "http://127.0.0.1:9090";
|
||||||
|
|
||||||
|
// Шаг подбираем так, чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
|
||||||
|
const RANGES: Record<string, { seconds: number; step: number }> = {
|
||||||
|
"1h": { seconds: 3600, step: 15 },
|
||||||
|
"6h": { seconds: 21600, step: 60 },
|
||||||
|
"24h": { seconds: 86400, step: 300 },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Point = [number, number];
|
||||||
|
type Series = Map<number, { name: string; points: Point[] }>; // server_id → ряд
|
||||||
|
|
||||||
|
// Запросы по серверам. Трафик берём со счётчиков интерфейса (монотонны), а не с сумм по пирам:
|
||||||
|
// сумма по пирам падает при удалении пира, и rate() рисовал бы ложный всплеск.
|
||||||
|
const QUERIES = {
|
||||||
|
down: 'sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8',
|
||||||
|
up: 'sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8',
|
||||||
|
online: 'sum by (server_id, server) (awg_peers_online)',
|
||||||
|
heavy: 'sum by (server_id, server) (awg_shaping_heavy_devices)',
|
||||||
|
drops: 'sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))',
|
||||||
|
congested: 'max by (server_id, server) (awg_shaping_congested)',
|
||||||
|
scrape_ok: 'max by (server_id, server) (awg_scrape_ok)',
|
||||||
|
// Загрузка процессора: доля времени не в idle/iowait. Память: MemAvailable, а не MemFree (кэш ядра свободен).
|
||||||
|
cpu: '100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))',
|
||||||
|
mem: '100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))',
|
||||||
|
cores: 'max by (server_id, server) (awg_host_cpu_cores)',
|
||||||
|
mem_total: 'max by (server_id, server) (awg_host_memory_total_bytes)',
|
||||||
|
} as const;
|
||||||
|
type Key = keyof typeof QUERIES;
|
||||||
|
|
||||||
|
async function rangeQuery(query: string, start: number, end: number, step: number): Promise<Series> {
|
||||||
|
const r = await axios.get(`${PROM_URL}/api/v1/query_range`, {
|
||||||
|
params: { query, start, end, step }, timeout: 8000,
|
||||||
|
});
|
||||||
|
const out: Series = new Map();
|
||||||
|
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; values: [number, string][] }[]) {
|
||||||
|
const id = Number(m.metric.server_id);
|
||||||
|
if (!Number.isInteger(id)) continue;
|
||||||
|
out.set(id, {
|
||||||
|
name: m.metric.server ?? String(id),
|
||||||
|
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
|
||||||
|
points: m.values.map(([t, v]) => [t, Number(v)] as Point).filter(p => Number.isFinite(p[1])),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сумма рядов по меткам времени (Prometheus выравнивает точки по шагу, метки совпадают).
|
||||||
|
function total(series: Series, how: "sum" | "max" = "sum"): Point[] {
|
||||||
|
const acc = new Map<number, number>();
|
||||||
|
for (const s of series.values()) for (const [t, v] of s.points)
|
||||||
|
acc.set(t, how === "sum" ? (acc.get(t) ?? 0) + v : Math.max(acc.get(t) ?? 0, v));
|
||||||
|
return [...acc.entries()].sort((a, b) => a[0] - b[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = (s?: { points: Point[] }): number | null => s && s.points.length ? s.points[s.points.length - 1][1] : null;
|
||||||
|
|
||||||
|
export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean }) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const promReady = async (): Promise<boolean> => {
|
||||||
|
try { return (await axios.get(`${PROM_URL}/-/ready`, { timeout: 1500, validateStatus: () => true })).status === 200; }
|
||||||
|
catch { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Установка возможна, только если рядом свой awg-ctrl (у панели без собственного VPN его нет —
|
||||||
|
// тогда остаётся команда в терминале) и он достаточно новый, чтобы знать маршрут.
|
||||||
|
type Job = { can: boolean; action: "install" | "remove" | null; running: boolean; exit: number | null; log: string };
|
||||||
|
async function jobState(): Promise<Job> {
|
||||||
|
const none: Job = { can: false, action: null, running: false, exit: null, log: "" };
|
||||||
|
if (!deps.localEnabled) return none;
|
||||||
|
try {
|
||||||
|
const r = await deps.ctrlLocal("GET", "/api/monitor/job");
|
||||||
|
if (r.status !== 200) return none;
|
||||||
|
return { can: true, action: r.data.action ?? null, running: !!r.data.running, exit: r.data.exit ?? null, log: String(r.data.log ?? "") };
|
||||||
|
} catch { return none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/", async (_req: Request, res: Response) => {
|
||||||
|
const [ready, job] = await Promise.all([promReady(), jobState()]);
|
||||||
|
res.json({
|
||||||
|
ready,
|
||||||
|
can_install: job.can,
|
||||||
|
// Последняя операция (установка или удаление) и её лог; null — ещё не запускали
|
||||||
|
job: job.can && job.action ? { action: job.action, running: job.running, exit: job.exit, log: job.log } : null,
|
||||||
|
command: "awg-ctrl monitor install",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
|
||||||
|
const ask = (action: "install" | "remove") => async (_req: Request, res: Response) => {
|
||||||
|
if (!deps.localEnabled) { res.status(409).json({ error: "У этой панели нет своего VPN-сервера: выполните команду на машине" }); return; }
|
||||||
|
try {
|
||||||
|
const r = await deps.ctrlLocal("POST", `/api/monitor/${action}`);
|
||||||
|
res.status(r.status === 404 ? 501 : r.status).json(
|
||||||
|
r.status === 404 ? { error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" } : r.data);
|
||||||
|
} catch { res.status(502).json({ error: "awg-ctrl недоступен" }); }
|
||||||
|
};
|
||||||
|
router.post("/install", ask("install"));
|
||||||
|
router.post("/remove", ask("remove"));
|
||||||
|
|
||||||
|
router.get("/overview", async (req: Request, res: Response) => {
|
||||||
|
const range = RANGES[String(req.query.range ?? "1h")];
|
||||||
|
if (!range) { res.status(400).json({ error: "range: 1h, 6h или 24h" }); return; }
|
||||||
|
const end = Math.floor(Date.now() / 1000);
|
||||||
|
const start = end - range.seconds;
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(QUERIES) as Key[];
|
||||||
|
const got = await Promise.all(keys.map(k => rangeQuery(QUERIES[k], start, end, range.step)));
|
||||||
|
// Вкладка показывает один сервер — тот, что выбран в сервер-баре (?server=<id>).
|
||||||
|
const only = Number.isInteger(Number(req.query.server)) && req.query.server !== undefined ? Number(req.query.server) : null;
|
||||||
|
const s = Object.fromEntries(keys.map((k, i) => [k,
|
||||||
|
only === null ? got[i] : new Map([...got[i]].filter(([id]) => id === only))])) as Record<Key, Series>;
|
||||||
|
|
||||||
|
// Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна.
|
||||||
|
const ids = new Set<number>();
|
||||||
|
for (const k of keys) for (const id of s[k].keys()) ids.add(id);
|
||||||
|
const servers = [...ids].sort((a, b) => a - b).map(id => ({
|
||||||
|
id,
|
||||||
|
name: (s.scrape_ok.get(id) ?? s.down.get(id) ?? s.online.get(id))?.name ?? String(id),
|
||||||
|
ok: last(s.scrape_ok.get(id)) === 1,
|
||||||
|
down: last(s.down.get(id)),
|
||||||
|
up: last(s.up.get(id)),
|
||||||
|
online: last(s.online.get(id)),
|
||||||
|
heavy: last(s.heavy.get(id)),
|
||||||
|
congested: last(s.congested.get(id)) === 1,
|
||||||
|
cpu: last(s.cpu.get(id)),
|
||||||
|
mem: last(s.mem.get(id)),
|
||||||
|
cores: last(s.cores.get(id)),
|
||||||
|
mem_total: last(s.mem_total.get(id)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
range: req.query.range ?? "1h", step: range.step, from: start, to: end,
|
||||||
|
servers,
|
||||||
|
total: {
|
||||||
|
down: total(s.down), up: total(s.up), online: total(s.online),
|
||||||
|
heavy: total(s.heavy), drops: total(s.drops),
|
||||||
|
cpu: total(s.cpu, "max"), mem: total(s.mem, "max"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Prometheus стоит, но не ответил: график важнее ошибки, но причину оператору покажем
|
||||||
|
res.status(502).json({ error: axios.isAxiosError(e) ? "Prometheus не отвечает" : (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { router };
|
||||||
|
}
|
||||||
+10
-5
@@ -305,9 +305,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", async (_req: Request, res: Response) => {
|
// Серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и для метрик.
|
||||||
if (LOCAL) await refreshHealth(0);
|
const list = () => {
|
||||||
const nodes: unknown[] = [];
|
const nodes: { id: number; name: string; local: boolean; online: boolean; pending: boolean; last_seen: number | null; version: string | null; health: Health | null }[] = [];
|
||||||
if (LOCAL) {
|
if (LOCAL) {
|
||||||
const h = health.get(0) ?? null;
|
const h = health.get(0) ?? null;
|
||||||
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
|
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
|
||||||
@@ -318,7 +318,12 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
|
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes });
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get("/", async (_req: Request, res: Response) => {
|
||||||
|
if (LOCAL) await refreshHealth(0);
|
||||||
|
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes: list() });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/", (req: Request, res: Response) => {
|
router.post("/", (req: Request, res: Response) => {
|
||||||
@@ -379,5 +384,5 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
res.json({ success: true, id });
|
res.json({ success: true, id });
|
||||||
});
|
});
|
||||||
|
|
||||||
return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, afterDelete, defaultId, start };
|
return { router, ctrlFor, exists, isOnline, ids, list, onOnline, beforeDelete, afterDelete, defaultId, start };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import axios from "axios";
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { createSub } from "./sub";
|
import { createSub } from "./sub";
|
||||||
import { createNodes } from "./nodes";
|
import { createNodes } from "./nodes";
|
||||||
|
import { createMetrics } from "./metrics";
|
||||||
|
import { createMonitor } from "./monitor";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.PORT) || 8080;
|
const PORT = Number(process.env.PORT) || 8080;
|
||||||
@@ -362,6 +364,15 @@ app.use("/ui/nodes", requireAuth, nodes.router);
|
|||||||
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
|
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
|
||||||
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
|
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
|
||||||
app.use("/ui/devices", requireAuth, sub.devices);
|
app.use("/ui/devices", requireAuth, sub.devices);
|
||||||
|
|
||||||
|
// Prometheus: /metrics за отдельным токеном «только метрики» (не JWT и не ключ API), токеном
|
||||||
|
// управляет панель. Регистрируется до SPA-фолбэка внизу, иначе его перехватит `*`.
|
||||||
|
const metrics = createMetrics({ uidb, version: VERSION, servers: nodes });
|
||||||
|
app.use("/ui/metrics-token", requireAuth, metrics.router);
|
||||||
|
app.get("/metrics", metrics.requireToken, metrics.handler);
|
||||||
|
// Монитор: встроенный Prometheus и графики во вкладке. Ставит его не панель, а awg-ctrl от root.
|
||||||
|
const monitor = createMonitor({ ctrlLocal: ctrl, localEnabled: (process.env.LOCAL_NODE ?? "on").toLowerCase() !== "off" });
|
||||||
|
app.use("/ui/monitor", requireAuth, monitor.router);
|
||||||
// Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован
|
// Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован
|
||||||
// на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже.
|
// на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже.
|
||||||
ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? []));
|
ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? []));
|
||||||
|
|||||||
@@ -332,6 +332,8 @@ body {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.nav-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
|
||||||
|
.nav-group-title { padding: 8px 12px 4px; font-size: 12px; color: var(--neutral-text); user-select: none; }
|
||||||
.nav-item--active {
|
.nav-item--active {
|
||||||
background: var(--primary-container);
|
background: var(--primary-container);
|
||||||
color: var(--on-primary-container);
|
color: var(--on-primary-container);
|
||||||
@@ -433,6 +435,19 @@ body {
|
|||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Настройки сервера: деление канала */
|
||||||
|
.ss-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; }
|
||||||
|
.ss-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.ss-setting-name { font-size: 14px; color: var(--on-surface); }
|
||||||
|
.ss-setting input[type="checkbox"] { width: 20px; height: 20px; flex: none; accent-color: var(--primary); }
|
||||||
|
.ss-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.ss-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
.ss-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: var(--surface-container); border-radius: var(--r-table); padding: 12px; }
|
||||||
|
.ss-mono { font-family: var(--mono); font-size: 12px; color: var(--neutral-text); word-break: break-all; }
|
||||||
|
/* Тёплый чип «внимание»: канал занят, фоновый приоритет */
|
||||||
|
.chip--warm { background: var(--tertiary-container); color: var(--on-tertiary-container); }
|
||||||
|
.chip--warm .chip-dot { background: var(--on-tertiary-container); }
|
||||||
.server-card-info {
|
.server-card-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+20
-2
@@ -11,7 +11,8 @@ import {
|
|||||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
||||||
import ServerBar from './components/ServerBar';
|
import ServerBar from './components/ServerBar';
|
||||||
import AddServerModal from './components/AddServerModal';
|
import AddServerModal from './components/AddServerModal';
|
||||||
import { TABS } from './tabs';
|
import ServerSettingsModal from './components/ServerSettingsModal';
|
||||||
|
import { TABS, TABS_BY_GROUP } from './tabs';
|
||||||
|
|
||||||
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
||||||
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
||||||
@@ -26,6 +27,7 @@ export default function App() {
|
|||||||
const [panelVersion, setPanelVersion] = useState<string | null>(null);
|
const [panelVersion, setPanelVersion] = useState<string | null>(null);
|
||||||
const [serverId, setServerIdState] = useState<number | null>(null);
|
const [serverId, setServerIdState] = useState<number | null>(null);
|
||||||
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
||||||
|
const [settingsFor, setSettingsFor] = useState<NodeInfo | null>(null);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
||||||
@@ -288,7 +290,10 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<hr className="drawer-divider" />
|
<hr className="drawer-divider" />
|
||||||
{TABS.map(t => (
|
{TABS_BY_GROUP.map(g => (
|
||||||
|
<nav key={g.id} className="nav-group" aria-label={g.label}>
|
||||||
|
<span className="nav-group-title">{g.label}</span>
|
||||||
|
{g.tabs.map(t => (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
|
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
|
||||||
@@ -297,6 +302,8 @@ export default function App() {
|
|||||||
<t.Icon /> {t.label}
|
<t.Icon /> {t.label}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</nav>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="sidebar-spacer" />
|
<div className="sidebar-spacer" />
|
||||||
|
|
||||||
@@ -318,6 +325,8 @@ export default function App() {
|
|||||||
onAdd={() => setModal({})}
|
onAdd={() => setModal({})}
|
||||||
onJoin={n => setModal({ node: n })}
|
onJoin={n => setModal({ node: n })}
|
||||||
onDelete={deleteNode}
|
onDelete={deleteNode}
|
||||||
|
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
|
||||||
|
onSettings={n => { pickServer(n.id); setSettingsFor(n); }}
|
||||||
onRestartAwg={restartAwg}
|
onRestartAwg={restartAwg}
|
||||||
restarting={restarting}
|
restarting={restarting}
|
||||||
showRestart={showRestart}
|
showRestart={showRestart}
|
||||||
@@ -346,6 +355,15 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{settingsFor && (
|
||||||
|
<ServerSettingsModal
|
||||||
|
token={token}
|
||||||
|
node={settingsFor}
|
||||||
|
onClose={() => setSettingsFor(null)}
|
||||||
|
showMsg={showMsg}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{msg && <div className="snack">{msg}</div>}
|
{msg && <div className="snack">{msg}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
// Линейный график для вкладки «Монитор». Одна ось, линия 2 px, конечная точка с кольцом цвета поверхности,
|
||||||
|
// перекрестие с подсказкой по всем рядам, легенда при ≥ 2 рядах, табличный вид и управление стрелками.
|
||||||
|
// Цвета рядов — токены темы (--series-N в monitor.css), подписи и значения — всегда цветом текста.
|
||||||
|
export interface ChartSeries { id: string; name: string; color: string; points: [number, number][] }
|
||||||
|
|
||||||
|
const M = { left: 46, right: 14, top: 10, bottom: 24 };
|
||||||
|
|
||||||
|
// Шкала оси: круглый шаг (1, 2, 2.5, 5 × 10ⁿ) и 3–5 интервалов, чтобы подписи были 0 / 50 / 100 / 150, а не 0 / 63 / 125.
|
||||||
|
function niceScale(max: number): { top: number; ticks: number[] } {
|
||||||
|
if (!(max > 0)) return { top: 1, ticks: [0, 1] };
|
||||||
|
const raw = max / 4;
|
||||||
|
const p = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||||
|
const step = ([1, 2, 2.5, 5, 10].map(f => f * p).find(s => s >= raw)) ?? 10 * p;
|
||||||
|
const n = Math.max(1, Math.ceil(max / step - 1e-9));
|
||||||
|
return { top: step * n, ticks: Array.from({ length: n + 1 }, (_, i) => step * i) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeLabel = (t: number, span: number) => {
|
||||||
|
const d = new Date(t * 1000);
|
||||||
|
const hm = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return span > 86400 * 1.5 ? `${d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} ${hm}` : hm;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LineChart({ title, series, from, to, step, format, formatAxis, max, dim = false }: {
|
||||||
|
title: string;
|
||||||
|
series: ChartSeries[];
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
step: number; // шаг данных, с: разрыв длиннее трёх шагов рвёт линию, а не рисует мост
|
||||||
|
format: (v: number) => string; // значение в подсказке и таблице
|
||||||
|
formatAxis?: (v: number) => string; // компактная подпись оси
|
||||||
|
max?: number; // фиксированный верх оси (проценты — 100); без него подбирается по данным
|
||||||
|
dim?: boolean; // идёт перезагрузка: держим прежний кадр бледнее
|
||||||
|
}) {
|
||||||
|
const wrap = useRef<HTMLDivElement>(null); // содержимое карточки без её padding: от него зависит ширина svg
|
||||||
|
const [w, setW] = useState(640);
|
||||||
|
const [hover, setHover] = useState<number | null>(null); // индекс по оси времени
|
||||||
|
const [table, setTable] = useState(false);
|
||||||
|
const H = 200;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrap.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => setW(Math.max(260, el.clientWidth)));
|
||||||
|
ro.observe(el); setW(Math.max(260, el.clientWidth));
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Общая ось времени: метки первого непустого ряда (Prometheus выравнивает точки по шагу).
|
||||||
|
const times = useMemo(() => {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const s of series) for (const p of s.points) set.add(p[0]);
|
||||||
|
return [...set].sort((a, b) => a - b);
|
||||||
|
}, [series]);
|
||||||
|
const byTime = useMemo(() => series.map(s => new Map(s.points)), [series]);
|
||||||
|
|
||||||
|
const empty = times.length === 0;
|
||||||
|
const { top: yMax, ticks: yTicks } = max !== undefined
|
||||||
|
? { top: max, ticks: [0, 1, 2, 3, 4].map(i => (max / 4) * i) }
|
||||||
|
: niceScale(Math.max(0, ...series.flatMap(s => s.points.map(p => p[1]))) * 1.04);
|
||||||
|
const x = (t: number) => M.left + ((t - from) / Math.max(1, to - from)) * (w - M.left - M.right);
|
||||||
|
const y = (v: number) => H - M.bottom - (v / yMax) * (H - M.top - M.bottom);
|
||||||
|
|
||||||
|
const path = (pts: [number, number][]) => {
|
||||||
|
let d = '', prev: number | null = null;
|
||||||
|
for (const [t, v] of pts) {
|
||||||
|
d += `${prev !== null && t - prev <= step * 3 ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`;
|
||||||
|
prev = t;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const span = to - from;
|
||||||
|
const xTicks = [0, 1, 2, 3, 4].map(i => from + (span / 4) * i);
|
||||||
|
const axis = formatAxis ?? format;
|
||||||
|
|
||||||
|
const onMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
|
if (!times.length) return;
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
const t = from + ((e.clientX - r.left) / Math.max(1, r.width)) * span;
|
||||||
|
let best = 0;
|
||||||
|
for (let i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
|
||||||
|
setHover(best);
|
||||||
|
};
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
if (!times.length) return;
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); setHover(h => Math.max(0, (h ?? times.length) - 1)); }
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); setHover(h => Math.min(times.length - 1, (h ?? -1) + 1)); }
|
||||||
|
if (e.key === 'Escape') setHover(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ht = hover !== null ? times[hover] : null;
|
||||||
|
// Подсказка не вылезает за правый край: правее середины открывается влево от перекрестия
|
||||||
|
const flip = ht !== null && x(ht) > w * 0.55;
|
||||||
|
|
||||||
|
// Табличный вид — те же числа без наведения: ~12 равномерно выбранных строк, последняя — самая свежая
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!times.length) return [] as number[];
|
||||||
|
const n = Math.min(12, times.length);
|
||||||
|
return Array.from({ length: n }, (_, i) => times[Math.round(((times.length - 1) * (i + 1)) / n)]).reverse();
|
||||||
|
}, [times]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className={`mon-chart${dim ? ' mon-chart--dim' : ''}`}>
|
||||||
|
<figcaption className="mon-chart-head">
|
||||||
|
<span className="mon-chart-title">{title}</span>
|
||||||
|
{series.length >= 2 && (
|
||||||
|
<span className="mon-legend">
|
||||||
|
{series.map(s => (
|
||||||
|
<span className="mon-legend-item" key={s.id}>
|
||||||
|
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />{s.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" className="mon-chart-toggle" aria-pressed={table} onClick={() => setTable(t => !t)}>
|
||||||
|
{table ? 'График' : 'Таблица'}
|
||||||
|
</button>
|
||||||
|
</figcaption>
|
||||||
|
|
||||||
|
<div ref={wrap}>
|
||||||
|
{table ? (
|
||||||
|
<div className="mon-table-wrap">
|
||||||
|
<table className="mon-table">
|
||||||
|
<thead><tr><th>Время</th>{series.map(s => <th key={s.id}>{s.name}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? <tr><td colSpan={series.length + 1}>Нет данных</td></tr> : rows.map(t => (
|
||||||
|
<tr key={t}>
|
||||||
|
<td>{timeLabel(t, span)}</td>
|
||||||
|
{series.map((s, i) => <td key={s.id}>{byTime[i].has(t) ? format(byTime[i].get(t)!) : '—'}</td>)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mon-plot">
|
||||||
|
<svg width={w} height={H} role="img" tabIndex={0} aria-label={`${title}. Стрелки влево и вправо — значения по времени.`}
|
||||||
|
onKeyDown={onKey} onBlur={() => setHover(null)}>
|
||||||
|
{yTicks.map((v, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<line className="mon-grid" x1={M.left} x2={w - M.right} y1={y(v)} y2={y(v)} />
|
||||||
|
<text className="mon-tick" x={M.left - 8} y={y(v) + 4} textAnchor="end">{axis(v)}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{xTicks.map((t, i) => (
|
||||||
|
<text key={i} className="mon-tick" x={x(t)} y={H - 6} textAnchor={i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}>
|
||||||
|
{timeLabel(t, span)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{!empty && series.map(s => (
|
||||||
|
<path key={s.id} d={path(s.points)} fill="none" style={{ stroke: s.color }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
))}
|
||||||
|
{/* конечная точка: r 4 + кольцо цвета поверхности, чтобы читалась поверх линий */}
|
||||||
|
{!empty && series.map(s => s.points.length > 0 && (
|
||||||
|
<circle key={s.id} cx={x(s.points[s.points.length - 1][0])} cy={y(s.points[s.points.length - 1][1])}
|
||||||
|
r={4} style={{ fill: s.color }} className="mon-end" />
|
||||||
|
))}
|
||||||
|
{ht !== null && (
|
||||||
|
<g>
|
||||||
|
<line className="mon-cross" x1={x(ht)} x2={x(ht)} y1={M.top} y2={H - M.bottom} />
|
||||||
|
{series.map((s, i) => byTime[i].has(ht) && (
|
||||||
|
<circle key={s.id} cx={x(ht)} cy={y(byTime[i].get(ht)!)} r={4} style={{ fill: s.color }} className="mon-end" />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{/* зона наведения шире линии: указатель ищет момент времени, а не 2-пиксельную линию */}
|
||||||
|
<rect x={M.left} y={M.top} width={Math.max(0, w - M.left - M.right)} height={H - M.top - M.bottom}
|
||||||
|
fill="transparent" onPointerMove={onMove} onPointerLeave={() => setHover(null)} />
|
||||||
|
</svg>
|
||||||
|
{empty && <p className="mon-empty">Данные появятся через минуту-две после запуска Prometheus</p>}
|
||||||
|
{ht !== null && (
|
||||||
|
<div className="mon-tip" style={{ left: flip ? undefined : x(ht) + 12, right: flip ? w - x(ht) + 12 : undefined, top: M.top }} role="status">
|
||||||
|
<span className="mon-tip-time">{timeLabel(ht, span)}</span>
|
||||||
|
{series.map((s, i) => (
|
||||||
|
<span className="mon-tip-row" key={s.id}>
|
||||||
|
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />
|
||||||
|
<b>{byTime[i].has(ht) ? format(byTime[i].get(ht)!) : '—'}</b>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
import type { Priority } from '../lib/shared';
|
||||||
|
|
||||||
|
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
|
||||||
|
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
|
||||||
|
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
|
||||||
|
// деления канала) чип не показываем: «обычный» там было бы выдумкой.
|
||||||
|
export default function PriorityChip({ priority }: { priority?: Priority }) {
|
||||||
|
if (!priority) return null;
|
||||||
|
const bg = priority === 'background';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="tip-wrap"
|
||||||
|
data-tip={bg
|
||||||
|
? 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'
|
||||||
|
: 'Обычный приоритет: канал делится поровну'}
|
||||||
|
>
|
||||||
|
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
|
||||||
|
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
|
import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
|
||||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
|
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
|
||||||
|
|
||||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
||||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
||||||
@@ -29,7 +29,7 @@ function versionChip(n: NodeInfo, panel: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ServerBar({
|
export default function ServerBar({
|
||||||
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete,
|
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete, onSettings,
|
||||||
onRestartAwg, restarting, showRestart = true,
|
onRestartAwg, restarting, showRestart = true,
|
||||||
}: {
|
}: {
|
||||||
nodes: NodeInfo[];
|
nodes: NodeInfo[];
|
||||||
@@ -40,6 +40,7 @@ export default function ServerBar({
|
|||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onJoin: (n: NodeInfo) => void;
|
onJoin: (n: NodeInfo) => void;
|
||||||
onDelete: (n: NodeInfo) => void;
|
onDelete: (n: NodeInfo) => void;
|
||||||
|
onSettings: (n: NodeInfo) => void;
|
||||||
onRestartAwg: () => void;
|
onRestartAwg: () => void;
|
||||||
restarting: boolean;
|
restarting: boolean;
|
||||||
showRestart?: boolean;
|
showRestart?: boolean;
|
||||||
@@ -103,6 +104,16 @@ export default function ServerBar({
|
|||||||
<IcoCopy />
|
<IcoCopy />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Настройки сервера: у ноды, которая ещё не подключалась, настраивать нечего */}
|
||||||
|
{!n.pending && n.online && (
|
||||||
|
<button
|
||||||
|
className="btn-icon btn-icon--neutral"
|
||||||
|
aria-label={`Настройки сервера ${n.name}`}
|
||||||
|
onClick={e => { e.stopPropagation(); onSettings(n); }}
|
||||||
|
>
|
||||||
|
<IcoSettings />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!n.local && (
|
{!n.local && (
|
||||||
<button
|
<button
|
||||||
className="btn-icon btn-icon--danger"
|
className="btn-icon btn-icon--danger"
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { apiFetch, type NodeInfo, type ShapingStatus } from '../lib/shared';
|
||||||
|
|
||||||
|
// Настройки сервера: деление канала между устройствами (CAKE). Сами настройки живут в
|
||||||
|
// awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
|
||||||
|
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
|
||||||
|
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
||||||
|
token: string;
|
||||||
|
node: NodeInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
showMsg: (text: string) => void;
|
||||||
|
}) {
|
||||||
|
const [st, setSt] = useState<ShapingStatus | null>(null);
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [auto, setAuto] = useState(true);
|
||||||
|
const [down, setDown] = useState('');
|
||||||
|
const [up, setUp] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [unsupported, setUnsupported] = useState(false);
|
||||||
|
|
||||||
|
const fill = (s: ShapingStatus) => {
|
||||||
|
setSt(s);
|
||||||
|
setEnabled(s.settings.enabled); setAuto(s.settings.auto);
|
||||||
|
setDown(s.settings.downMbit ? String(s.settings.downMbit) : '');
|
||||||
|
setUp(s.settings.upMbit ? String(s.settings.upMbit) : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const errText = (e: unknown, fallback: string) =>
|
||||||
|
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
apiFetch('GET', '/api/shaping', token)
|
||||||
|
.then(s => { if (alive) fill(s as ShapingStatus); })
|
||||||
|
.catch(e => {
|
||||||
|
if (!alive) return;
|
||||||
|
// Старый awg-ctrl не знает этот маршрут — его надо обновить, а не «починить» здесь.
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 404) setUnsupported(true);
|
||||||
|
else setError(errText(e, 'Не удалось загрузить настройки'));
|
||||||
|
});
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', h);
|
||||||
|
return () => document.removeEventListener('keydown', h);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const downN = Number(down), upN = Number(up);
|
||||||
|
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
const s = await apiFetch('PUT', '/api/shaping', token, {
|
||||||
|
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
|
||||||
|
});
|
||||||
|
fill(s as ShapingStatus);
|
||||||
|
showMsg(enabled ? 'Деление канала включено' : 'Деление канала выключено');
|
||||||
|
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="node-scrim" onClick={onClose}>
|
||||||
|
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="node-modal-title">Деление канала: {node.name}</h3>
|
||||||
|
|
||||||
|
{unsupported ? (
|
||||||
|
<p className="node-modal-hint">
|
||||||
|
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="node-modal-hint">
|
||||||
|
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
|
||||||
|
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="ss-setting">
|
||||||
|
<span className="ss-setting-text">
|
||||||
|
<span className="ss-setting-name">Честное деление между устройствами</span>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" checked={enabled} disabled={!st || busy} onChange={e => setEnabled(e.target.checked)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="ss-grid">
|
||||||
|
<label className="ss-field">
|
||||||
|
<span className="ss-setting-name">Загрузка, Мбит/с</span>
|
||||||
|
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
|
||||||
|
value={down} disabled={!st || busy} onChange={e => setDown(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="ss-field">
|
||||||
|
<span className="ss-setting-name">Отдача, Мбит/с</span>
|
||||||
|
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
|
||||||
|
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="node-modal-hint">
|
||||||
|
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
|
||||||
|
и деление перестаёт работать.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="ss-setting">
|
||||||
|
<span className="ss-setting-text">
|
||||||
|
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
|
||||||
|
<span className="node-modal-hint">
|
||||||
|
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
|
||||||
|
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" checked={auto} disabled={!st || busy || !enabled} onChange={e => setAuto(e.target.checked)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{st?.settings.enabled && (
|
||||||
|
<div className="ss-status">
|
||||||
|
<span className={`chip ${st.active ? (st.congested ? 'chip--warm' : 'chip--online') : 'chip--offline'}`}>
|
||||||
|
<span className="chip-dot" />
|
||||||
|
{!st.active ? 'не применено' : st.congested ? 'канал занят' : 'канал свободен'}
|
||||||
|
</span>
|
||||||
|
<span className="ss-mono">
|
||||||
|
↓ {st.rateDown} · ↑ {st.rateUp} Мбит/с · устройств {st.devices}
|
||||||
|
</span>
|
||||||
|
{st.heavy.length > 0 && (
|
||||||
|
<span className="ss-mono">в фоне: {st.heavy.join(', ')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
|
||||||
|
<div className="node-modal-actions">
|
||||||
|
{!unsupported && (
|
||||||
|
<button className="btn btn--primary" onClick={save} disabled={!st || busy || !valid}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn--outline" onClick={onClose}>Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,7 +90,21 @@ export const IcoMoon = () => (
|
|||||||
|
|
||||||
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
|
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
|
||||||
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
|
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
|
||||||
|
export const IcoShield = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3z"/><path d="m9 12 2 2 4-4"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoPulse = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const ICONS: Record<string, () => JSX.Element> = {
|
export const ICONS: Record<string, () => JSX.Element> = {
|
||||||
users: IcoUsers,
|
users: IcoUsers,
|
||||||
key: IcoKey,
|
key: IcoKey,
|
||||||
|
shield: IcoShield,
|
||||||
|
pulse: IcoPulse,
|
||||||
};
|
};
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
|
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
|
||||||
export type AwgGen = '2' | '3.1';
|
export type AwgGen = '2' | '3.1';
|
||||||
|
// Текущая приоритетность ключа/устройства: «background» — канал занят, а он давно качает больше своей доли.
|
||||||
|
export type Priority = 'normal' | 'background';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -15,6 +18,7 @@ export interface User {
|
|||||||
lastHandshake: number;
|
lastHandshake: number;
|
||||||
rx?: number;
|
rx?: number;
|
||||||
tx?: number;
|
tx?: number;
|
||||||
|
priority?: Priority; // нет у awg-ctrl старше деления канала
|
||||||
}
|
}
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -64,6 +68,17 @@ export interface NodeInfo {
|
|||||||
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Деление канала (GET/PUT /api/shaping на awg-ctrl сервера).
|
||||||
|
export interface ShapingStatus {
|
||||||
|
settings: { enabled: boolean; downMbit: number; upMbit: number; auto: boolean };
|
||||||
|
active: boolean; // CAKE реально стоит на интерфейсе
|
||||||
|
congested: boolean;
|
||||||
|
heavy: string[]; // IP устройств, переведённых в фоновый класс
|
||||||
|
devices: number; // активных устройств в окне
|
||||||
|
rateDown: number; // Мбит/с за последний тик
|
||||||
|
rateUp: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
||||||
export function cmpVersion(a: string, b: string): number {
|
export function cmpVersion(a: string, b: string): number {
|
||||||
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
||||||
|
|||||||
@@ -90,3 +90,5 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||||
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
|
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
|
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
|
||||||
|
|
||||||
|
.apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; }
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||||
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
|
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
|
||||||
|
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в сервер-баре
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -54,12 +55,15 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
|||||||
|
|
||||||
const createKey = useCallback(async () => {
|
const createKey = useCallback(async () => {
|
||||||
if (!label.trim()) return;
|
if (!label.trim()) return;
|
||||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
|
const def = getServerId() ?? 0;
|
||||||
|
const servers = picked ?? [def];
|
||||||
|
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: servers.includes(def) ? def : servers[0], servers });
|
||||||
if (r.error) { showMsg(r.error); return; }
|
if (r.error) { showMsg(r.error); return; }
|
||||||
setLabel('');
|
setLabel('');
|
||||||
|
setPicked(null);
|
||||||
setCreated({ label: r.label, key: r.key });
|
setCreated({ label: r.label, key: r.key });
|
||||||
await load();
|
await load();
|
||||||
}, [label, token, load, showMsg]);
|
}, [label, picked, token, load, showMsg]);
|
||||||
|
|
||||||
const deleteKey = useCallback(async (id: number, lbl: string) => {
|
const deleteKey = useCallback(async (id: number, lbl: string) => {
|
||||||
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
|
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
|
||||||
@@ -70,6 +74,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
|||||||
|
|
||||||
useEffect(() => { if (token) load(); }, [token, load]);
|
useEffect(() => { if (token) load(); }, [token, load]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
|
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
|
||||||
@@ -87,6 +92,11 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="apikey-new-servers">
|
||||||
|
<span className="apikey-tools-hint">Серверы нового ключа:</span>
|
||||||
|
<ServerPicker nodes={nodes} selected={picked ?? [getServerId() ?? 0]}
|
||||||
|
onChange={next => setPicked(next)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="table-card">
|
<div className="table-card">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
"id": "apikeys",
|
"id": "apikeys",
|
||||||
"label": "API-ключи",
|
"label": "API-ключи",
|
||||||
"icon": "key",
|
"icon": "key",
|
||||||
|
"group": "main",
|
||||||
"order": 2
|
"order": 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2026 Ivan Vasilev
|
||||||
|
This source code is licensed under the MIT license found in the
|
||||||
|
LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
/* Стили вкладки «Аудит». Общая дизайн-система — в App.css. */
|
||||||
|
|
||||||
|
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||||
|
.au-run { margin-left: auto; }
|
||||||
|
.au-hint { font-size: 13px; color: var(--neutral-text); }
|
||||||
|
.au-notice {
|
||||||
|
font-size: 13px; color: var(--error-text); background: var(--error-bg);
|
||||||
|
border-radius: 16px; padding: 10px 16px; margin: 0;
|
||||||
|
}
|
||||||
|
.au-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
|
||||||
|
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: 12px; }
|
||||||
|
.au-table td, .au-table th { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* мобильный: таблица скрыта (.table-card), то же — карточками */
|
||||||
|
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
|
||||||
|
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
|
||||||
|
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.au-card .au-pub { max-width: 100%; }
|
||||||
|
@media (max-width: 640px) { .au-cards { display: flex; } }
|
||||||
|
|
||||||
|
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
||||||
|
.au-run { margin-left: 0; }
|
||||||
|
|
||||||
|
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
|
||||||
|
.au-box-title { margin: 0; font-size: 14px; font-weight: 500; }
|
||||||
|
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.au-setting-name { font-size: 14px; }
|
||||||
|
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.au-max { width: 88px !important; }
|
||||||
|
.au-select { width: auto !important; }
|
||||||
|
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 13px; }
|
||||||
|
.au-log .au-pub { max-width: 240px; }
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||||
|
import { IcoRefresh, IcoTrash } from '../../components/icons';
|
||||||
|
import { serverName } from '../../components/ServerPicker';
|
||||||
|
import './audit.css';
|
||||||
|
|
||||||
|
// Вкладка «Аудит»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
|
||||||
|
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
|
||||||
|
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
|
||||||
|
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
|
||||||
|
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
|
||||||
|
interface AuditEntry {
|
||||||
|
server_id: number; status: 'ok' | 'offline' | 'error'; ghosts_checked: boolean;
|
||||||
|
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number; blocked?: number;
|
||||||
|
}
|
||||||
|
interface Settings { clean: boolean; every_h: number; max: number }
|
||||||
|
interface Cleaned { at: number; server_id: number; pub_key: string; kind: 'ghost' | 'orphan'; key?: string | null; auto: boolean }
|
||||||
|
interface AuditData { enabled: boolean; settings: Settings; report: { at: number; servers: AuditEntry[] } | null; cleaned: Cleaned[] }
|
||||||
|
|
||||||
|
const PERIODS = [1, 3, 6, 12, 24];
|
||||||
|
|
||||||
|
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||||
|
|
||||||
|
export default function AuditPage({ token, showMsg }: PageProps) {
|
||||||
|
const [audit, setAudit] = useState<AuditData | null>(null);
|
||||||
|
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [maxEdit, setMaxEdit] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setAudit(await apiFetch('GET', '/ui/masterkeys/audit', token));
|
||||||
|
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||||
|
} catch { showMsg('Ошибка загрузки аудита'); }
|
||||||
|
}, [token, showMsg]);
|
||||||
|
|
||||||
|
useEffect(() => { if (token) load(); }, [token, load]);
|
||||||
|
|
||||||
|
const call = useCallback(async (method: string, path: string, ok: string, body?: object) => {
|
||||||
|
setBusy(true);
|
||||||
|
try { setAudit(await apiFetch(method, `/ui/masterkeys/audit${path}`, token, body)); showMsg(ok); }
|
||||||
|
catch (e) { showMsg(errText(e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}, [token, showMsg]);
|
||||||
|
const run = () => call('POST', '', 'Аудит выполнен');
|
||||||
|
const save = (patch: Partial<Settings>) => call('PUT', '/settings', 'Настройки сохранены', patch);
|
||||||
|
|
||||||
|
if (!audit) return null;
|
||||||
|
if (!audit.enabled) return <p className="au-empty">Аудит выключен на этой панели: его проводит только core.</p>;
|
||||||
|
|
||||||
|
const { settings } = audit;
|
||||||
|
const servers = audit.report?.servers ?? [];
|
||||||
|
const dead = servers.reduce((n, e) => n + e.ghosts.length + e.orphans.length, 0);
|
||||||
|
const bad = servers.filter(e => e.status !== 'ok');
|
||||||
|
const confirmed = servers.reduce((n, e) => n + [...e.ghosts, ...e.orphans].filter(p => p.seen_before).length, 0);
|
||||||
|
const blocked = servers.filter(e => e.blocked);
|
||||||
|
const rows = servers.flatMap(e => [
|
||||||
|
...e.ghosts.map(p => ({ e, p, why: 'Есть в ядре, у awg-ctrl записи нет' })),
|
||||||
|
...e.orphans.map(p => ({ e, p, why: 'Панели не нужен, не снят' })),
|
||||||
|
]);
|
||||||
|
const state = (p: AuditPeer) => (
|
||||||
|
<span className={`chip chip--${p.online ? 'error' : 'offline'}`}>
|
||||||
|
<span className="chip-dot" />{p.online ? 'Онлайн' : p.lastHandshake ? `был ${timeAgo(p.lastHandshake)} назад` : 'не подключался'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const confirm = (p: AuditPeer) => (
|
||||||
|
<span className="au-hint">{p.seen_before ? 'подтверждён' : 'найден впервые — снимется, если останется и в следующий раз'}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="au-head">
|
||||||
|
<span className={`chip chip--${!audit.report ? 'offline' : dead ? 'error' : 'online'}`}>
|
||||||
|
<span className="chip-dot" />
|
||||||
|
{!audit.report ? 'Аудит ещё не проводился' : dead ? `Мёртвых пиров: ${dead}` : 'Мёртвых пиров нет'}
|
||||||
|
</span>
|
||||||
|
{audit.report && (
|
||||||
|
<span className="au-hint">
|
||||||
|
Проверено {timeAgo(audit.report.at)} назад · серверов: {servers.length} · раз в {settings.every_h} ч
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="au-btns">
|
||||||
|
{confirmed > 0 && (
|
||||||
|
<button className="btn btn--tonal" disabled={busy}
|
||||||
|
onClick={() => window.confirm(`Снять с серверов подтверждённых пиров: ${confirmed}?`) && call('POST', '/clean', 'Очистка выполнена')}>
|
||||||
|
<IcoTrash /> Снять подтверждённых ({confirmed})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn--tonal" disabled={busy} onClick={run}>
|
||||||
|
<IcoRefresh /> {busy ? 'Работаем…' : 'Проверить сейчас'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="au-box">
|
||||||
|
<h4 className="au-box-title">Настройки</h4>
|
||||||
|
<label className="au-setting">
|
||||||
|
<span className="au-setting-text">
|
||||||
|
<span className="au-setting-name">Снимать мёртвых автоматически</span>
|
||||||
|
<span className="au-hint">Только тех, кто мёртв два прохода подряд. Ручной проход ничего не снимает.</span>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" checked={settings.clean} disabled={busy} onChange={e => save({ clean: e.target.checked })} />
|
||||||
|
</label>
|
||||||
|
<div className="au-setting">
|
||||||
|
<span className="au-setting-text">
|
||||||
|
<label className="au-setting-name" htmlFor="au-every">Период проверки</label>
|
||||||
|
<span className="au-hint">Как часто core обходит серверы</span>
|
||||||
|
</span>
|
||||||
|
<select id="au-every" className="field au-select" value={settings.every_h} disabled={busy}
|
||||||
|
onChange={e => save({ every_h: Number(e.target.value) })}>
|
||||||
|
{[...new Set([...PERIODS, settings.every_h])].sort((a, b) => a - b).map(h => <option key={h} value={h}>{h} ч</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="au-setting">
|
||||||
|
<span className="au-setting-text">
|
||||||
|
<label className="au-setting-name" htmlFor="au-max">Предел за проход</label>
|
||||||
|
<span className="au-hint">Если на сервере мёртвых больше — автоочистка ничего не снимает и только сообщает (защита от сбоя)</span>
|
||||||
|
</span>
|
||||||
|
<span className="au-setting-ctl">
|
||||||
|
<input id="au-max" className="field au-max" type="number" min={1} max={1000}
|
||||||
|
value={maxEdit || String(settings.max)} onChange={e => setMaxEdit(e.target.value)} />
|
||||||
|
<button className="btn btn--tonal" disabled={busy || !maxEdit || Number(maxEdit) === settings.max}
|
||||||
|
onClick={() => save({ max: Number(maxEdit) }).then(() => setMaxEdit(''))}>Сохранить</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{bad.length > 0 && (
|
||||||
|
<p className="au-notice">
|
||||||
|
Не проверены: {bad.map(e => `${serverName(nodes, e.server_id)} (${e.status === 'offline' ? 'не в сети' : 'ошибка'})`).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{blocked.length > 0 && (
|
||||||
|
<p className="au-notice">
|
||||||
|
Автоочистка пропустила: {blocked.map(e => `${serverName(nodes, e.server_id)} (${e.blocked} пиров при пределе ${settings.max})`).join(', ')}. Проверьте список и снимите вручную.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{servers.some(e => e.status === 'ok' && !e.ghosts_checked) && (
|
||||||
|
<p className="au-notice">Часть серверов на старой версии awg-ctrl: пиров, которые есть только в ядре, там не видно.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="table-card au-table-card">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Сервер</th><th>Публичный ключ</th><th>Причина</th><th>Мастер-ключ</th><th>Состояние</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(({ e, p, why }) => (
|
||||||
|
<tr key={`${e.server_id}:${p.pub_key}`}>
|
||||||
|
<td>{serverName(nodes, e.server_id)}</td>
|
||||||
|
<td className="td-mono"><code className="au-pub" title={p.pub_key}>{p.pub_key}</code></td>
|
||||||
|
<td>{why}</td>
|
||||||
|
<td>{p.key ?? '—'}</td>
|
||||||
|
<td>{state(p)}<br />{confirm(p)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<ul className="au-cards">
|
||||||
|
{rows.map(({ e, p, why }) => (
|
||||||
|
<li className="au-card" key={`${e.server_id}:${p.pub_key}`}>
|
||||||
|
<div className="au-card-top"><b>{serverName(nodes, e.server_id)}</b>{state(p)}</div>
|
||||||
|
<code className="au-pub" title={p.pub_key}>{p.pub_key}</code>
|
||||||
|
<span className="au-hint">{why}{p.key ? ` · ${p.key}` : ''}</span>
|
||||||
|
{confirm(p)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{audit.cleaned.length > 0 && (
|
||||||
|
<section className="au-box">
|
||||||
|
<h4 className="au-box-title">Снято</h4>
|
||||||
|
<ul className="au-log">
|
||||||
|
{audit.cleaned.slice(0, 20).map(c => (
|
||||||
|
<li key={`${c.at}:${c.pub_key}`}>
|
||||||
|
<span className="au-hint">{timeAgo(c.at)} назад</span>
|
||||||
|
<span>{serverName(nodes, c.server_id)}</span>
|
||||||
|
<code className="au-pub" title={c.pub_key}>{c.pub_key}</code>
|
||||||
|
<span className="au-hint">{c.kind === 'ghost' ? 'был только в ядре' : 'лишний у awg-ctrl'}{c.key ? ` · ${c.key}` : ''} · {c.auto ? 'автоматически' : 'вручную'}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{audit.report && rows.length === 0 && <p className="au-empty">Всё чисто: лишних пиров на серверах нет.</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "audit",
|
||||||
|
"label": "Аудит",
|
||||||
|
"icon": "shield",
|
||||||
|
"group": "server",
|
||||||
|
"order": 1.8,
|
||||||
|
"chrome": {
|
||||||
|
"serverBar": false,
|
||||||
|
"restart": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
||||||
// index.tsx — компонент + логика
|
// index.tsx — компонент + логика
|
||||||
// <name>.css — стили вкладки (импортит сам компонент)
|
// <name>.css — стили вкладки (импортит сам компонент)
|
||||||
// metadata.json — { id, label, icon, order?, chrome? } (манифест: описание + порядок)
|
// metadata.json — { id, label, icon, group?, order?, chrome? } (манифест: описание, группа меню, порядок)
|
||||||
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
|
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
|
||||||
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
|
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
|
||||||
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
|
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
|
||||||
@@ -21,12 +21,20 @@ export interface TabChrome {
|
|||||||
restart?: boolean;
|
restart?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Группы меню: порядок здесь = порядок заголовков в сайдбаре. Новая группа — строка сюда.
|
||||||
|
export const TAB_GROUPS = [
|
||||||
|
{ id: 'main', label: 'Основное' },
|
||||||
|
{ id: 'server', label: 'О сервере' },
|
||||||
|
] as const;
|
||||||
|
export type TabGroupId = (typeof TAB_GROUPS)[number]['id'];
|
||||||
|
|
||||||
// Сериализуемая часть из metadata.json.
|
// Сериализуемая часть из metadata.json.
|
||||||
export interface TabMeta {
|
export interface TabMeta {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: string; // имя иконки → ICONS
|
icon: string; // имя иконки → ICONS
|
||||||
order?: number; // порядок в меню (по возрастанию; без него — 0)
|
group: TabGroupId; // группа меню; без поля — 'main'
|
||||||
|
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
|
||||||
chrome?: TabChrome;
|
chrome?: TabChrome;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +66,15 @@ function toMeta(name: string, raw: unknown): TabMeta {
|
|||||||
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
|
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
|
||||||
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
|
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
|
||||||
}
|
}
|
||||||
|
const group = (m.group ?? 'main') as string;
|
||||||
|
if (!TAB_GROUPS.some(g => g.id === group)) {
|
||||||
|
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: str('id'),
|
id: str('id'),
|
||||||
label: str('label'),
|
label: str('label'),
|
||||||
icon: str('icon'),
|
icon: str('icon'),
|
||||||
|
group: group as TabGroupId,
|
||||||
order: m.order as number | undefined,
|
order: m.order as number | undefined,
|
||||||
chrome: m.chrome as TabChrome | undefined,
|
chrome: m.chrome as TabChrome | undefined,
|
||||||
};
|
};
|
||||||
@@ -80,7 +93,15 @@ export const TABS: TabDef[] = Object.entries(metas)
|
|||||||
}
|
}
|
||||||
return { ...meta, Icon: ICONS[meta.icon], Page: page };
|
return { ...meta, Icon: ICONS[meta.icon], Page: page };
|
||||||
})
|
})
|
||||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
// сначала по группе (как в TAB_GROUPS), внутри группы — по order
|
||||||
|
.sort((a, b) =>
|
||||||
|
TAB_GROUPS.findIndex(g => g.id === a.group) - TAB_GROUPS.findIndex(g => g.id === b.group)
|
||||||
|
|| (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
|
||||||
|
// Вкладки по группам для меню; пустые группы не показываем.
|
||||||
|
export const TABS_BY_GROUP = TAB_GROUPS
|
||||||
|
.map(g => ({ ...g, tabs: TABS.filter(t => t.group === g.id) }))
|
||||||
|
.filter(g => g.tabs.length > 0);
|
||||||
|
|
||||||
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
|
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
|
||||||
const ids = TABS.map(t => t.id);
|
const ids = TABS.map(t => t.id);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
|
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
|
||||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
||||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||||
|
import PriorityChip from '../../components/PriorityChip';
|
||||||
import './masterkeys.css';
|
import './masterkeys.css';
|
||||||
|
|
||||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||||
@@ -15,7 +16,7 @@ import './masterkeys.css';
|
|||||||
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||||
interface Device {
|
interface Device {
|
||||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
||||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
|
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority;
|
||||||
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +284,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
||||||
</span>
|
</span>
|
||||||
|
<PriorityChip priority={d.priority} />
|
||||||
<div className="mk-device-traffic">
|
<div className="mk-device-traffic">
|
||||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
|
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
|
||||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
|
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
"id": "masterkeys",
|
"id": "masterkeys",
|
||||||
"label": "Мастер-ключи",
|
"label": "Мастер-ключи",
|
||||||
"icon": "key",
|
"icon": "key",
|
||||||
|
"group": "main",
|
||||||
"order": 1.5
|
"order": 1.5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||||
|
import { IcoCopy, IcoTrash } from '../../components/icons';
|
||||||
|
import LineChart from '../../components/LineChart';
|
||||||
|
import './monitor.css';
|
||||||
|
|
||||||
|
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
|
||||||
|
// Кнопка «Подключить Prometheus» просит локальный awg-ctrl поставить его (`awg-ctrl monitor install`, root;
|
||||||
|
// сама панель пакетов не ставит). Графики читают Prometheus через фиксированные запросы панели
|
||||||
|
// (awg-ui/monitor.ts).
|
||||||
|
interface Status {
|
||||||
|
ready: boolean; can_install: boolean; command: string;
|
||||||
|
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
|
||||||
|
}
|
||||||
|
type Pts = [number, number][];
|
||||||
|
interface Overview {
|
||||||
|
range: string; step: number; from: number; to: number;
|
||||||
|
servers: {
|
||||||
|
id: number; name: string; ok: boolean; down: number | null; up: number | null; online: number | null; heavy: number | null; congested: boolean;
|
||||||
|
cpu: number | null; mem: number | null; cores: number | null; mem_total: number | null;
|
||||||
|
}[];
|
||||||
|
total: Series;
|
||||||
|
}
|
||||||
|
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts }
|
||||||
|
|
||||||
|
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа']];
|
||||||
|
|
||||||
|
const nf = (v: number, d = 1) => v.toLocaleString('ru-RU', { maximumFractionDigits: d });
|
||||||
|
// Скорость в битах/с → «12,4 Мбит/с»
|
||||||
|
function rate(v: number, d = 1): string {
|
||||||
|
if (v >= 1e9) return `${nf(v / 1e9, d)} Гбит/с`;
|
||||||
|
if (v >= 1e6) return `${nf(v / 1e6, d)} Мбит/с`;
|
||||||
|
if (v >= 1e3) return `${nf(v / 1e3, d)} Кбит/с`;
|
||||||
|
return `${nf(v, 0)} бит/с`;
|
||||||
|
}
|
||||||
|
const lastOf = (p: Pts) => (p.length ? p[p.length - 1][1] : null);
|
||||||
|
// Полоска нагрузки: цвет заливки — серьёзность (норма → внимание → опасно), дорожка — светлая ступень той же шкалы.
|
||||||
|
// Цифра рядом всегда есть: цвет не единственный носитель смысла.
|
||||||
|
const level = (v: number) => (v >= 90 ? 'mon-bar--bad' : v >= 70 ? 'mon-bar--warn' : '');
|
||||||
|
function Bar({ v, label }: { v: number | null | undefined; label: string }) {
|
||||||
|
const pct = v == null ? 0 : Math.min(100, Math.max(0, v));
|
||||||
|
return (
|
||||||
|
<span className="mon-bar" role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100}
|
||||||
|
aria-valuenow={v == null ? undefined : Math.round(pct)}>
|
||||||
|
<i className={v == null ? '' : level(pct)} style={{ width: `${pct}%` }} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
|
||||||
|
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
|
||||||
|
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
|
||||||
|
function JobProgress({ job, starting }: { job: Status['job']; starting: boolean }) {
|
||||||
|
let step: [number, number, string] | null = null;
|
||||||
|
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
|
||||||
|
const failed = !!job && job.exit !== null && job.exit !== 0;
|
||||||
|
const done = !!job && job.exit === 0;
|
||||||
|
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
|
||||||
|
const label = failed ? `Не удалось (код ${job!.exit})`
|
||||||
|
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
|
||||||
|
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
|
||||||
|
return (
|
||||||
|
<div className="mon-progress" aria-live="polite">
|
||||||
|
<span className={`mon-pbar${failed ? ' mon-pbar--bad' : done ? ' mon-pbar--ok' : !step && (starting || job?.running) ? ' mon-pbar--wait' : ''}`}
|
||||||
|
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
|
||||||
|
<i style={{ width: `${pct}%` }} />
|
||||||
|
</span>
|
||||||
|
<span className="mon-progress-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
|
||||||
|
|
||||||
|
export default function MonitorPage({ token, showMsg }: PageProps) {
|
||||||
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
|
const [range, setRange] = useState('1h');
|
||||||
|
const [ov, setOv] = useState<Overview | null>(null);
|
||||||
|
const [ovErr, setOvErr] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена из этой вкладки
|
||||||
|
const [server, setServer] = useState<NodeInfo | null>(null); // показанный сервер: выбран в другой вкладке, здесь не меняется
|
||||||
|
const alive = useRef(true);
|
||||||
|
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
try { const s = await apiFetch('GET', '/ui/monitor', token) as Status; if (alive.current) setStatus(s); return s; }
|
||||||
|
catch { return null; }
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const loadOverview = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}&server=${getServerId() ?? 0}`, token) as Overview;
|
||||||
|
if (alive.current) { setOv(o); setOvErr(''); }
|
||||||
|
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
|
||||||
|
finally { if (alive.current) setLoading(false); }
|
||||||
|
}, [token, range]);
|
||||||
|
|
||||||
|
useEffect(() => { void loadStatus(); }, [loadStatus]);
|
||||||
|
|
||||||
|
// Название и состояние сервера для заголовка; список серверов на этой вкладке не показываем
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => apiFetch('GET', '/ui/nodes', token)
|
||||||
|
.then(r => { if (alive.current) setServer((r.nodes as NodeInfo[]).find(n => n.id === (getServerId() ?? 0)) ?? null); })
|
||||||
|
.catch(() => { /* заголовок необязателен */ });
|
||||||
|
void load();
|
||||||
|
const t = setInterval(load, 15000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// Пока Prometheus готов — обновляем графики; пока ставится — следим за ходом установки.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!status?.ready) return;
|
||||||
|
void loadOverview();
|
||||||
|
const t = setInterval(() => { void loadOverview(); }, 15000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [status?.ready, loadOverview]);
|
||||||
|
|
||||||
|
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending && !status?.job?.running) return;
|
||||||
|
const t = setInterval(async () => {
|
||||||
|
const s = await loadStatus();
|
||||||
|
if (s && !s.job?.running) setPending(null);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [pending, status?.job?.running, loadStatus]);
|
||||||
|
|
||||||
|
const run = async (action: 'install' | 'remove') => {
|
||||||
|
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 15 дней) сотрутся. Панель продолжит отдавать /metrics.')) return;
|
||||||
|
setPending(action);
|
||||||
|
try { await apiFetch('POST', `/ui/monitor/${action}`, token); await loadStatus(); }
|
||||||
|
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
|
||||||
|
};
|
||||||
|
const working = !!pending || !!status?.job?.running;
|
||||||
|
|
||||||
|
// Прогресс рядом с кнопкой: для установки — пока Prometheus нет (там же виден итог удаления), для удаления — только удаление
|
||||||
|
const progressFor = (action: 'install' | 'remove' | 'any') => {
|
||||||
|
const j = status?.job;
|
||||||
|
const mine = !!j && (action === 'any' || j.action === action) && (j.running || j.exit !== null);
|
||||||
|
const starting = !!pending && (action === 'any' || action === pending);
|
||||||
|
return mine || starting ? <JobProgress job={mine ? j! : null} starting={starting} /> : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt
|
||||||
|
const failureLog = () => {
|
||||||
|
const j = status?.job;
|
||||||
|
if (!j || j.exit === null || j.exit === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<pre className="mon-log">{j.log.trim()}</pre>
|
||||||
|
<p className="login-error">
|
||||||
|
{j.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${j.action}`}</b>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ───────── установка ─────────
|
||||||
|
const setup = status && !status.ready && (
|
||||||
|
<section className="mon-card" aria-labelledby="mon-setup">
|
||||||
|
<div className="mon-head">
|
||||||
|
<h3 className="mon-title" id="mon-setup">Мониторинг</h3>
|
||||||
|
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span>
|
||||||
|
</div>
|
||||||
|
<p className="mon-hint">
|
||||||
|
Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики —
|
||||||
|
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
|
||||||
|
только 127.0.0.1, наружу он не открывается.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{status.can_install ? (
|
||||||
|
<div className="mon-actions">
|
||||||
|
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
|
||||||
|
{pending === 'install' || (status.job?.running && status.job.action === 'install') ? 'Устанавливаем…' : 'Подключить Prometheus'}
|
||||||
|
</button>
|
||||||
|
{progressFor('any')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mon-hint">Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):</p>
|
||||||
|
<code className="mon-code">{status.command}</code>
|
||||||
|
<div className="mon-actions">
|
||||||
|
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
|
||||||
|
<IcoCopy /> Скопировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failureLog()}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Удаление: только пока Prometheus есть
|
||||||
|
const manage = status?.ready && (
|
||||||
|
<section className="mon-card" aria-labelledby="mon-manage">
|
||||||
|
<div className="mon-head">
|
||||||
|
<h3 className="mon-title" id="mon-manage">Prometheus</h3>
|
||||||
|
<span className="chip chip--online"><span className="chip-dot" />работает</span>
|
||||||
|
</div>
|
||||||
|
<p className="mon-hint">
|
||||||
|
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 15 дней. Удаление остановит его, снимет
|
||||||
|
пакет и сотрёт накопленные данные.
|
||||||
|
</p>
|
||||||
|
{status.can_install ? (
|
||||||
|
<div className="mon-actions">
|
||||||
|
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
|
||||||
|
<IcoTrash /> {pending === 'remove' || (status.job?.running && status.job.action === 'remove') ? 'Удаляем…' : 'Удалить Prometheus'}
|
||||||
|
</button>
|
||||||
|
{progressFor('remove')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mon-hint">Выполните на сервере от root:</p>
|
||||||
|
<code className="mon-code">awg-ctrl monitor remove</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status.job?.action === 'remove' && failureLog()}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ───────── дашборд ─────────
|
||||||
|
// Вкладка показывает один сервер — выбранный в сервер-баре; при смене сервера App монтирует её заново.
|
||||||
|
const one = ov?.servers[0];
|
||||||
|
const data = ov?.total ?? null;
|
||||||
|
const cur = data && {
|
||||||
|
down: lastOf(data.down), up: lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy),
|
||||||
|
busy: !!one?.congested,
|
||||||
|
};
|
||||||
|
// Одна единица на всю ось (Мбит/с, а не «50 Мбит» у каждой риски): выбирается по максимуму ряда
|
||||||
|
const peak = data ? Math.max(0, ...data.down.map(p => p[1]), ...data.up.map(p => p[1])) : 0;
|
||||||
|
const unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
|
||||||
|
const dashboard = status?.ready && (
|
||||||
|
<>
|
||||||
|
{/* Период — единственный фильтр: он задаёт окно для всего ниже */}
|
||||||
|
<div className="mon-filters" role="group" aria-label="Период">
|
||||||
|
{RANGES.map(([id, label]) => (
|
||||||
|
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
|
||||||
|
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ovErr && <p className="login-error">{ovErr}</p>}
|
||||||
|
{ov && !one?.ok && (
|
||||||
|
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mon-tiles">
|
||||||
|
<div className="mon-tile">
|
||||||
|
<span className="mon-tile-label">Загрузка сейчас</span>
|
||||||
|
<span className="mon-tile-value">{cur?.down != null ? rate(cur.down) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mon-tile">
|
||||||
|
<span className="mon-tile-label">Отдача сейчас</span>
|
||||||
|
<span className="mon-tile-value">{cur?.up != null ? rate(cur.up) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mon-tile">
|
||||||
|
<span className="mon-tile-label">Устройств онлайн</span>
|
||||||
|
<span className="mon-tile-value">{cur?.online != null ? nf(cur.online, 0) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mon-tile">
|
||||||
|
<span className="mon-tile-label">В фоне</span>
|
||||||
|
<span className="mon-tile-value">{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}</span>
|
||||||
|
<span className="mon-tile-sub">{cur?.busy ? 'канал занят' : 'канал свободен'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mon-tile">
|
||||||
|
<span className="mon-tile-label">Процессор</span>
|
||||||
|
<span className="mon-tile-value">{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}</span>
|
||||||
|
<Bar v={one?.cpu} label="Загрузка процессора" />
|
||||||
|
<span className="mon-tile-sub">{one?.cores ? `${nf(one.cores, 0)} ядер` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mon-tile">
|
||||||
|
<span className="mon-tile-label">Память</span>
|
||||||
|
<span className="mon-tile-value">{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}</span>
|
||||||
|
<Bar v={one?.mem} label="Занято оперативной памяти" />
|
||||||
|
<span className="mon-tile-sub">{one?.mem_total ? `из ${gb(one.mem_total)}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ov && data && (
|
||||||
|
<div className="mon-charts">
|
||||||
|
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||||
|
format={v => rate(v)} formatAxis={v => nf(v / unit.div, unit.div > 1 && v / unit.div < 10 ? 1 : 0)}
|
||||||
|
series={[
|
||||||
|
{ id: 'down', name: 'загрузка', color: 'var(--series-1)', points: data.down },
|
||||||
|
{ id: 'up', name: 'отдача', color: 'var(--series-2)', points: data.up },
|
||||||
|
]} />
|
||||||
|
<LineChart title="Процессор и память, %" from={ov.from} to={ov.to} step={ov.step} dim={loading} max={100}
|
||||||
|
format={v => `${nf(v, 0)} %`}
|
||||||
|
series={[
|
||||||
|
{ id: 'cpu', name: 'процессор', color: 'var(--series-1)', points: data.cpu },
|
||||||
|
{ id: 'mem', name: 'память', color: 'var(--series-2)', points: data.mem },
|
||||||
|
]} />
|
||||||
|
<LineChart title="Устройства" from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||||
|
format={v => nf(v, 0)}
|
||||||
|
series={[
|
||||||
|
{ id: 'online', name: 'онлайн', color: 'var(--series-1)', points: data.online },
|
||||||
|
{ id: 'heavy', name: 'в фоне', color: 'var(--series-2)', points: data.heavy },
|
||||||
|
]} />
|
||||||
|
<LineChart title="Отброшено очередью, пакетов/с" from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||||
|
format={v => nf(v, 1)}
|
||||||
|
series={[{ id: 'drops', name: 'отброшено', color: 'var(--series-1)', points: data.drops }]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{server && (
|
||||||
|
<p className="mon-server">
|
||||||
|
<span className="mon-server-label">Сервер</span>
|
||||||
|
<b>{server.health?.server || server.name}</b>
|
||||||
|
<span className={`chip chip--${server.online ? 'online' : 'offline'}`}><span className="chip-dot" />{server.online ? 'на связи' : 'нет связи'}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!status && <p className="mon-hint">Загрузка…</p>}
|
||||||
|
{setup}
|
||||||
|
{dashboard}
|
||||||
|
{manage}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "monitor",
|
||||||
|
"label": "Монитор",
|
||||||
|
"icon": "pulse",
|
||||||
|
"group": "server",
|
||||||
|
"order": 1.7,
|
||||||
|
"chrome": {
|
||||||
|
"serverBar": false,
|
||||||
|
"restart": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2026 Ivan Vasilev
|
||||||
|
This source code is licensed under the MIT license found in the
|
||||||
|
LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
/* Стили вкладки «Монитор». Общая дизайн-система — в App.css. */
|
||||||
|
.mon-card {
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
background: var(--surface-bright);
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.mon-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||||
|
.mon-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||||
|
.mon-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
|
||||||
|
.mon-code {
|
||||||
|
display: block; word-break: break-all; user-select: all;
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
|
||||||
|
background: var(--surface-container);
|
||||||
|
border-radius: var(--r-table);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mon-actions .btn { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Графики ─────
|
||||||
|
Цвета рядов — токены темы. Проверено validate_palette: светлая #6a45b8 / #c4620a на #fdfaf6,
|
||||||
|
тёмная #8f6be0 / #d9772a на #2b251d (CVD ΔE ≥ 19, контраст ≥ 3:1). Подписи и значения цветом ряда не красим. */
|
||||||
|
:root { --series-1: #6a45b8; --series-2: #c4620a; }
|
||||||
|
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
|
||||||
|
|
||||||
|
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
||||||
|
.mon-range {
|
||||||
|
min-height: 44px; padding: 0 18px;
|
||||||
|
font: inherit; font-size: 14px; color: var(--on-surface);
|
||||||
|
background: var(--surface-bright);
|
||||||
|
border: 1px solid var(--outline); border-radius: var(--r-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mon-range:hover { background: var(--state-primary); }
|
||||||
|
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
|
||||||
|
|
||||||
|
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||||
|
.mon-tile {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
background: var(--surface-bright);
|
||||||
|
border: 1px solid var(--outline-variant); border-radius: var(--r-table);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.mon-tile-label { font-size: 12px; color: var(--neutral-text); }
|
||||||
|
.mon-tile-value { font-size: 20px; font-weight: 500; color: var(--on-surface); }
|
||||||
|
.mon-tile-sub { font-size: 12px; color: var(--neutral-text); }
|
||||||
|
|
||||||
|
.mon-charts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 16px; }
|
||||||
|
@media (min-width: 1000px) {
|
||||||
|
.mon-charts { grid-template-columns: 1fr 1fr; }
|
||||||
|
.mon-charts .mon-chart:first-child { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
.mon-chart {
|
||||||
|
margin: 0; min-width: 0;
|
||||||
|
background: var(--surface-bright);
|
||||||
|
border: 1px solid var(--outline-variant); border-radius: var(--r-card);
|
||||||
|
padding: 16px;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.mon-chart--dim { opacity: .6; }
|
||||||
|
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
|
||||||
|
.mon-chart-title { font-size: 15px; font-weight: 500; color: var(--on-surface); }
|
||||||
|
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--neutral-text); }
|
||||||
|
.mon-legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: 2px; flex: none; }
|
||||||
|
.mon-chart-toggle {
|
||||||
|
margin-left: auto; min-height: 44px; padding: 0 12px;
|
||||||
|
font: inherit; font-size: 13px; color: var(--on-primary-container);
|
||||||
|
background: none; border: 0; border-radius: var(--r-pill); cursor: pointer;
|
||||||
|
}
|
||||||
|
.mon-chart-toggle:hover { background: var(--state-primary); }
|
||||||
|
|
||||||
|
.mon-plot { position: relative; }
|
||||||
|
.mon-plot svg { display: block; max-width: 100%; }
|
||||||
|
.mon-plot svg:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 8px; }
|
||||||
|
.mon-grid { stroke: var(--outline-variant); stroke-width: 1; }
|
||||||
|
.mon-tick { fill: var(--neutral-text); font-size: 12px; font-family: var(--font); }
|
||||||
|
.mon-cross { stroke: var(--outline); stroke-width: 1; }
|
||||||
|
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
|
||||||
|
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: 13px; color: var(--neutral-text); }
|
||||||
|
.mon-tip {
|
||||||
|
position: absolute; pointer-events: none; z-index: 2;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
background: var(--surface-container);
|
||||||
|
border: 1px solid var(--outline-variant); border-radius: 12px;
|
||||||
|
padding: 8px 10px; font-size: 12px; color: var(--neutral-text);
|
||||||
|
}
|
||||||
|
.mon-tip-time { color: var(--neutral-text); }
|
||||||
|
.mon-tip-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.mon-tip-row b { font-weight: 500; font-size: 13px; color: var(--on-surface); }
|
||||||
|
|
||||||
|
.mon-table-wrap { overflow-x: auto; }
|
||||||
|
.mon-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.mon-table th { text-align: left; font-weight: 500; color: var(--label); padding: 8px 10px; white-space: nowrap; }
|
||||||
|
.mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
|
||||||
|
.mon-log {
|
||||||
|
margin: 0; max-height: 240px; overflow: auto;
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
|
||||||
|
background: var(--surface-container); border-radius: var(--r-table);
|
||||||
|
padding: 12px; white-space: pre-wrap; word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mon-notice { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* Полоски процессора и памяти в плитках */
|
||||||
|
.mon-bar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); margin: 2px 0; }
|
||||||
|
.mon-bar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .3s; }
|
||||||
|
.mon-bar i.mon-bar--warn { background: var(--warn-dot); }
|
||||||
|
.mon-bar i.mon-bar--bad { background: var(--error-dot); }
|
||||||
|
|
||||||
|
/* Какой сервер показан (выбирается в других вкладках) */
|
||||||
|
.mon-server { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 12px; font-size: 16px; color: var(--on-surface); }
|
||||||
|
.mon-server b { font-weight: 500; }
|
||||||
|
.mon-server-label { font-size: 12px; color: var(--neutral-text); }
|
||||||
|
|
||||||
|
|
||||||
|
/* Прогресс операции рядом с кнопкой */
|
||||||
|
.mon-actions { align-items: center; }
|
||||||
|
.mon-progress { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.mon-progress-label { font-size: 12px; color: var(--neutral-text); }
|
||||||
|
.mon-pbar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); }
|
||||||
|
.mon-pbar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .4s ease; }
|
||||||
|
.mon-pbar--ok i { background: var(--success-dot); }
|
||||||
|
.mon-pbar--bad i { background: var(--error-dot); }
|
||||||
|
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
|
||||||
|
.mon-pbar--wait i { width: 35% !important; animation: mon-wait 1.2s ease-in-out infinite; }
|
||||||
|
@keyframes mon-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .mon-pbar--wait i { animation: none; } .mon-pbar i { transition: none; } }
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type User, type PageProps, type AwgGen,
|
type User, type PageProps, type AwgGen,
|
||||||
} from '../../lib/shared';
|
} from '../../lib/shared';
|
||||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||||
|
import PriorityChip from '../../components/PriorityChip';
|
||||||
import './users.css';
|
import './users.css';
|
||||||
|
|
||||||
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
|
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
|
||||||
@@ -28,7 +29,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
const data = await apiFetch('GET', '/api/users/stats', tok);
|
const data = await apiFetch('GET', '/api/users/stats', tok);
|
||||||
setUsers(prev => prev.map(u => {
|
setUsers(prev => prev.map(u => {
|
||||||
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
|
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
|
||||||
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx } : u;
|
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority } : u;
|
||||||
}));
|
}));
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -162,6 +163,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
|
<th>Приоритет</th>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
<th>↓ rx</th>
|
<th>↓ rx</th>
|
||||||
@@ -171,7 +173,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr>
|
<tr><td colSpan={8} className="empty">Нет пользователей</td></tr>
|
||||||
) : users.map((u, i) => (
|
) : users.map((u, i) => (
|
||||||
<tr key={u.name}>
|
<tr key={u.name}>
|
||||||
<td className="td-num">{i + 1}</td>
|
<td className="td-num">{i + 1}</td>
|
||||||
@@ -185,6 +187,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
: 'никогда'}
|
: 'никогда'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td><PriorityChip priority={u.priority} /></td>
|
||||||
<td>
|
<td>
|
||||||
{u.name}
|
{u.name}
|
||||||
{u.key_gen !== serverGen && (
|
{u.key_gen !== serverGen && (
|
||||||
@@ -251,6 +254,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
<div className="user-card-ip">
|
<div className="user-card-ip">
|
||||||
<IcoGlobe />
|
<IcoGlobe />
|
||||||
<span className="user-card-ip-text">{u.ip}</span>
|
<span className="user-card-ip-text">{u.ip}</span>
|
||||||
|
<PriorityChip priority={u.priority} />
|
||||||
</div>
|
</div>
|
||||||
<div className="user-card-metrics">
|
<div className="user-card-metrics">
|
||||||
<div className="metric-block">
|
<div className="metric-block">
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
"id": "users",
|
"id": "users",
|
||||||
"label": "Пользователи",
|
"label": "Пользователи",
|
||||||
"icon": "users",
|
"icon": "users",
|
||||||
|
"group": "main",
|
||||||
"order": 1
|
"order": 1
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-3
@@ -72,6 +72,8 @@ const TS_WINDOW = 300; // секунд, ±
|
|||||||
const DEFAULT_LIMIT = 3;
|
const DEFAULT_LIMIT = 3;
|
||||||
const MAX_SERVERS = 32; // серверов в одном мастер-ключе
|
const MAX_SERVERS = 32; // серверов в одном мастер-ключе
|
||||||
const SYNC_EVERY = 60_000; // фоновое сведение серверов
|
const SYNC_EVERY = 60_000; // фоновое сведение серверов
|
||||||
|
const AUDIT_HOUR = 3_600_000;
|
||||||
|
const AUDIT_FIRST = 120_000; // первый аудит — вскоре после старта, когда ноды подключились
|
||||||
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
|
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
|
||||||
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
|
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
|
||||||
|
|
||||||
@@ -407,6 +409,161 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
|||||||
q.revokedGone.run();
|
q.revokedGone.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Аудит серверов ─────────────────────────────────────────────────────
|
||||||
|
// Периодически (по умолчанию раз в час) core обходит все серверы и ищет «мёртвых»: пиров, которые живы на сервере, хотя
|
||||||
|
// панель про них не знает. Сообщает и — если включена очистка (настройки аудита) или оператор нажал кнопку — снимает подтверждённых: мёртвых два прохода подряд.
|
||||||
|
// ghosts — есть в ядре (awg show), но неизвестны awg-ctrl: строку удалили, а `awg set … remove`
|
||||||
|
// не сработал, и никто про такого пира уже не вспомнит;
|
||||||
|
// orphans — есть у awg-ctrl в области этой панели, но панели не нужны (sync их не снял);
|
||||||
|
// key — мастер-ключ пира, если он отозван или удалён.
|
||||||
|
// Пиры чужой области (другая панель, пиры до областей) в отчёт не попадают — считаются в foreign.
|
||||||
|
// seen_before — «мёртвый» и в прошлом проходе тоже: только такие снимает очистка (пир, которого
|
||||||
|
// прямо сейчас добавляют или удаляют, за два прохода подряд мёртвым не окажется).
|
||||||
|
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
|
||||||
|
interface AuditEntry {
|
||||||
|
server_id: number; status: "ok" | "offline" | "error";
|
||||||
|
ghosts_checked: boolean; // false — awg-ctrl старше /api/audit, «призраков» не видно
|
||||||
|
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number;
|
||||||
|
blocked?: number; // очистка отказалась снимать столько пиров разом (предохранитель)
|
||||||
|
}
|
||||||
|
interface AuditReport { at: number; servers: AuditEntry[] }
|
||||||
|
interface CleanedPeer { at: number; server_id: number; pub_key: string; kind: "ghost" | "orphan"; key?: string | null; auto: boolean }
|
||||||
|
// Настройки аудита (панель, вкладка «Аудит»). clean — снимать «мёртвых» сами; every_h — период;
|
||||||
|
// max — предохранитель: больше пиров на сервере за проход не снимаем, только сообщаем.
|
||||||
|
interface AuditSettings { clean: boolean; every_h: number; max: number }
|
||||||
|
const AUDIT_DEFAULTS: AuditSettings = { clean: false, every_h: 1, max: 10 };
|
||||||
|
|
||||||
|
const AUDIT_ON = (process.env.AUDIT ?? "on").toLowerCase() !== "off"; // выключен у ноды со своей панелью
|
||||||
|
const metaGet = <T,>(key: string, dflt: T): T => {
|
||||||
|
try {
|
||||||
|
const row = uidb.prepare("SELECT value FROM sub_meta WHERE key = ?").get(key) as { value: string } | undefined;
|
||||||
|
return row ? JSON.parse(row.value) as T : dflt;
|
||||||
|
} catch { return dflt; }
|
||||||
|
};
|
||||||
|
const metaSet = (key: string, v: unknown) =>
|
||||||
|
uidb.prepare("INSERT INTO sub_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, JSON.stringify(v));
|
||||||
|
let lastAudit: AuditReport | null = metaGet<AuditReport | null>("audit", null);
|
||||||
|
let auditSettings: AuditSettings = { ...AUDIT_DEFAULTS, ...metaGet<Partial<AuditSettings>>("audit_settings", {}) };
|
||||||
|
let cleanedLog: CleanedPeer[] = metaGet<CleanedPeer[]>("audit_cleaned", []);
|
||||||
|
|
||||||
|
async function auditNow(id: number): Promise<AuditEntry> {
|
||||||
|
const entry: AuditEntry = { server_id: id, status: "ok", ghosts_checked: true, ghosts: [], orphans: [], foreign: 0 };
|
||||||
|
if (!servers.exists(id) || !servers.isOnline(id)) return { ...entry, status: "offline", ghosts_checked: false };
|
||||||
|
const ctrl = servers.ctrlFor(id);
|
||||||
|
// Сначала обычное сведение: отчёт — про то, что не снялось само.
|
||||||
|
await reconcileNow(id);
|
||||||
|
const [a, p] = [await ctrl("GET", "/api/audit"), await ctrl("GET", "/api/peers")];
|
||||||
|
if (!hasCtrlOk(p.status)) return { ...entry, status: "error", ghosts_checked: false };
|
||||||
|
if (hasCtrlOk(a.status)) entry.ghosts = (a.data.ghosts as AuditPeer[]).map(g => ({
|
||||||
|
pub_key: g.pub_key, online: g.online, lastHandshake: g.lastHandshake,
|
||||||
|
}));
|
||||||
|
else if (a.status === 404) entry.ghosts_checked = false;
|
||||||
|
else return { ...entry, status: "error", ghosts_checked: false };
|
||||||
|
|
||||||
|
const keep = new Set((q.keepOf.all(id, id) as { pub: string }[]).map(r => r.pub));
|
||||||
|
const prefix = panelId + "/";
|
||||||
|
for (const peer of p.data.peers as { pub_key: string; owner: string; online: boolean; lastHandshake: number }[]) {
|
||||||
|
if (!peer.owner.startsWith(prefix)) { if (peer.owner.includes("/")) entry.foreign++; continue; }
|
||||||
|
if (keep.has(peer.pub_key)) continue;
|
||||||
|
const mid = Number(peer.owner.match(/^[^/]+\/m(\d+)\//)?.[1]);
|
||||||
|
const m = Number.isSafeInteger(mid) ? q.masterRow.get(mid) as MasterRow | undefined : undefined;
|
||||||
|
entry.orphans.push({
|
||||||
|
pub_key: peer.pub_key, owner: peer.owner, online: peer.online, lastHandshake: peer.lastHandshake,
|
||||||
|
key: m ? (m.revoked_at !== null ? `${m.label} (удалён)` : m.label) : "(нет в ui.db)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Под той же блокировкой, что и сведение: пир, которого прямо сейчас добавляет проход, не «призрак».
|
||||||
|
function audit(id: number): Promise<AuditEntry> {
|
||||||
|
const run = (locks.get(id) ?? Promise.resolve()).then(() => auditNow(id));
|
||||||
|
locks.set(id, run.then(() => {}, () => {}));
|
||||||
|
return run.catch((): AuditEntry => ({ server_id: id, status: "error", ghosts_checked: false, ghosts: [], orphans: [], foreign: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Снять «мёртвых» этого сервера: только подтверждённых (seen_before), не больше max за проход.
|
||||||
|
// «Призраки» — через awg-ctrl (он сам перепроверяет ключи), лишние — обычным DELETE.
|
||||||
|
async function cleanEntry(e: AuditEntry, auto: boolean): Promise<void> {
|
||||||
|
const ghosts = e.ghosts.filter(p => p.seen_before);
|
||||||
|
const orphans = e.orphans.filter(p => p.seen_before);
|
||||||
|
if (ghosts.length + orphans.length === 0 || e.status !== "ok") return;
|
||||||
|
if (auto && ghosts.length + orphans.length > auditSettings.max) {
|
||||||
|
e.blocked = ghosts.length + orphans.length;
|
||||||
|
console.warn(`sub: очистка сервера #${e.server_id} пропущена: ${e.blocked} пиров больше предела ${auditSettings.max}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctrl = servers.ctrlFor(e.server_id);
|
||||||
|
const done: CleanedPeer[] = [];
|
||||||
|
const log = (p: AuditPeer, kind: CleanedPeer["kind"]) =>
|
||||||
|
done.push({ at: now(), server_id: e.server_id, pub_key: p.pub_key, kind, key: p.key, auto });
|
||||||
|
if (ghosts.length) {
|
||||||
|
const r = await ctrl("POST", "/api/audit/clean", { pub_keys: ghosts.map(g => g.pub_key) });
|
||||||
|
if (hasCtrlOk(r.status)) {
|
||||||
|
const gone = new Set(r.data.removed as string[]);
|
||||||
|
for (const g of ghosts) if (gone.has(g.pub_key)) log(g, "ghost");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of orphans) {
|
||||||
|
const r = await ctrl("DELETE", `/api/peers/${pubParam(p.pub_key)}`);
|
||||||
|
if (hasCtrlOk(r.status) || r.status === 404) log(p, "orphan");
|
||||||
|
else if (gone502(r.status)) break;
|
||||||
|
}
|
||||||
|
if (!done.length) return;
|
||||||
|
const cleaned = new Set(done.map(d => d.pub_key));
|
||||||
|
e.ghosts = e.ghosts.filter(p => !cleaned.has(p.pub_key));
|
||||||
|
e.orphans = e.orphans.filter(p => !cleaned.has(p.pub_key));
|
||||||
|
cleanedLog = [...done, ...cleanedLog].slice(0, 100);
|
||||||
|
metaSet("audit_cleaned", cleanedLog);
|
||||||
|
console.warn(`sub: ${auto ? "автоочистка" : "очистка"} сервера #${e.server_id}: снято ${done.length} (${done.map(d => d.pub_key.slice(0, 8)).join(", ")})`);
|
||||||
|
}
|
||||||
|
const gone502 = (s: number) => s === 502 || s === 504;
|
||||||
|
|
||||||
|
let auditing: Promise<AuditReport> | null = null;
|
||||||
|
// auto — плановый проход: очищает сам, если включено. Ручной только смотрит и обновляет «видели раньше».
|
||||||
|
function auditAll(auto = false): Promise<AuditReport> {
|
||||||
|
auditing ??= (async () => {
|
||||||
|
const prev = lastAudit;
|
||||||
|
const report: AuditReport = { at: now(), servers: await Promise.all(servers.ids().map(audit)) };
|
||||||
|
for (const e of report.servers) {
|
||||||
|
const before = prev?.servers.find(x => x.server_id === e.server_id);
|
||||||
|
const was = new Set([...(before?.ghosts ?? []), ...(before?.orphans ?? [])].map(p => p.pub_key));
|
||||||
|
for (const p of [...e.ghosts, ...e.orphans]) p.seen_before = was.has(p.pub_key);
|
||||||
|
if (auto && auditSettings.clean) await audit_clean(e);
|
||||||
|
}
|
||||||
|
lastAudit = report;
|
||||||
|
metaSet("audit", report);
|
||||||
|
for (const e of report.servers) {
|
||||||
|
if (e.status === "error") console.warn(`sub: аудит сервера #${e.server_id} не удался`);
|
||||||
|
if (e.ghosts.length || e.orphans.length)
|
||||||
|
console.warn(`sub: аудит сервера #${e.server_id}: мёртвых пиров ${e.ghosts.length + e.orphans.length} ` +
|
||||||
|
`(в ядре без записи: ${e.ghosts.length}, лишних у awg-ctrl: ${e.orphans.length}, онлайн: ` +
|
||||||
|
`${[...e.ghosts, ...e.orphans].filter(x => x.online).length})`);
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
})().finally(() => { auditing = null; });
|
||||||
|
return auditing;
|
||||||
|
}
|
||||||
|
// Очистка под блокировкой сервера — как и сам аудит.
|
||||||
|
function audit_clean(e: AuditEntry): Promise<void> {
|
||||||
|
const run = (locks.get(e.server_id) ?? Promise.resolve()).then(() => cleanEntry(e, true));
|
||||||
|
locks.set(e.server_id, run.then(() => {}, () => {}));
|
||||||
|
return run.catch(err => { console.warn(`sub: очистка сервера #${e.server_id} не удалась: ${(err as Error).message}`); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ручная очистка подтверждённых «мёртвых» по последнему отчёту (кнопка на вкладке «Аудит»).
|
||||||
|
async function cleanNow(): Promise<AuditReport | null> {
|
||||||
|
if (!lastAudit) return null;
|
||||||
|
for (const e of lastAudit.servers) {
|
||||||
|
const run = (locks.get(e.server_id) ?? Promise.resolve()).then(() => cleanEntry(e, false));
|
||||||
|
locks.set(e.server_id, run.then(() => {}, () => {}));
|
||||||
|
await run.catch(() => {});
|
||||||
|
}
|
||||||
|
metaSet("audit", lastAudit);
|
||||||
|
return lastAudit;
|
||||||
|
}
|
||||||
|
const auditState = () => ({ enabled: AUDIT_ON, settings: auditSettings, report: lastAudit, cleaned: cleanedLog });
|
||||||
|
|
||||||
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
|
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
|
||||||
function forgetDevice(dev: DeviceRow): number[] {
|
function forgetDevice(dev: DeviceRow): number[] {
|
||||||
const links = q.linksOf.all(dev.id) as LinkRow[];
|
const links = q.linksOf.all(dev.id) as LinkRow[];
|
||||||
@@ -834,15 +991,17 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
|||||||
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
|
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
|
||||||
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
|
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
|
||||||
async function masterDevices(m: MasterRow) {
|
async function masterDevices(m: MasterRow) {
|
||||||
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
|
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number; background: boolean }>();
|
||||||
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
|
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
|
||||||
const r = await servers.ctrlFor(id)("GET", "/api/peers");
|
const r = await servers.ctrlFor(id)("GET", "/api/peers");
|
||||||
if (!hasCtrlOk(r.status)) return;
|
if (!hasCtrlOk(r.status)) return;
|
||||||
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number }[]) {
|
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: string }[]) {
|
||||||
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0 };
|
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0, background: false };
|
||||||
stats.set(p.pub_key, {
|
stats.set(p.pub_key, {
|
||||||
online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake),
|
online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake),
|
||||||
rx: s.rx + p.rx, tx: s.tx + p.tx,
|
rx: s.rx + p.rx, tx: s.tx + p.tx,
|
||||||
|
// Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них.
|
||||||
|
background: s.background || p.priority === "background",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -853,6 +1012,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
|||||||
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
|
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
|
||||||
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
|
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
|
||||||
online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0,
|
online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0,
|
||||||
|
priority: s?.background ? "background" : "normal",
|
||||||
servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length,
|
servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -876,6 +1036,35 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
|||||||
masterKeys.get("/", (_req, res) => {
|
masterKeys.get("/", (_req, res) => {
|
||||||
res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) });
|
res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) });
|
||||||
});
|
});
|
||||||
|
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
|
||||||
|
masterKeys.get("/audit", (_req, res) => { res.json(auditState()); });
|
||||||
|
masterKeys.post("/audit", aw(async (_req, res) => {
|
||||||
|
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
|
||||||
|
await auditAll();
|
||||||
|
res.json(auditState());
|
||||||
|
}));
|
||||||
|
masterKeys.post("/audit/clean", aw(async (_req, res) => {
|
||||||
|
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
|
||||||
|
await cleanNow();
|
||||||
|
res.json(auditState());
|
||||||
|
}));
|
||||||
|
masterKeys.put("/audit/settings", aw((req, res) => {
|
||||||
|
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
|
||||||
|
const b = (req.body ?? {}) as Partial<AuditSettings>;
|
||||||
|
const next = { ...auditSettings };
|
||||||
|
if (b.clean !== undefined) { if (typeof b.clean !== "boolean") fail(400, "clean: true или false"); next.clean = b.clean; }
|
||||||
|
if (b.every_h !== undefined) {
|
||||||
|
if (!Number.isInteger(b.every_h) || b.every_h < 1 || b.every_h > 168) fail(400, "Период: от 1 до 168 часов");
|
||||||
|
next.every_h = b.every_h;
|
||||||
|
}
|
||||||
|
if (b.max !== undefined) {
|
||||||
|
if (!Number.isInteger(b.max) || b.max < 1 || b.max > 1000) fail(400, "Предел: от 1 до 1000");
|
||||||
|
next.max = b.max;
|
||||||
|
}
|
||||||
|
auditSettings = next;
|
||||||
|
metaSet("audit_settings", next);
|
||||||
|
res.json(auditState());
|
||||||
|
}));
|
||||||
masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); }));
|
masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); }));
|
||||||
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(masterIdOf(req.params.id), null), req.body, null)); }));
|
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(masterIdOf(req.params.id), null), req.body, null)); }));
|
||||||
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
|
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
|
||||||
@@ -955,6 +1144,15 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
|||||||
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
|
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
|
||||||
}, SYNC_EVERY).unref();
|
}, SYNC_EVERY).unref();
|
||||||
|
|
||||||
|
if (AUDIT_ON) {
|
||||||
|
// Период читается из настроек каждый раз: смена в панели действует без рестарта.
|
||||||
|
const next = (ms: number) => setTimeout(() => {
|
||||||
|
auditAll(true).catch(e => console.warn(`sub: аудит не удался: ${(e as Error).message}`))
|
||||||
|
.finally(() => next(auditSettings.every_h * AUDIT_HOUR));
|
||||||
|
}, ms).unref();
|
||||||
|
next(AUDIT_FIRST);
|
||||||
|
}
|
||||||
|
|
||||||
if (!signKey) return;
|
if (!signKey) return;
|
||||||
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
|
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
|
||||||
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
|
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
|
||||||
|
|||||||
+7
-1
@@ -9,6 +9,7 @@ import * as readline from "readline";
|
|||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
import * as http from "http";
|
import * as http from "http";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { monitorCmd } from "./monitor";
|
||||||
|
|
||||||
const ROOT = path.resolve(__dirname, "../..");
|
const ROOT = path.resolve(__dirname, "../..");
|
||||||
const LOGS = path.join(ROOT, "logs");
|
const LOGS = path.join(ROOT, "logs");
|
||||||
@@ -107,6 +108,10 @@ const SERVICES = {
|
|||||||
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
|
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
|
||||||
// core без собственного VPN: awg-ctrl рядом не нужен.
|
// core без собственного VPN: awg-ctrl рядом не нужен.
|
||||||
NODE_PORT: process.env.NODE_PORT ?? "",
|
NODE_PORT: process.env.NODE_PORT ?? "",
|
||||||
|
// Часовой аудит «мёртвых» пиров на всех серверах — только у core (у ноды со своей панелью выключен).
|
||||||
|
AUDIT: process.env.CORE_HOST ? "off" : (process.env.AUDIT ?? "on"),
|
||||||
|
// Пер-пировые серии в /metrics: только по явному согласию оператора (cli.env).
|
||||||
|
METRICS_PEERS: process.env.METRICS_PEERS ?? "off",
|
||||||
LOCAL_NODE: process.env.LOCAL_NODE ?? "on",
|
LOCAL_NODE: process.env.LOCAL_NODE ?? "on",
|
||||||
NODE_TLS_KEY_FILE: process.env.NODE_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/node_tls.key",
|
NODE_TLS_KEY_FILE: process.env.NODE_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/node_tls.key",
|
||||||
NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
|
NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
|
||||||
@@ -640,7 +645,7 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]>";
|
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>";
|
||||||
|
|
||||||
const [,,, sub, arg] = process.argv;
|
const [,,, sub, arg] = process.argv;
|
||||||
|
|
||||||
@@ -657,6 +662,7 @@ async function main() {
|
|||||||
case "join": await joinCore(sub, arg); break;
|
case "join": await joinCore(sub, arg); break;
|
||||||
case "unjoin": await unjoinCore(); break;
|
case "unjoin": await unjoinCore(); break;
|
||||||
case "peers": await peersCmd(sub, arg); break;
|
case "peers": await peersCmd(sub, arg); break;
|
||||||
|
case "monitor": await monitorCmd(sub); break;
|
||||||
default: console.log(USAGE); process.exit(1);
|
default: console.log(USAGE); process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
//
|
||||||
|
// `awg-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор».
|
||||||
|
// Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не ставит, она только просит
|
||||||
|
// (через awg-ctrl) или показывает эту команду. Всё слушает 127.0.0.1: наружу Prometheus не торчит,
|
||||||
|
// а панель читает его по HTTP на той же машине.
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import { readFileSync, writeFileSync, existsSync, copyFileSync, unlinkSync } from "fs";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import * as http from "http";
|
||||||
|
|
||||||
|
const AWG_DIR = "/etc/amnezia/amneziawg";
|
||||||
|
const PROM_DIR = "/etc/prometheus";
|
||||||
|
const CONF_FILE = `${PROM_DIR}/prometheus.yml`;
|
||||||
|
const TOKEN_FILE = `${PROM_DIR}/forgetting.token`;
|
||||||
|
// Хэш локального токена для awg-ui: читает его при проверке /metrics (сам токен знает только Prometheus).
|
||||||
|
export const MONITOR_HASH_FILE = `${AWG_DIR}/monitor_token.sha256`;
|
||||||
|
const DEFAULTS_FILE = "/etc/default/prometheus";
|
||||||
|
const MARKER = "# managed by forgetting";
|
||||||
|
const LISTEN = "127.0.0.1:9090";
|
||||||
|
const RETENTION = "15d";
|
||||||
|
|
||||||
|
const log = (s: string) => console.log(` ${s}`);
|
||||||
|
function fail(s: string): never { console.error(`\n ✗ ${s}\n`); process.exit(1); }
|
||||||
|
|
||||||
|
function sh(cmd: string, args: string[], opts: { quiet?: boolean } = {}) {
|
||||||
|
return spawnSync(cmd, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: opts.quiet ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
|
||||||
|
env: { ...process.env, DEBIAN_FRONTEND: "noninteractive" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function osFamily(): string {
|
||||||
|
try {
|
||||||
|
const t = readFileSync("/etc/os-release", "utf8");
|
||||||
|
const id = t.match(/^ID=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
|
||||||
|
const like = t.match(/^ID_LIKE=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
|
||||||
|
return `${id} ${like}`;
|
||||||
|
} catch { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(timeoutMs = 1500): Promise<boolean> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const [host, port] = LISTEN.split(":");
|
||||||
|
const req = http.get({ host, port: Number(port), path: "/-/ready", timeout: timeoutMs }, res => {
|
||||||
|
res.resume(); resolve(res.statusCode === 200);
|
||||||
|
});
|
||||||
|
req.on("timeout", () => { req.destroy(); resolve(false); });
|
||||||
|
req.on("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = () => sh("dpkg", ["-s", "prometheus"], { quiet: true }).status === 0;
|
||||||
|
const active = () => sh("systemctl", ["is-active", "--quiet", "prometheus"], { quiet: true }).status === 0;
|
||||||
|
const managed = () => existsSync(CONF_FILE) && readFileSync(CONF_FILE, "utf8").includes(MARKER);
|
||||||
|
|
||||||
|
// Новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран.
|
||||||
|
function authBlock(): string {
|
||||||
|
const v = sh("prometheus", ["--version"], { quiet: true });
|
||||||
|
const m = `${v.stdout ?? ""}${v.stderr ?? ""}`.match(/version (\d+)\.(\d+)/);
|
||||||
|
const [maj, min] = m ? [Number(m[1]), Number(m[2])] : [2, 0];
|
||||||
|
return maj > 2 || (maj === 2 && min >= 26)
|
||||||
|
? ` authorization:\n credentials_file: ${TOKEN_FILE}\n`
|
||||||
|
: ` bearer_token_file: ${TOKEN_FILE}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(uiPort: string) {
|
||||||
|
if (existsSync(CONF_FILE)) copyFileSync(CONF_FILE, `${CONF_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
||||||
|
writeFileSync(CONF_FILE, `${MARKER}
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: forgetting
|
||||||
|
scheme: http
|
||||||
|
metrics_path: /metrics
|
||||||
|
${authBlock()} static_configs:
|
||||||
|
- targets: ['127.0.0.1:${uiPort}']
|
||||||
|
`, { mode: 0o644 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш.
|
||||||
|
function writeToken() {
|
||||||
|
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
|
||||||
|
writeFileSync(TOKEN_FILE, token, { mode: 0o640 });
|
||||||
|
sh("chown", ["root:prometheus", TOKEN_FILE], { quiet: true });
|
||||||
|
writeFileSync(MONITOR_HASH_FILE, crypto.createHash("sha256").update(token).digest("hex") + "\n", { mode: 0o644 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setArgs() {
|
||||||
|
const line = `ARGS="--web.listen-address=${LISTEN} --storage.tsdb.retention.time=${RETENTION}"`;
|
||||||
|
let text = existsSync(DEFAULTS_FILE) ? readFileSync(DEFAULTS_FILE, "utf8") : "";
|
||||||
|
if (text) copyFileSync(DEFAULTS_FILE, `${DEFAULTS_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
||||||
|
text = /^ARGS=/m.test(text) ? text.replace(/^ARGS=.*$/m, line) : `${text.replace(/\n?$/, "\n")}${line}\n`;
|
||||||
|
writeFileSync(DEFAULTS_FILE, text, { mode: 0o644 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install() {
|
||||||
|
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor install");
|
||||||
|
if (!/debian|ubuntu/i.test(osFamily())) fail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).");
|
||||||
|
const uiPort = process.env.UI_PORT;
|
||||||
|
if (!uiPort) fail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.");
|
||||||
|
|
||||||
|
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
|
||||||
|
if (installed() && existsSync(CONF_FILE) && !managed()) {
|
||||||
|
fail(`Prometheus уже установлен и настроен не нами (${CONF_FILE}). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("1/4 установка пакета prometheus");
|
||||||
|
if (!installed()) {
|
||||||
|
if (sh("apt-get", ["update", "-qq"]).status !== 0) fail("apt-get update завершился ошибкой");
|
||||||
|
if (sh("apt-get", ["install", "-y", "--no-install-recommends", "prometheus"]).status !== 0) fail("apt-get install prometheus завершился ошибкой");
|
||||||
|
} else log("уже установлен");
|
||||||
|
|
||||||
|
log("2/4 токен и конфиг");
|
||||||
|
writeToken();
|
||||||
|
writeConfig(uiPort);
|
||||||
|
|
||||||
|
log(`3/4 слушаем только ${LISTEN}, хранение ${RETENTION}`);
|
||||||
|
setArgs();
|
||||||
|
|
||||||
|
log("4/4 запуск");
|
||||||
|
sh("systemctl", ["enable", "prometheus"], { quiet: true });
|
||||||
|
if (sh("systemctl", ["restart", "prometheus"]).status !== 0) fail("prometheus не запустился: journalctl -u prometheus");
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (await ready()) { console.log(`\n ✓ Prometheus работает на ${LISTEN}, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n`); return; }
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
fail("Prometheus не ответил за 30 с: journalctl -u prometheus");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function status() {
|
||||||
|
const ok = await ready();
|
||||||
|
console.log(` пакет: ${installed() ? "установлен" : "нет"}`);
|
||||||
|
console.log(` сервис: ${active() ? "работает" : "остановлен"}`);
|
||||||
|
console.log(` наш конфиг: ${managed() ? "да" : "нет"}`);
|
||||||
|
console.log(` готов: ${ok ? `да, ${LISTEN}` : "нет"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove() {
|
||||||
|
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor remove");
|
||||||
|
// Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили.
|
||||||
|
if (installed() && existsSync(CONF_FILE) && !managed()) {
|
||||||
|
fail(`Prometheus настроен не нами (${CONF_FILE}), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("1/3 остановка сервиса");
|
||||||
|
sh("systemctl", ["disable", "--now", "prometheus"], { quiet: true });
|
||||||
|
|
||||||
|
log("2/3 токен");
|
||||||
|
for (const f of [TOKEN_FILE, MONITOR_HASH_FILE]) { try { unlinkSync(f); } catch { /* уже нет */ } }
|
||||||
|
|
||||||
|
log("3/3 удаление пакета и накопленных данных");
|
||||||
|
if (installed() && sh("apt-get", ["purge", "-y", "prometheus"]).status !== 0) fail("apt-get purge prometheus завершился ошибкой");
|
||||||
|
|
||||||
|
console.log("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function monitorCmd(sub?: string) {
|
||||||
|
switch (sub) {
|
||||||
|
case "install": await install(); break;
|
||||||
|
case "status": await status(); break;
|
||||||
|
case "remove": remove(); break;
|
||||||
|
default: console.log("Usage: awg-ctrl monitor <install|status|remove>"); process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user