Files
maeneko 105f184a93 Monitor: 7 and 30 day views, traffic used, merged tiles
- ranges 7d (30 min step) and 30d (2 h step); Prometheus keeps 31d
- rate() window is at least the step, so long views show averages
  instead of a 2-minute sample; "now" values still use a 2m window
- overview returns used_down/used_up: increase() of the interface
  counters over the whole range
- tiles merged: traffic (with volume for the period), devices, load
2026-10-17 21:36:51 +03:00

196 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 с).
// Дальше 30 дней не заглядываем: столько Prometheus хранит (RETENTION в cli/src/monitor.ts).
const RANGES: Record<string, { seconds: number; step: number }> = {
"1h": { seconds: 3600, step: 15 },
"6h": { seconds: 21600, step: 60 },
"24h": { seconds: 86400, step: 300 },
"7d": { seconds: 604800, step: 1800 },
"30d": { seconds: 2592000, step: 7200 },
};
// Окно rate() — не короче шага: иначе на неделе/месяце точка была бы случайной двухминутной выборкой
// из двух часов, а не средним за них. Значения «сейчас» при этом считаются отдельно, по окну 2m.
const RATE_WINDOW = "2m";
const withWindow = (q: string, w: string) => q.split(`[${RATE_WINDOW}]`).join(`[${w}]`);
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 instantQuery(query: string, time: number): Promise<Map<number, number>> {
const r = await axios.get(`${PROM_URL}/api/v1/query`, { params: { query, time }, timeout: 8000 });
const out = new Map<number, number>();
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; value: [number, string] }[]) {
const id = Number(m.metric.server_id), v = Number(m.value[1]);
if (Number.isInteger(id) && Number.isFinite(v)) out.set(id, v);
}
return out;
}
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, 7d или 30d" }); return; }
const end = Math.floor(Date.now() / 1000);
const start = end - range.seconds;
try {
const keys = Object.keys(QUERIES) as Key[];
const window = range.step > 120 ? `${range.step}s` : RATE_WINDOW;
const got = await Promise.all(keys.map(k => rangeQuery(withWindow(QUERIES[k], window), start, end, range.step)));
// Окно расширено — «сейчас» с конца ряда было бы средним за шаг; берём мгновенные значения по 2m.
const rated: Key[] = ["down", "up", "cpu"];
const now = window === RATE_WINDOW ? null
: new Map(await Promise.all(rated.map(async k => [k, await instantQuery(QUERIES[k], end)] as const)));
// Вкладка показывает один сервер — тот, что выбран в сервер-баре (?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);
// Объём за весь период: increase() по тем же монотонным счётчикам интерфейса (сбросы счётчика он учитывает)
const [usedDown, usedUp] = await Promise.all([
instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[${range.seconds}s]))`, end),
instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[${range.seconds}s]))`, end),
]);
const cur = (k: Key, id: number) => now?.has(k) ? (now.get(k)!.get(id) ?? null) : last(s[k].get(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: cur("down", id),
up: cur("up", id),
online: last(s.online.get(id)),
heavy: last(s.heavy.get(id)),
congested: last(s.congested.get(id)) === 1,
cpu: cur("cpu", id),
mem: last(s.mem.get(id)),
cores: last(s.cores.get(id)),
mem_total: last(s.mem_total.get(id)),
used_down: usedDown.get(id) ?? null,
used_up: usedUp.get(id) ?? null,
}));
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 };
}