// 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; // сама панель пакетов не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента. // Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go). interface Status { ready: boolean; can_install: boolean; command: string; host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере 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; used_down: number | null; used_up: 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 часа'], ['7d', '7 дней'], ['30d', '30 дней']]; 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 ( ); } // Прогресс операции из строк «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 (
{label}
); } 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) { const [status, setStatus] = useState(null); const [range, setRange] = useState('1h'); const [ov, setOv] = useState(null); const [ovErr, setOvErr] = useState(''); const [loading, setLoading] = useState(false); const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена из этой вкладки const [server, setServer] = useState(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?server=${getServerId() ?? 0}`, 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? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return; setPending(action); try { await apiFetch('POST', `/ui/monitor/${action}?server=${getServerId() ?? 0}`, 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 ? : null; }; // Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt const failureLog = () => { const j = status?.job; if (!j || j.exit === null || j.exit === 0) return null; return ( <>
{j.log.trim()}

{j.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: {`awg-ctrl monitor ${j.action}`}

); }; // ───────── установка ───────── const setup = status && !status.ready && (

Мониторинг

не подключён

Панель поставит Prometheus {status.host ? <>на сервер {status.host.name} : 'на этот сервер'} и сама подключит его к своим метрикам: основные графики — скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает только 127.0.0.1, наружу он не открывается.

{status.can_install ? (
{progressFor('any')}
) : ( <>

У этой панели нет своего VPN-сервера: выберите ноду в сервер-баре, чтобы поставить Prometheus на неё кнопкой, или выполните на ней от root:

{status.command}
)} {failureLog()}
); // Удаление: только пока Prometheus есть const manage = status?.ready && (

Prometheus

работает

Стоит {status.host ? <>на сервере {status.host.name} (панель читает его через агента ноды) : 'на этом сервере'} и слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет пакет и сотрёт накопленные данные.

{status.can_install ? (
{progressFor('remove')}
) : ( <>

Выполните на сервере с Prometheus от root:

awg-ctrl monitor remove )} {status.job?.action === 'remove' && failureLog()}
); // ───────── дашборд ───────── // Вкладка показывает один сервер — выбранный в сервер-баре; при смене сервера App монтирует её заново. const one = ov?.servers[0]; const data = ov?.total ?? null; const cur = data && { // Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее down: one?.down ?? lastOf(data.down), up: one?.up ?? lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy), busy: !!one?.congested, }; const used = one?.used_down != null && one.used_up != null ? one.used_down + one.used_up : null; // Одна единица на всю ось (Мбит/с, а не «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 && ( <> {/* Период — единственный фильтр: он задаёт окно для всего ниже */}
{RANGES.map(([id, label]) => ( ))}
{ovErr &&

{ovErr}

} {ov && !one?.ok && (

Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.

)}
Трафик сейчас {/* Стрелки окрашены как серии графика «Трафик» ниже */}
↓ {cur?.down != null ? rate(cur.down) : '—'} ↑ {cur?.up != null ? rate(cur.up) : '—'}
{used != null ? vol(used) : '—'} потрачено за {RANGES.find(([id]) => id === range)?.[1]}
Устройства {/* Точки окрашены как серии графика «Устройства» ниже */}
Нагрузка
{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'} процессор{one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}
{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'} память{one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}
{ov && data && (
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 }, ]} /> `${nf(v, 0)} %`} series={[ { id: 'cpu', name: 'процессор', color: 'var(--series-1)', points: data.cpu }, { id: 'mem', name: 'память', color: 'var(--series-2)', points: data.mem }, ]} /> nf(v, 0)} series={[ { id: 'online', name: 'онлайн', color: 'var(--series-1)', points: data.online }, { id: 'heavy', name: 'в фоне', color: 'var(--series-2)', points: data.heavy }, ]} /> nf(v, 1)} series={[{ id: 'drops', name: 'отброшено', color: 'var(--series-1)', points: data.drops }]} />
)} ); return ( <> {server && (

Сервер {server.health?.server || server.name} {server.online ? 'на связи' : 'нет связи'}

)} {!status &&

Загрузка…

} {setup} {dashboard} {manage} ); }