// 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 = { "1h": { seconds: 3600, step: 15 }, "6h": { seconds: 21600, step: 60 }, "24h": { seconds: 86400, step: 300 }, }; type Point = [number, number]; type Series = Map; // 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 { 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; 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(); 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 => { 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 { 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=). 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; // Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна. const ids = new Set(); 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 }; }