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
This commit is contained in:
2026-10-17 21:36:51 +03:00
parent 2388a42275
commit 105f184a93
7 changed files with 218 additions and 47 deletions
+5 -3
View File
@@ -97,7 +97,7 @@ app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
// Монитор: «установка» и «удаление» Prometheus с ходом процесса (как лог CLI на реальном сервере). // Монитор: «установка» и «удаление» Prometheus с ходом процесса (как лог CLI на реальном сервере).
const MON_LOGS = { 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 с."], install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 31d", "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 удалён вместе с накопленными графиками."], 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 monJob: { action: "install" | "remove"; start: number } | null = null;
@@ -118,7 +118,7 @@ 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.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) => { app.get("/ui/monitor/overview", (q, r) => {
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300] }; const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "7d": [604800, 1800], "30d": [2592000, 7200] };
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"]; const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
const to = Math.floor(Date.now() / step) * step, from = to - sec; 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 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))]);
@@ -136,11 +136,13 @@ app.get("/ui/monitor/overview", (q, r) => {
const id = Number(q.query.server ?? 0); const id = Number(q.query.server ?? 0);
const k = id === 0 ? 0.7 : 0.3; const k = id === 0 ? 0.7 : 0.3;
const down1 = scale(down, k), up1 = scale(up, k); const down1 = scale(down, k), up1 = scale(up, k);
const used = (a: [number, number][]) => a.reduce((n, [, v]) => n + v, 0) * step / 8; // бит/с × с → байты
if (id === 3) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] } }); 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, 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, 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, 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 }], 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,
used_down: used(down1), used_up: used(up1) }],
total: { down: down1, up: up1, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0), 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) } }); cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) } });
}); });
+35 -5
View File
@@ -16,11 +16,18 @@ type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ statu
const PROM_URL = process.env.PROM_URL ?? "http://127.0.0.1:9090"; const PROM_URL = process.env.PROM_URL ?? "http://127.0.0.1:9090";
// Шаг подбираем так, чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с). // Шаг подбираем так, чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
// Дальше 30 дней не заглядываем: столько Prometheus хранит (RETENTION в cli/src/monitor.ts).
const RANGES: Record<string, { seconds: number; step: number }> = { const RANGES: Record<string, { seconds: number; step: number }> = {
"1h": { seconds: 3600, step: 15 }, "1h": { seconds: 3600, step: 15 },
"6h": { seconds: 21600, step: 60 }, "6h": { seconds: 21600, step: 60 },
"24h": { seconds: 86400, step: 300 }, "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 Point = [number, number];
type Series = Map<number, { name: string; points: Point[] }>; // server_id → ряд type Series = Map<number, { name: string; points: Point[] }>; // server_id → ряд
@@ -43,6 +50,16 @@ const QUERIES = {
} as const; } as const;
type Key = keyof typeof QUERIES; 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> { async function rangeQuery(query: string, start: number, end: number, step: number): Promise<Series> {
const r = await axios.get(`${PROM_URL}/api/v1/query_range`, { const r = await axios.get(`${PROM_URL}/api/v1/query_range`, {
params: { query, start, end, step }, timeout: 8000, params: { query, start, end, step }, timeout: 8000,
@@ -116,12 +133,17 @@ export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean })
router.get("/overview", async (req: Request, res: Response) => { router.get("/overview", async (req: Request, res: Response) => {
const range = RANGES[String(req.query.range ?? "1h")]; const range = RANGES[String(req.query.range ?? "1h")];
if (!range) { res.status(400).json({ error: "range: 1h, 6h или 24h" }); return; } if (!range) { res.status(400).json({ error: "range: 1h, 6h, 24h, 7d или 30d" }); return; }
const end = Math.floor(Date.now() / 1000); const end = Math.floor(Date.now() / 1000);
const start = end - range.seconds; const start = end - range.seconds;
try { try {
const keys = Object.keys(QUERIES) as Key[]; const keys = Object.keys(QUERIES) as Key[];
const got = await Promise.all(keys.map(k => rangeQuery(QUERIES[k], start, end, range.step))); 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>). // Вкладка показывает один сервер — тот, что выбран в сервер-баре (?server=<id>).
const only = Number.isInteger(Number(req.query.server)) && req.query.server !== undefined ? Number(req.query.server) : null; 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, const s = Object.fromEntries(keys.map((k, i) => [k,
@@ -130,19 +152,27 @@ export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean })
// Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна. // Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна.
const ids = new Set<number>(); const ids = new Set<number>();
for (const k of keys) for (const id of s[k].keys()) ids.add(id); 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 => ({ const servers = [...ids].sort((a, b) => a - b).map(id => ({
id, id,
name: (s.scrape_ok.get(id) ?? s.down.get(id) ?? s.online.get(id))?.name ?? String(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, ok: last(s.scrape_ok.get(id)) === 1,
down: last(s.down.get(id)), down: cur("down", id),
up: last(s.up.get(id)), up: cur("up", id),
online: last(s.online.get(id)), online: last(s.online.get(id)),
heavy: last(s.heavy.get(id)), heavy: last(s.heavy.get(id)),
congested: last(s.congested.get(id)) === 1, congested: last(s.congested.get(id)) === 1,
cpu: last(s.cpu.get(id)), cpu: cur("cpu", id),
mem: last(s.mem.get(id)), mem: last(s.mem.get(id)),
cores: last(s.cores.get(id)), cores: last(s.cores.get(id)),
mem_total: last(s.mem_total.get(id)), mem_total: last(s.mem_total.get(id)),
used_down: usedDown.get(id) ?? null,
used_up: usedUp.get(id) ?? null,
})); }));
+57 -24
View File
@@ -21,12 +21,13 @@ interface Overview {
servers: { servers: {
id: number; name: string; ok: boolean; down: number | null; up: number | null; online: number | null; heavy: number | null; congested: boolean; 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; cpu: number | null; mem: number | null; cores: number | null; mem_total: number | null;
used_down: number | null; used_up: number | null; // байт за весь период
}[]; }[];
total: Series; total: Series;
} }
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts } 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 RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней']];
const nf = (v: number, d = 1) => v.toLocaleString('ru-RU', { maximumFractionDigits: d }); const nf = (v: number, d = 1) => v.toLocaleString('ru-RU', { maximumFractionDigits: d });
// Скорость в битах/с → «12,4 Мбит/с» // Скорость в битах/с → «12,4 Мбит/с»
@@ -72,6 +73,12 @@ function JobProgress({ job, starting }: { job: Status['job']; starting: boolean
); );
} }
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`; const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
// Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт
function vol(b: number): string {
if (b >= 1e12) return `${nf(b / 1e12, 2)} ТБ`;
if (b >= 1e9) return `${nf(b / 1e9, 1)} ГБ`;
return `${nf(b / 1e6, 0)} МБ`;
}
export default function MonitorPage({ token, showMsg }: PageProps) { export default function MonitorPage({ token, showMsg }: PageProps) {
const [status, setStatus] = useState<Status | null>(null); const [status, setStatus] = useState<Status | null>(null);
@@ -130,7 +137,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
}, [pending, status?.job?.running, loadStatus]); }, [pending, status?.job?.running, loadStatus]);
const run = async (action: 'install' | 'remove') => { const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 15 дней) сотрутся. Панель продолжит отдавать /metrics.')) return; if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action); setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}`, token); await loadStatus(); } try { await apiFetch('POST', `/ui/monitor/${action}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); } catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
@@ -203,7 +210,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<span className="chip chip--online"><span className="chip-dot" />работает</span> <span className="chip chip--online"><span className="chip-dot" />работает</span>
</div> </div>
<p className="mon-hint"> <p className="mon-hint">
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 15 дней. Удаление остановит его, снимет Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет
пакет и сотрёт накопленные данные. пакет и сотрёт накопленные данные.
</p> </p>
{status.can_install ? ( {status.can_install ? (
@@ -228,9 +235,11 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
const one = ov?.servers[0]; const one = ov?.servers[0];
const data = ov?.total ?? null; const data = ov?.total ?? null;
const cur = data && { const cur = data && {
down: lastOf(data.down), up: lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy), // Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
down: one?.down ?? lastOf(data.down), up: one?.up ?? lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy),
busy: !!one?.congested, busy: !!one?.congested,
}; };
const used = one?.used_down != null && one.used_up != null ? one.used_down + one.used_up : null;
// Одна единица на всю ось (Мбит/с, а не «50 Мбит» у каждой риски): выбирается по максимуму ряда // Одна единица на всю ось (Мбит/с, а не «50 Мбит» у каждой риски): выбирается по максимуму ряда
const peak = data ? Math.max(0, ...data.down.map(p => p[1]), ...data.up.map(p => p[1])) : 0; 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 unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
@@ -251,33 +260,57 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<div className="mon-tiles"> <div className="mon-tiles">
<div className="mon-tile"> <div className="mon-tile">
<span className="mon-tile-label">Загрузка сейчас</span> <span className="mon-tile-label">Трафик сейчас</span>
<span className="mon-tile-value">{cur?.down != null ? rate(cur.down) : '—'}</span> {/* Стрелки окрашены как серии графика «Трафик» ниже */}
<div className="mon-tile-rates">
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dir" style={{ color: 'var(--series-1)' }} role="img" aria-label="загрузка">↓</span>
{cur?.down != null ? rate(cur.down) : '—'}
</span>
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dir" style={{ color: 'var(--series-2)' }} role="img" aria-label="отдача">↑</span>
{cur?.up != null ? rate(cur.up) : '—'}
</span>
</div>
<span className="mon-tile-value mon-tile-rate mon-tile-total">
{used != null ? vol(used) : '—'}
<span className="mon-tile-sub">потрачено за {RANGES.find(([id]) => id === range)?.[1]}</span>
</span>
</div> </div>
<div className="mon-tile"> <div className="mon-tile">
<span className="mon-tile-label">Отдача сейчас</span> <span className="mon-tile-label">Устройства</span>
<span className="mon-tile-value">{cur?.up != null ? rate(cur.up) : '—'}</span> {/* Точки окрашены как серии графика «Устройства» ниже */}
<div className="mon-tile-rates">
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dot" style={{ background: 'var(--series-1)' }} aria-hidden="true" />
{cur?.online != null ? nf(cur.online, 0) : '—'}
<span className="mon-tile-sub">онлайн</span>
</span>
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dot" style={{ background: 'var(--series-2)' }} aria-hidden="true" />
{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}
<span className="mon-tile-sub">в фоне · {cur?.busy ? 'канал занят' : 'канал свободен'}</span>
</span>
</div>
</div> </div>
<div className="mon-tile"> <div className="mon-tile">
<span className="mon-tile-label">Устройств онлайн</span> <span className="mon-tile-label">Нагрузка</span>
<span className="mon-tile-value">{cur?.online != null ? nf(cur.online, 0) : '—'}</span> <div className="mon-tile-rates">
</div> <div className="mon-tile-meter">
<div className="mon-tile"> <span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-label">В фоне</span> {one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}
<span className="mon-tile-value">{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}</span> <span className="mon-tile-sub">процессор{one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}</span>
<span className="mon-tile-sub">{cur?.busy ? 'канал занят' : 'канал свободен'}</span> </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="Загрузка процессора" /> <Bar v={one?.cpu} label="Загрузка процессора" />
<span className="mon-tile-sub">{one?.cores ? `${nf(one.cores, 0)} ядер` : ''}</span>
</div> </div>
<div className="mon-tile"> <div className="mon-tile-meter">
<span className="mon-tile-label">Память</span> <span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-value">{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}</span> {one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}
<span className="mon-tile-sub">память{one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}</span>
</span>
<Bar v={one?.mem} label="Занято оперативной памяти" /> <Bar v={one?.mem} label="Занято оперативной памяти" />
<span className="mon-tile-sub">{one?.mem_total ? `из ${gb(one.mem_total)}` : ''}</span> </div>
</div>
</div> </div>
</div> </div>
+6
View File
@@ -56,6 +56,12 @@ LICENSE file in the root directory of this source tree.
.mon-tile-label { font-size: 12px; color: var(--neutral-text); } .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-value { font-size: 20px; font-weight: 500; color: var(--on-surface); }
.mon-tile-sub { font-size: 12px; color: var(--neutral-text); } .mon-tile-sub { font-size: 12px; color: var(--neutral-text); }
.mon-tile-rates { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
.mon-tile-rate { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; white-space: nowrap; }
.mon-tile-dir { font-size: 16px; }
.mon-tile-total { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--outline-variant); }
.mon-tile-meter { display: flex; flex-direction: column; gap: 4px; }
.mon-tile-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
.mon-charts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 16px; } .mon-charts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 16px; }
@media (min-width: 1000px) { @media (min-width: 1000px) {
+1 -1
View File
@@ -20,7 +20,7 @@ export const MONITOR_HASH_FILE = `${AWG_DIR}/monitor_token.sha256`;
const DEFAULTS_FILE = "/etc/default/prometheus"; const DEFAULTS_FILE = "/etc/default/prometheus";
const MARKER = "# managed by forgetting"; const MARKER = "# managed by forgetting";
const LISTEN = "127.0.0.1:9090"; const LISTEN = "127.0.0.1:9090";
const RETENTION = "15d"; const RETENTION = "31d"; // с запасом на вид «30 дней» во вкладке «Монитор»
const log = (s: string) => console.log(` ${s}`); const log = (s: string) => console.log(` ${s}`);
function fail(s: string): never { console.error(`\n ✗ ${s}\n`); process.exit(1); } function fail(s: string): never { console.error(`\n ✗ ${s}\n`); process.exit(1); }
+1 -1
View File
@@ -29,7 +29,7 @@ const (
promDefaults = "/etc/default/prometheus" promDefaults = "/etc/default/prometheus"
promMarker = "# managed by forgetting" promMarker = "# managed by forgetting"
promListen = "127.0.0.1:9090" promListen = "127.0.0.1:9090"
promRetention = "15d" promRetention = "31d" // с запасом на вид «30 дней» во вкладке «Монитор»
) )
func mlog(s string) { fmt.Println(" " + s) } func mlog(s string) { fmt.Println(" " + s) }
+107 -7
View File
@@ -4,7 +4,7 @@ package ui
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки // GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root) // POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove` // POST /ui/monitor/remove — то же для `awg-ctrl monitor remove`
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч // GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч / 7 дн / 30 дн
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами. // Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
import ( import (
@@ -25,7 +25,20 @@ import (
type promRange struct{ seconds, step int64 } type promRange struct{ seconds, step int64 }
// Шаг — чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с). // Шаг — чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
var promRanges = map[string]promRange{"1h": {3600, 15}, "6h": {21600, 60}, "24h": {86400, 300}} // Дальше 30 дней не заглядываем: столько Prometheus хранит (promRetention в internal/cli/monitor.go).
var promRanges = map[string]promRange{
"1h": {3600, 15}, "6h": {21600, 60}, "24h": {86400, 300},
"7d": {604800, 1800}, "30d": {2592000, 7200},
}
// Окно rate() — не короче шага: иначе на неделе/месяце точка была бы случайной двухминутной выборкой
// из двух часов, а не средним за них. Значения «сейчас» при этом считаются отдельно, по окну 2m.
const promRateWindow = "2m"
func withWindow(q, w string) string { return strings.ReplaceAll(q, "["+promRateWindow+"]", "["+w+"]") }
// rated — ряды, чьё «сейчас» показывает вкладка; при расширенном окне их берём мгновенным запросом.
var rated = []string{"down", "up", "cpu"}
// Запросы по серверам. Трафик — со счётчиков интерфейса (монотонны), а не с сумм по пирам. // Запросы по серверам. Трафик — со счётчиков интерфейса (монотонны), а не с сумм по пирам.
var promQueries = []struct{ key, q string }{ var promQueries = []struct{ key, q string }{
@@ -132,6 +145,39 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
return out, nil return out, nil
} }
// instantQuery — значение на момент time по каждому server_id.
func (m *Monitor) instantQuery(query string, time_ int64) (map[int]float64, error) {
q := url.Values{}
q.Set("query", query)
q.Set("time", strconv.FormatInt(time_, 10))
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
resp, err := c.Get(m.promURL + "/api/v1/query?" + q.Encode())
if err != nil {
return nil, errProm
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errProm
}
out := map[int]float64{}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
metric := field(r, "metric")
if field(metric, "server_id") == nil {
continue
}
idf := jsToNumberAny(field(metric, "server_id"))
pair := arr(field(r, "value"))
if idf != float64(int(idf)) || len(pair) < 2 {
continue
}
if v := jsToNumberAny(pair[1]); !math.IsNaN(v) && !math.IsInf(v, 0) {
out[int(idf)] = v
}
}
return out, nil
}
// total — сумма (или максимум) рядов по меткам времени. // total — сумма (или максимум) рядов по меткам времени.
func total(s *series, max bool) []any { func total(s *series, max bool) []any {
acc := map[float64]float64{} acc := map[float64]float64{}
@@ -246,16 +292,54 @@ func (m *Monitor) router() *Router {
} }
rg, ok := promRanges[rangeKey] rg, ok := promRanges[rangeKey]
if !ok { if !ok {
return c.Error(400, "range: 1h, 6h или 24h") return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d")
} }
end := now() end := now()
start := end - rg.seconds start := end - rg.seconds
window := promRateWindow
if rg.step > 120 {
window = strconv.FormatInt(rg.step, 10) + "s"
}
got := make([]*series, len(promQueries)) got := make([]*series, len(promQueries))
errs := make([]error, len(promQueries)) errs := make([]error, len(promQueries)+2+len(rated))
// Объём за весь период: increase() по тем же монотонным счётчикам (сбросы счётчика он учитывает).
// Окно расширено — «сейчас» с конца ряда было бы средним за шаг; берём мгновенные значения по 2m.
var usedDown, usedUp map[int]float64
nowVals := map[string]map[int]float64{}
var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
for i, pq := range promQueries { for i, pq := range promQueries {
wg.Add(1) wg.Add(1)
go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(pq.q, start, end, rg.step) }() go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(withWindow(pq.q, window), start, end, rg.step) }()
}
span := strconv.FormatInt(rg.seconds, 10) + "s"
n := len(promQueries)
wg.Add(2)
go func() {
defer wg.Done()
usedDown, errs[n] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
}()
go func() {
defer wg.Done()
usedUp, errs[n+1] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
}()
if window != promRateWindow {
for j, k := range rated {
for _, pq := range promQueries {
if pq.key != k {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
v, err := m.instantQuery(pq.q, end)
errs[n+2+j] = err
mu.Lock()
nowVals[k] = v
mu.Unlock()
}()
}
}
} }
wg.Wait() wg.Wait()
for _, err := range errs { for _, err := range errs {
@@ -263,6 +347,12 @@ func (m *Monitor) router() *Router {
return c.Error(502, err.Error()) return c.Error(502, err.Error())
} }
} }
used := func(v map[int]float64, id int) any {
if x, ok := v[id]; ok {
return x
}
return nil
}
// Вкладка показывает один сервер — выбранный в сервер-баре (?server=<id>). // Вкладка показывает один сервер — выбранный в сервер-баре (?server=<id>).
if _, has := qs["server"]; has { if _, has := qs["server"]; has {
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) { if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) {
@@ -280,6 +370,15 @@ func (m *Monitor) router() *Router {
for i, pq := range promQueries { for i, pq := range promQueries {
s[pq.key] = got[i] s[pq.key] = got[i]
} }
cur := func(k string, id int) any {
if v, ok := nowVals[k]; ok {
if x, ok := v[id]; ok {
return x
}
return nil
}
return last(s[k].get(id))
}
ids := map[int]bool{} ids := map[int]bool{}
for _, x := range got { for _, x := range got {
for _, id := range x.ids { for _, id := range x.ids {
@@ -302,11 +401,12 @@ func (m *Monitor) router() *Router {
} }
servers = append(servers, jsjson.Object{ servers = append(servers, jsjson.Object{
{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "ok", Val: last(s["scrape_ok"].get(id)) == 1.0}, {Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "ok", Val: last(s["scrape_ok"].get(id)) == 1.0},
{Key: "down", Val: last(s["down"].get(id))}, {Key: "up", Val: last(s["up"].get(id))}, {Key: "down", Val: cur("down", id)}, {Key: "up", Val: cur("up", id)},
{Key: "online", Val: last(s["online"].get(id))}, {Key: "heavy", Val: last(s["heavy"].get(id))}, {Key: "online", Val: last(s["online"].get(id))}, {Key: "heavy", Val: last(s["heavy"].get(id))},
{Key: "congested", Val: last(s["congested"].get(id)) == 1.0}, {Key: "cpu", Val: last(s["cpu"].get(id))}, {Key: "congested", Val: last(s["congested"].get(id)) == 1.0}, {Key: "cpu", Val: cur("cpu", id)},
{Key: "mem", Val: last(s["mem"].get(id))}, {Key: "cores", Val: last(s["cores"].get(id))}, {Key: "mem", Val: last(s["mem"].get(id))}, {Key: "cores", Val: last(s["cores"].get(id))},
{Key: "mem_total", Val: last(s["mem_total"].get(id))}, {Key: "mem_total", Val: last(s["mem_total"].get(id))},
{Key: "used_down", Val: used(usedDown, id)}, {Key: "used_up", Val: used(usedUp, id)},
}) })
} }
var rangeOut any = "1h" var rangeOut any = "1h"