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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user