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.
This commit is contained in:
@@ -332,6 +332,8 @@ body {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.nav-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
|
||||
.nav-group-title { padding: 8px 12px 4px; font-size: 12px; color: var(--neutral-text); user-select: none; }
|
||||
.nav-item--active {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
@@ -433,6 +435,19 @@ body {
|
||||
user-select: all;
|
||||
}
|
||||
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
|
||||
/* Настройки сервера: деление канала */
|
||||
.ss-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; }
|
||||
.ss-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.ss-setting-name { font-size: 14px; color: var(--on-surface); }
|
||||
.ss-setting input[type="checkbox"] { width: 20px; height: 20px; flex: none; accent-color: var(--primary); }
|
||||
.ss-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.ss-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.ss-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: var(--surface-container); border-radius: var(--r-table); padding: 12px; }
|
||||
.ss-mono { font-family: var(--mono); font-size: 12px; color: var(--neutral-text); word-break: break-all; }
|
||||
/* Тёплый чип «внимание»: канал занят, фоновый приоритет */
|
||||
.chip--warm { background: var(--tertiary-container); color: var(--on-tertiary-container); }
|
||||
.chip--warm .chip-dot { background: var(--on-tertiary-container); }
|
||||
.server-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+27
-9
@@ -11,7 +11,8 @@ import {
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
||||
import ServerBar from './components/ServerBar';
|
||||
import AddServerModal from './components/AddServerModal';
|
||||
import { TABS } from './tabs';
|
||||
import ServerSettingsModal from './components/ServerSettingsModal';
|
||||
import { TABS, TABS_BY_GROUP } from './tabs';
|
||||
|
||||
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
||||
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
||||
@@ -26,6 +27,7 @@ export default function App() {
|
||||
const [panelVersion, setPanelVersion] = useState<string | null>(null);
|
||||
const [serverId, setServerIdState] = useState<number | null>(null);
|
||||
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
||||
const [settingsFor, setSettingsFor] = useState<NodeInfo | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
||||
@@ -288,14 +290,19 @@ export default function App() {
|
||||
</span>
|
||||
</div>
|
||||
<hr className="drawer-divider" />
|
||||
{TABS.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
|
||||
>
|
||||
<t.Icon /> {t.label}
|
||||
</div>
|
||||
{TABS_BY_GROUP.map(g => (
|
||||
<nav key={g.id} className="nav-group" aria-label={g.label}>
|
||||
<span className="nav-group-title">{g.label}</span>
|
||||
{g.tabs.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
|
||||
>
|
||||
<t.Icon /> {t.label}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
))}
|
||||
|
||||
<div className="sidebar-spacer" />
|
||||
@@ -318,6 +325,8 @@ export default function App() {
|
||||
onAdd={() => setModal({})}
|
||||
onJoin={n => setModal({ node: n })}
|
||||
onDelete={deleteNode}
|
||||
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
|
||||
onSettings={n => { pickServer(n.id); setSettingsFor(n); }}
|
||||
onRestartAwg={restartAwg}
|
||||
restarting={restarting}
|
||||
showRestart={showRestart}
|
||||
@@ -346,6 +355,15 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{settingsFor && (
|
||||
<ServerSettingsModal
|
||||
token={token}
|
||||
node={settingsFor}
|
||||
onClose={() => setSettingsFor(null)}
|
||||
showMsg={showMsg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{msg && <div className="snack">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// 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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Линейный график для вкладки «Монитор». Одна ось, линия 2 px, конечная точка с кольцом цвета поверхности,
|
||||
// перекрестие с подсказкой по всем рядам, легенда при ≥ 2 рядах, табличный вид и управление стрелками.
|
||||
// Цвета рядов — токены темы (--series-N в monitor.css), подписи и значения — всегда цветом текста.
|
||||
export interface ChartSeries { id: string; name: string; color: string; points: [number, number][] }
|
||||
|
||||
const M = { left: 46, right: 14, top: 10, bottom: 24 };
|
||||
|
||||
// Шкала оси: круглый шаг (1, 2, 2.5, 5 × 10ⁿ) и 3–5 интервалов, чтобы подписи были 0 / 50 / 100 / 150, а не 0 / 63 / 125.
|
||||
function niceScale(max: number): { top: number; ticks: number[] } {
|
||||
if (!(max > 0)) return { top: 1, ticks: [0, 1] };
|
||||
const raw = max / 4;
|
||||
const p = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||
const step = ([1, 2, 2.5, 5, 10].map(f => f * p).find(s => s >= raw)) ?? 10 * p;
|
||||
const n = Math.max(1, Math.ceil(max / step - 1e-9));
|
||||
return { top: step * n, ticks: Array.from({ length: n + 1 }, (_, i) => step * i) };
|
||||
}
|
||||
|
||||
const timeLabel = (t: number, span: number) => {
|
||||
const d = new Date(t * 1000);
|
||||
const hm = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
return span > 86400 * 1.5 ? `${d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} ${hm}` : hm;
|
||||
};
|
||||
|
||||
export default function LineChart({ title, series, from, to, step, format, formatAxis, max, dim = false }: {
|
||||
title: string;
|
||||
series: ChartSeries[];
|
||||
from: number;
|
||||
to: number;
|
||||
step: number; // шаг данных, с: разрыв длиннее трёх шагов рвёт линию, а не рисует мост
|
||||
format: (v: number) => string; // значение в подсказке и таблице
|
||||
formatAxis?: (v: number) => string; // компактная подпись оси
|
||||
max?: number; // фиксированный верх оси (проценты — 100); без него подбирается по данным
|
||||
dim?: boolean; // идёт перезагрузка: держим прежний кадр бледнее
|
||||
}) {
|
||||
const wrap = useRef<HTMLDivElement>(null); // содержимое карточки без её padding: от него зависит ширина svg
|
||||
const [w, setW] = useState(640);
|
||||
const [hover, setHover] = useState<number | null>(null); // индекс по оси времени
|
||||
const [table, setTable] = useState(false);
|
||||
const H = 200;
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrap.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => setW(Math.max(260, el.clientWidth)));
|
||||
ro.observe(el); setW(Math.max(260, el.clientWidth));
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Общая ось времени: метки первого непустого ряда (Prometheus выравнивает точки по шагу).
|
||||
const times = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
for (const s of series) for (const p of s.points) set.add(p[0]);
|
||||
return [...set].sort((a, b) => a - b);
|
||||
}, [series]);
|
||||
const byTime = useMemo(() => series.map(s => new Map(s.points)), [series]);
|
||||
|
||||
const empty = times.length === 0;
|
||||
const { top: yMax, ticks: yTicks } = max !== undefined
|
||||
? { top: max, ticks: [0, 1, 2, 3, 4].map(i => (max / 4) * i) }
|
||||
: niceScale(Math.max(0, ...series.flatMap(s => s.points.map(p => p[1]))) * 1.04);
|
||||
const x = (t: number) => M.left + ((t - from) / Math.max(1, to - from)) * (w - M.left - M.right);
|
||||
const y = (v: number) => H - M.bottom - (v / yMax) * (H - M.top - M.bottom);
|
||||
|
||||
const path = (pts: [number, number][]) => {
|
||||
let d = '', prev: number | null = null;
|
||||
for (const [t, v] of pts) {
|
||||
d += `${prev !== null && t - prev <= step * 3 ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`;
|
||||
prev = t;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
const span = to - from;
|
||||
const xTicks = [0, 1, 2, 3, 4].map(i => from + (span / 4) * i);
|
||||
const axis = formatAxis ?? format;
|
||||
|
||||
const onMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||
if (!times.length) return;
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const t = from + ((e.clientX - r.left) / Math.max(1, r.width)) * span;
|
||||
let best = 0;
|
||||
for (let i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
|
||||
setHover(best);
|
||||
};
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (!times.length) return;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); setHover(h => Math.max(0, (h ?? times.length) - 1)); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); setHover(h => Math.min(times.length - 1, (h ?? -1) + 1)); }
|
||||
if (e.key === 'Escape') setHover(null);
|
||||
};
|
||||
|
||||
const ht = hover !== null ? times[hover] : null;
|
||||
// Подсказка не вылезает за правый край: правее середины открывается влево от перекрестия
|
||||
const flip = ht !== null && x(ht) > w * 0.55;
|
||||
|
||||
// Табличный вид — те же числа без наведения: ~12 равномерно выбранных строк, последняя — самая свежая
|
||||
const rows = useMemo(() => {
|
||||
if (!times.length) return [] as number[];
|
||||
const n = Math.min(12, times.length);
|
||||
return Array.from({ length: n }, (_, i) => times[Math.round(((times.length - 1) * (i + 1)) / n)]).reverse();
|
||||
}, [times]);
|
||||
|
||||
return (
|
||||
<figure className={`mon-chart${dim ? ' mon-chart--dim' : ''}`}>
|
||||
<figcaption className="mon-chart-head">
|
||||
<span className="mon-chart-title">{title}</span>
|
||||
{series.length >= 2 && (
|
||||
<span className="mon-legend">
|
||||
{series.map(s => (
|
||||
<span className="mon-legend-item" key={s.id}>
|
||||
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />{s.name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="mon-chart-toggle" aria-pressed={table} onClick={() => setTable(t => !t)}>
|
||||
{table ? 'График' : 'Таблица'}
|
||||
</button>
|
||||
</figcaption>
|
||||
|
||||
<div ref={wrap}>
|
||||
{table ? (
|
||||
<div className="mon-table-wrap">
|
||||
<table className="mon-table">
|
||||
<thead><tr><th>Время</th>{series.map(s => <th key={s.id}>{s.name}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={series.length + 1}>Нет данных</td></tr> : rows.map(t => (
|
||||
<tr key={t}>
|
||||
<td>{timeLabel(t, span)}</td>
|
||||
{series.map((s, i) => <td key={s.id}>{byTime[i].has(t) ? format(byTime[i].get(t)!) : '—'}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mon-plot">
|
||||
<svg width={w} height={H} role="img" tabIndex={0} aria-label={`${title}. Стрелки влево и вправо — значения по времени.`}
|
||||
onKeyDown={onKey} onBlur={() => setHover(null)}>
|
||||
{yTicks.map((v, i) => (
|
||||
<g key={i}>
|
||||
<line className="mon-grid" x1={M.left} x2={w - M.right} y1={y(v)} y2={y(v)} />
|
||||
<text className="mon-tick" x={M.left - 8} y={y(v) + 4} textAnchor="end">{axis(v)}</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks.map((t, i) => (
|
||||
<text key={i} className="mon-tick" x={x(t)} y={H - 6} textAnchor={i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}>
|
||||
{timeLabel(t, span)}
|
||||
</text>
|
||||
))}
|
||||
{!empty && series.map(s => (
|
||||
<path key={s.id} d={path(s.points)} fill="none" style={{ stroke: s.color }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
))}
|
||||
{/* конечная точка: r 4 + кольцо цвета поверхности, чтобы читалась поверх линий */}
|
||||
{!empty && series.map(s => s.points.length > 0 && (
|
||||
<circle key={s.id} cx={x(s.points[s.points.length - 1][0])} cy={y(s.points[s.points.length - 1][1])}
|
||||
r={4} style={{ fill: s.color }} className="mon-end" />
|
||||
))}
|
||||
{ht !== null && (
|
||||
<g>
|
||||
<line className="mon-cross" x1={x(ht)} x2={x(ht)} y1={M.top} y2={H - M.bottom} />
|
||||
{series.map((s, i) => byTime[i].has(ht) && (
|
||||
<circle key={s.id} cx={x(ht)} cy={y(byTime[i].get(ht)!)} r={4} style={{ fill: s.color }} className="mon-end" />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
{/* зона наведения шире линии: указатель ищет момент времени, а не 2-пиксельную линию */}
|
||||
<rect x={M.left} y={M.top} width={Math.max(0, w - M.left - M.right)} height={H - M.top - M.bottom}
|
||||
fill="transparent" onPointerMove={onMove} onPointerLeave={() => setHover(null)} />
|
||||
</svg>
|
||||
{empty && <p className="mon-empty">Данные появятся через минуту-две после запуска Prometheus</p>}
|
||||
{ht !== null && (
|
||||
<div className="mon-tip" style={{ left: flip ? undefined : x(ht) + 12, right: flip ? w - x(ht) + 12 : undefined, top: M.top }} role="status">
|
||||
<span className="mon-tip-time">{timeLabel(ht, span)}</span>
|
||||
{series.map((s, i) => (
|
||||
<span className="mon-tip-row" key={s.id}>
|
||||
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />
|
||||
<b>{byTime[i].has(ht) ? format(byTime[i].get(ht)!) : '—'}</b>
|
||||
<span>{s.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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 type { Priority } from '../lib/shared';
|
||||
|
||||
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
|
||||
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
|
||||
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
|
||||
// деления канала) чип не показываем: «обычный» там было бы выдумкой.
|
||||
export default function PriorityChip({ priority }: { priority?: Priority }) {
|
||||
if (!priority) return null;
|
||||
const bg = priority === 'background';
|
||||
return (
|
||||
<span
|
||||
className="tip-wrap"
|
||||
data-tip={bg
|
||||
? 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'
|
||||
: 'Обычный приоритет: канал делится поровну'}
|
||||
>
|
||||
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
|
||||
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
|
||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
|
||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
|
||||
|
||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
||||
@@ -29,7 +29,7 @@ function versionChip(n: NodeInfo, panel: string | null) {
|
||||
}
|
||||
|
||||
export default function ServerBar({
|
||||
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete,
|
||||
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete, onSettings,
|
||||
onRestartAwg, restarting, showRestart = true,
|
||||
}: {
|
||||
nodes: NodeInfo[];
|
||||
@@ -40,6 +40,7 @@ export default function ServerBar({
|
||||
onAdd: () => void;
|
||||
onJoin: (n: NodeInfo) => void;
|
||||
onDelete: (n: NodeInfo) => void;
|
||||
onSettings: (n: NodeInfo) => void;
|
||||
onRestartAwg: () => void;
|
||||
restarting: boolean;
|
||||
showRestart?: boolean;
|
||||
@@ -103,6 +104,16 @@ export default function ServerBar({
|
||||
<IcoCopy />
|
||||
</button>
|
||||
)}
|
||||
{/* Настройки сервера: у ноды, которая ещё не подключалась, настраивать нечего */}
|
||||
{!n.pending && n.online && (
|
||||
<button
|
||||
className="btn-icon btn-icon--neutral"
|
||||
aria-label={`Настройки сервера ${n.name}`}
|
||||
onClick={e => { e.stopPropagation(); onSettings(n); }}
|
||||
>
|
||||
<IcoSettings />
|
||||
</button>
|
||||
)}
|
||||
{!n.local && (
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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 { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { apiFetch, type NodeInfo, type ShapingStatus } from '../lib/shared';
|
||||
|
||||
// Настройки сервера: деление канала между устройствами (CAKE). Сами настройки живут в
|
||||
// awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
|
||||
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
|
||||
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
||||
token: string;
|
||||
node: NodeInfo;
|
||||
onClose: () => void;
|
||||
showMsg: (text: string) => void;
|
||||
}) {
|
||||
const [st, setSt] = useState<ShapingStatus | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [auto, setAuto] = useState(true);
|
||||
const [down, setDown] = useState('');
|
||||
const [up, setUp] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
|
||||
const fill = (s: ShapingStatus) => {
|
||||
setSt(s);
|
||||
setEnabled(s.settings.enabled); setAuto(s.settings.auto);
|
||||
setDown(s.settings.downMbit ? String(s.settings.downMbit) : '');
|
||||
setUp(s.settings.upMbit ? String(s.settings.upMbit) : '');
|
||||
};
|
||||
|
||||
const errText = (e: unknown, fallback: string) =>
|
||||
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiFetch('GET', '/api/shaping', token)
|
||||
.then(s => { if (alive) fill(s as ShapingStatus); })
|
||||
.catch(e => {
|
||||
if (!alive) return;
|
||||
// Старый awg-ctrl не знает этот маршрут — его надо обновить, а не «починить» здесь.
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) setUnsupported(true);
|
||||
else setError(errText(e, 'Не удалось загрузить настройки'));
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const downN = Number(down), upN = Number(up);
|
||||
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const s = await apiFetch('PUT', '/api/shaping', token, {
|
||||
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
|
||||
});
|
||||
fill(s as ShapingStatus);
|
||||
showMsg(enabled ? 'Деление канала включено' : 'Деление канала выключено');
|
||||
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="node-scrim" onClick={onClose}>
|
||||
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="node-modal-title">Деление канала: {node.name}</h3>
|
||||
|
||||
{unsupported ? (
|
||||
<p className="node-modal-hint">
|
||||
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="node-modal-hint">
|
||||
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
|
||||
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
|
||||
</p>
|
||||
|
||||
<label className="ss-setting">
|
||||
<span className="ss-setting-text">
|
||||
<span className="ss-setting-name">Честное деление между устройствами</span>
|
||||
</span>
|
||||
<input type="checkbox" checked={enabled} disabled={!st || busy} onChange={e => setEnabled(e.target.checked)} />
|
||||
</label>
|
||||
|
||||
<div className="ss-grid">
|
||||
<label className="ss-field">
|
||||
<span className="ss-setting-name">Загрузка, Мбит/с</span>
|
||||
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
|
||||
value={down} disabled={!st || busy} onChange={e => setDown(e.target.value)} />
|
||||
</label>
|
||||
<label className="ss-field">
|
||||
<span className="ss-setting-name">Отдача, Мбит/с</span>
|
||||
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
|
||||
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="node-modal-hint">
|
||||
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
|
||||
и деление перестаёт работать.
|
||||
</p>
|
||||
|
||||
<label className="ss-setting">
|
||||
<span className="ss-setting-text">
|
||||
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
|
||||
<span className="node-modal-hint">
|
||||
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
|
||||
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
|
||||
</span>
|
||||
</span>
|
||||
<input type="checkbox" checked={auto} disabled={!st || busy || !enabled} onChange={e => setAuto(e.target.checked)} />
|
||||
</label>
|
||||
|
||||
{st?.settings.enabled && (
|
||||
<div className="ss-status">
|
||||
<span className={`chip ${st.active ? (st.congested ? 'chip--warm' : 'chip--online') : 'chip--offline'}`}>
|
||||
<span className="chip-dot" />
|
||||
{!st.active ? 'не применено' : st.congested ? 'канал занят' : 'канал свободен'}
|
||||
</span>
|
||||
<span className="ss-mono">
|
||||
↓ {st.rateDown} · ↑ {st.rateUp} Мбит/с · устройств {st.devices}
|
||||
</span>
|
||||
{st.heavy.length > 0 && (
|
||||
<span className="ss-mono">в фоне: {st.heavy.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
<div className="node-modal-actions">
|
||||
{!unsupported && (
|
||||
<button className="btn btn--primary" onClick={save} disabled={!st || busy || !valid}>
|
||||
Сохранить
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn--outline" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,21 @@ export const IcoMoon = () => (
|
||||
|
||||
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
|
||||
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
|
||||
export const IcoShield = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3z"/><path d="m9 12 2 2 4-4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcoPulse = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ICONS: Record<string, () => JSX.Element> = {
|
||||
users: IcoUsers,
|
||||
key: IcoKey,
|
||||
shield: IcoShield,
|
||||
pulse: IcoPulse,
|
||||
};
|
||||
@@ -5,6 +5,9 @@
|
||||
import axios from 'axios';
|
||||
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
|
||||
export type AwgGen = '2' | '3.1';
|
||||
// Текущая приоритетность ключа/устройства: «background» — канал занят, а он давно качает больше своей доли.
|
||||
export type Priority = 'normal' | 'background';
|
||||
|
||||
export interface User {
|
||||
name: string;
|
||||
ip: string;
|
||||
@@ -15,6 +18,7 @@ export interface User {
|
||||
lastHandshake: number;
|
||||
rx?: number;
|
||||
tx?: number;
|
||||
priority?: Priority; // нет у awg-ctrl старше деления канала
|
||||
}
|
||||
export interface ServerInfo {
|
||||
name: string;
|
||||
@@ -64,6 +68,17 @@ export interface NodeInfo {
|
||||
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
||||
}
|
||||
|
||||
// Деление канала (GET/PUT /api/shaping на awg-ctrl сервера).
|
||||
export interface ShapingStatus {
|
||||
settings: { enabled: boolean; downMbit: number; upMbit: number; auto: boolean };
|
||||
active: boolean; // CAKE реально стоит на интерфейсе
|
||||
congested: boolean;
|
||||
heavy: string[]; // IP устройств, переведённых в фоновый класс
|
||||
devices: number; // активных устройств в окне
|
||||
rateDown: number; // Мбит/с за последний тик
|
||||
rateUp: number;
|
||||
}
|
||||
|
||||
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
||||
export function cmpVersion(a: string, b: string): number {
|
||||
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
||||
|
||||
@@ -90,3 +90,5 @@ LICENSE file in the root directory of this source tree.
|
||||
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
|
||||
|
||||
.apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; }
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
|
||||
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в сервер-баре
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -54,12 +55,15 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
|
||||
const def = getServerId() ?? 0;
|
||||
const servers = picked ?? [def];
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: servers.includes(def) ? def : servers[0], servers });
|
||||
if (r.error) { showMsg(r.error); return; }
|
||||
setLabel('');
|
||||
setPicked(null);
|
||||
setCreated({ label: r.label, key: r.key });
|
||||
await load();
|
||||
}, [label, token, load, showMsg]);
|
||||
}, [label, picked, token, load, showMsg]);
|
||||
|
||||
const deleteKey = useCallback(async (id: number, lbl: string) => {
|
||||
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
|
||||
@@ -70,6 +74,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
|
||||
@@ -87,6 +92,11 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="apikey-new-servers">
|
||||
<span className="apikey-tools-hint">Серверы нового ключа:</span>
|
||||
<ServerPicker nodes={nodes} selected={picked ?? [getServerId() ?? 0]}
|
||||
onChange={next => setPicked(next)} />
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"id": "apikeys",
|
||||
"label": "API-ключи",
|
||||
"icon": "key",
|
||||
"group": "main",
|
||||
"order": 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
/* Стили вкладки «Аудит». Общая дизайн-система — в App.css. */
|
||||
|
||||
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.au-run { margin-left: auto; }
|
||||
.au-hint { font-size: 13px; color: var(--neutral-text); }
|
||||
.au-notice {
|
||||
font-size: 13px; color: var(--error-text); background: var(--error-bg);
|
||||
border-radius: 16px; padding: 10px 16px; margin: 0;
|
||||
}
|
||||
.au-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
|
||||
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: 12px; }
|
||||
.au-table td, .au-table th { white-space: nowrap; }
|
||||
|
||||
/* мобильный: таблица скрыта (.table-card), то же — карточками */
|
||||
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
|
||||
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
|
||||
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.au-card .au-pub { max-width: 100%; }
|
||||
@media (max-width: 640px) { .au-cards { display: flex; } }
|
||||
|
||||
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
||||
.au-run { margin-left: 0; }
|
||||
|
||||
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
|
||||
.au-box-title { margin: 0; font-size: 14px; font-weight: 500; }
|
||||
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.au-setting-name { font-size: 14px; }
|
||||
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.au-max { width: 88px !important; }
|
||||
.au-select { width: auto !important; }
|
||||
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 13px; }
|
||||
.au-log .au-pub { max-width: 240px; }
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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 } from 'react';
|
||||
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||
import { IcoRefresh, IcoTrash } from '../../components/icons';
|
||||
import { serverName } from '../../components/ServerPicker';
|
||||
import './audit.css';
|
||||
|
||||
// Вкладка «Аудит»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
|
||||
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
|
||||
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
|
||||
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
|
||||
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
|
||||
interface AuditEntry {
|
||||
server_id: number; status: 'ok' | 'offline' | 'error'; ghosts_checked: boolean;
|
||||
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number; blocked?: number;
|
||||
}
|
||||
interface Settings { clean: boolean; every_h: number; max: number }
|
||||
interface Cleaned { at: number; server_id: number; pub_key: string; kind: 'ghost' | 'orphan'; key?: string | null; auto: boolean }
|
||||
interface AuditData { enabled: boolean; settings: Settings; report: { at: number; servers: AuditEntry[] } | null; cleaned: Cleaned[] }
|
||||
|
||||
const PERIODS = [1, 3, 6, 12, 24];
|
||||
|
||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||
|
||||
export default function AuditPage({ token, showMsg }: PageProps) {
|
||||
const [audit, setAudit] = useState<AuditData | null>(null);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [maxEdit, setMaxEdit] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAudit(await apiFetch('GET', '/ui/masterkeys/audit', token));
|
||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
} catch { showMsg('Ошибка загрузки аудита'); }
|
||||
}, [token, showMsg]);
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
const call = useCallback(async (method: string, path: string, ok: string, body?: object) => {
|
||||
setBusy(true);
|
||||
try { setAudit(await apiFetch(method, `/ui/masterkeys/audit${path}`, token, body)); showMsg(ok); }
|
||||
catch (e) { showMsg(errText(e)); }
|
||||
finally { setBusy(false); }
|
||||
}, [token, showMsg]);
|
||||
const run = () => call('POST', '', 'Аудит выполнен');
|
||||
const save = (patch: Partial<Settings>) => call('PUT', '/settings', 'Настройки сохранены', patch);
|
||||
|
||||
if (!audit) return null;
|
||||
if (!audit.enabled) return <p className="au-empty">Аудит выключен на этой панели: его проводит только core.</p>;
|
||||
|
||||
const { settings } = audit;
|
||||
const servers = audit.report?.servers ?? [];
|
||||
const dead = servers.reduce((n, e) => n + e.ghosts.length + e.orphans.length, 0);
|
||||
const bad = servers.filter(e => e.status !== 'ok');
|
||||
const confirmed = servers.reduce((n, e) => n + [...e.ghosts, ...e.orphans].filter(p => p.seen_before).length, 0);
|
||||
const blocked = servers.filter(e => e.blocked);
|
||||
const rows = servers.flatMap(e => [
|
||||
...e.ghosts.map(p => ({ e, p, why: 'Есть в ядре, у awg-ctrl записи нет' })),
|
||||
...e.orphans.map(p => ({ e, p, why: 'Панели не нужен, не снят' })),
|
||||
]);
|
||||
const state = (p: AuditPeer) => (
|
||||
<span className={`chip chip--${p.online ? 'error' : 'offline'}`}>
|
||||
<span className="chip-dot" />{p.online ? 'Онлайн' : p.lastHandshake ? `был ${timeAgo(p.lastHandshake)} назад` : 'не подключался'}
|
||||
</span>
|
||||
);
|
||||
const confirm = (p: AuditPeer) => (
|
||||
<span className="au-hint">{p.seen_before ? 'подтверждён' : 'найден впервые — снимется, если останется и в следующий раз'}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="au-head">
|
||||
<span className={`chip chip--${!audit.report ? 'offline' : dead ? 'error' : 'online'}`}>
|
||||
<span className="chip-dot" />
|
||||
{!audit.report ? 'Аудит ещё не проводился' : dead ? `Мёртвых пиров: ${dead}` : 'Мёртвых пиров нет'}
|
||||
</span>
|
||||
{audit.report && (
|
||||
<span className="au-hint">
|
||||
Проверено {timeAgo(audit.report.at)} назад · серверов: {servers.length} · раз в {settings.every_h} ч
|
||||
</span>
|
||||
)}
|
||||
<div className="au-btns">
|
||||
{confirmed > 0 && (
|
||||
<button className="btn btn--tonal" disabled={busy}
|
||||
onClick={() => window.confirm(`Снять с серверов подтверждённых пиров: ${confirmed}?`) && call('POST', '/clean', 'Очистка выполнена')}>
|
||||
<IcoTrash /> Снять подтверждённых ({confirmed})
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn--tonal" disabled={busy} onClick={run}>
|
||||
<IcoRefresh /> {busy ? 'Работаем…' : 'Проверить сейчас'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="au-box">
|
||||
<h4 className="au-box-title">Настройки</h4>
|
||||
<label className="au-setting">
|
||||
<span className="au-setting-text">
|
||||
<span className="au-setting-name">Снимать мёртвых автоматически</span>
|
||||
<span className="au-hint">Только тех, кто мёртв два прохода подряд. Ручной проход ничего не снимает.</span>
|
||||
</span>
|
||||
<input type="checkbox" checked={settings.clean} disabled={busy} onChange={e => save({ clean: e.target.checked })} />
|
||||
</label>
|
||||
<div className="au-setting">
|
||||
<span className="au-setting-text">
|
||||
<label className="au-setting-name" htmlFor="au-every">Период проверки</label>
|
||||
<span className="au-hint">Как часто core обходит серверы</span>
|
||||
</span>
|
||||
<select id="au-every" className="field au-select" value={settings.every_h} disabled={busy}
|
||||
onChange={e => save({ every_h: Number(e.target.value) })}>
|
||||
{[...new Set([...PERIODS, settings.every_h])].sort((a, b) => a - b).map(h => <option key={h} value={h}>{h} ч</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="au-setting">
|
||||
<span className="au-setting-text">
|
||||
<label className="au-setting-name" htmlFor="au-max">Предел за проход</label>
|
||||
<span className="au-hint">Если на сервере мёртвых больше — автоочистка ничего не снимает и только сообщает (защита от сбоя)</span>
|
||||
</span>
|
||||
<span className="au-setting-ctl">
|
||||
<input id="au-max" className="field au-max" type="number" min={1} max={1000}
|
||||
value={maxEdit || String(settings.max)} onChange={e => setMaxEdit(e.target.value)} />
|
||||
<button className="btn btn--tonal" disabled={busy || !maxEdit || Number(maxEdit) === settings.max}
|
||||
onClick={() => save({ max: Number(maxEdit) }).then(() => setMaxEdit(''))}>Сохранить</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{bad.length > 0 && (
|
||||
<p className="au-notice">
|
||||
Не проверены: {bad.map(e => `${serverName(nodes, e.server_id)} (${e.status === 'offline' ? 'не в сети' : 'ошибка'})`).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{blocked.length > 0 && (
|
||||
<p className="au-notice">
|
||||
Автоочистка пропустила: {blocked.map(e => `${serverName(nodes, e.server_id)} (${e.blocked} пиров при пределе ${settings.max})`).join(', ')}. Проверьте список и снимите вручную.
|
||||
</p>
|
||||
)}
|
||||
{servers.some(e => e.status === 'ok' && !e.ghosts_checked) && (
|
||||
<p className="au-notice">Часть серверов на старой версии awg-ctrl: пиров, которые есть только в ядре, там не видно.</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
<div className="table-card au-table-card">
|
||||
<table>
|
||||
<thead><tr><th>Сервер</th><th>Публичный ключ</th><th>Причина</th><th>Мастер-ключ</th><th>Состояние</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(({ e, p, why }) => (
|
||||
<tr key={`${e.server_id}:${p.pub_key}`}>
|
||||
<td>{serverName(nodes, e.server_id)}</td>
|
||||
<td className="td-mono"><code className="au-pub" title={p.pub_key}>{p.pub_key}</code></td>
|
||||
<td>{why}</td>
|
||||
<td>{p.key ?? '—'}</td>
|
||||
<td>{state(p)}<br />{confirm(p)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul className="au-cards">
|
||||
{rows.map(({ e, p, why }) => (
|
||||
<li className="au-card" key={`${e.server_id}:${p.pub_key}`}>
|
||||
<div className="au-card-top"><b>{serverName(nodes, e.server_id)}</b>{state(p)}</div>
|
||||
<code className="au-pub" title={p.pub_key}>{p.pub_key}</code>
|
||||
<span className="au-hint">{why}{p.key ? ` · ${p.key}` : ''}</span>
|
||||
{confirm(p)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{audit.cleaned.length > 0 && (
|
||||
<section className="au-box">
|
||||
<h4 className="au-box-title">Снято</h4>
|
||||
<ul className="au-log">
|
||||
{audit.cleaned.slice(0, 20).map(c => (
|
||||
<li key={`${c.at}:${c.pub_key}`}>
|
||||
<span className="au-hint">{timeAgo(c.at)} назад</span>
|
||||
<span>{serverName(nodes, c.server_id)}</span>
|
||||
<code className="au-pub" title={c.pub_key}>{c.pub_key}</code>
|
||||
<span className="au-hint">{c.kind === 'ghost' ? 'был только в ядре' : 'лишний у awg-ctrl'}{c.key ? ` · ${c.key}` : ''} · {c.auto ? 'автоматически' : 'вручную'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{audit.report && rows.length === 0 && <p className="au-empty">Всё чисто: лишних пиров на серверах нет.</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "audit",
|
||||
"label": "Аудит",
|
||||
"icon": "shield",
|
||||
"group": "server",
|
||||
"order": 1.8,
|
||||
"chrome": {
|
||||
"serverBar": false,
|
||||
"restart": false
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
||||
// index.tsx — компонент + логика
|
||||
// <name>.css — стили вкладки (импортит сам компонент)
|
||||
// metadata.json — { id, label, icon, order?, chrome? } (манифест: описание + порядок)
|
||||
// metadata.json — { id, label, icon, group?, order?, chrome? } (манифест: описание, группа меню, порядок)
|
||||
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
|
||||
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
|
||||
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
|
||||
@@ -21,12 +21,20 @@ export interface TabChrome {
|
||||
restart?: boolean;
|
||||
}
|
||||
|
||||
// Группы меню: порядок здесь = порядок заголовков в сайдбаре. Новая группа — строка сюда.
|
||||
export const TAB_GROUPS = [
|
||||
{ id: 'main', label: 'Основное' },
|
||||
{ id: 'server', label: 'О сервере' },
|
||||
] as const;
|
||||
export type TabGroupId = (typeof TAB_GROUPS)[number]['id'];
|
||||
|
||||
// Сериализуемая часть из metadata.json.
|
||||
export interface TabMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string; // имя иконки → ICONS
|
||||
order?: number; // порядок в меню (по возрастанию; без него — 0)
|
||||
group: TabGroupId; // группа меню; без поля — 'main'
|
||||
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
|
||||
chrome?: TabChrome;
|
||||
}
|
||||
|
||||
@@ -58,10 +66,15 @@ function toMeta(name: string, raw: unknown): TabMeta {
|
||||
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
|
||||
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
|
||||
}
|
||||
const group = (m.group ?? 'main') as string;
|
||||
if (!TAB_GROUPS.some(g => g.id === group)) {
|
||||
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
|
||||
}
|
||||
return {
|
||||
id: str('id'),
|
||||
label: str('label'),
|
||||
icon: str('icon'),
|
||||
group: group as TabGroupId,
|
||||
order: m.order as number | undefined,
|
||||
chrome: m.chrome as TabChrome | undefined,
|
||||
};
|
||||
@@ -80,7 +93,15 @@ export const TABS: TabDef[] = Object.entries(metas)
|
||||
}
|
||||
return { ...meta, Icon: ICONS[meta.icon], Page: page };
|
||||
})
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
// сначала по группе (как в TAB_GROUPS), внутри группы — по order
|
||||
.sort((a, b) =>
|
||||
TAB_GROUPS.findIndex(g => g.id === a.group) - TAB_GROUPS.findIndex(g => g.id === b.group)
|
||||
|| (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
// Вкладки по группам для меню; пустые группы не показываем.
|
||||
export const TABS_BY_GROUP = TAB_GROUPS
|
||||
.map(g => ({ ...g, tabs: TABS.filter(t => t.group === g.id) }))
|
||||
.filter(g => g.tabs.length > 0);
|
||||
|
||||
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
|
||||
const ids = TABS.map(t => t.id);
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||
import PriorityChip from '../../components/PriorityChip';
|
||||
import './masterkeys.css';
|
||||
|
||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||
@@ -15,7 +16,7 @@ import './masterkeys.css';
|
||||
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||
interface Device {
|
||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
|
||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority;
|
||||
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
||||
}
|
||||
|
||||
@@ -283,6 +284,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
||||
</span>
|
||||
<PriorityChip priority={d.priority} />
|
||||
<div className="mk-device-traffic">
|
||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
|
||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"id": "masterkeys",
|
||||
"label": "Мастер-ключи",
|
||||
"icon": "key",
|
||||
"group": "main",
|
||||
"order": 1.5
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// 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;
|
||||
// сама панель пакетов не ставит). Графики читают Prometheus через фиксированные запросы панели
|
||||
// (awg-ui/monitor.ts).
|
||||
interface Status {
|
||||
ready: boolean; can_install: boolean; command: string;
|
||||
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;
|
||||
}[];
|
||||
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 часа']];
|
||||
|
||||
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 (
|
||||
<span className="mon-bar" role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100}
|
||||
aria-valuenow={v == null ? undefined : Math.round(pct)}>
|
||||
<i className={v == null ? '' : level(pct)} style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Прогресс операции из строк «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 (
|
||||
<div className="mon-progress" aria-live="polite">
|
||||
<span className={`mon-pbar${failed ? ' mon-pbar--bad' : done ? ' mon-pbar--ok' : !step && (starting || job?.running) ? ' mon-pbar--wait' : ''}`}
|
||||
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
|
||||
<i style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
<span className="mon-progress-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
|
||||
|
||||
export default function MonitorPage({ token, showMsg }: PageProps) {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [range, setRange] = useState('1h');
|
||||
const [ov, setOv] = useState<Overview | null>(null);
|
||||
const [ovErr, setOvErr] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена из этой вкладки
|
||||
const [server, setServer] = useState<NodeInfo | null>(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', 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? Он будет остановлен, а накопленные графики (до 15 дней) сотрутся. Панель продолжит отдавать /metrics.')) return;
|
||||
setPending(action);
|
||||
try { await apiFetch('POST', `/ui/monitor/${action}`, 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 ? <JobProgress job={mine ? j! : null} starting={starting} /> : null;
|
||||
};
|
||||
|
||||
// Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt
|
||||
const failureLog = () => {
|
||||
const j = status?.job;
|
||||
if (!j || j.exit === null || j.exit === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<pre className="mon-log">{j.log.trim()}</pre>
|
||||
<p className="login-error">
|
||||
{j.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${j.action}`}</b>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ───────── установка ─────────
|
||||
const setup = status && !status.ready && (
|
||||
<section className="mon-card" aria-labelledby="mon-setup">
|
||||
<div className="mon-head">
|
||||
<h3 className="mon-title" id="mon-setup">Мониторинг</h3>
|
||||
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span>
|
||||
</div>
|
||||
<p className="mon-hint">
|
||||
Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики —
|
||||
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
|
||||
только 127.0.0.1, наружу он не открывается.
|
||||
</p>
|
||||
|
||||
{status.can_install ? (
|
||||
<div className="mon-actions">
|
||||
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
|
||||
{pending === 'install' || (status.job?.running && status.job.action === 'install') ? 'Устанавливаем…' : 'Подключить Prometheus'}
|
||||
</button>
|
||||
{progressFor('any')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mon-hint">Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):</p>
|
||||
<code className="mon-code">{status.command}</code>
|
||||
<div className="mon-actions">
|
||||
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
|
||||
<IcoCopy /> Скопировать
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{failureLog()}
|
||||
</section>
|
||||
);
|
||||
|
||||
// Удаление: только пока Prometheus есть
|
||||
const manage = status?.ready && (
|
||||
<section className="mon-card" aria-labelledby="mon-manage">
|
||||
<div className="mon-head">
|
||||
<h3 className="mon-title" id="mon-manage">Prometheus</h3>
|
||||
<span className="chip chip--online"><span className="chip-dot" />работает</span>
|
||||
</div>
|
||||
<p className="mon-hint">
|
||||
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 15 дней. Удаление остановит его, снимет
|
||||
пакет и сотрёт накопленные данные.
|
||||
</p>
|
||||
{status.can_install ? (
|
||||
<div className="mon-actions">
|
||||
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
|
||||
<IcoTrash /> {pending === 'remove' || (status.job?.running && status.job.action === 'remove') ? 'Удаляем…' : 'Удалить Prometheus'}
|
||||
</button>
|
||||
{progressFor('remove')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mon-hint">Выполните на сервере от root:</p>
|
||||
<code className="mon-code">awg-ctrl monitor remove</code>
|
||||
</>
|
||||
)}
|
||||
{status.job?.action === 'remove' && failureLog()}
|
||||
</section>
|
||||
);
|
||||
|
||||
// ───────── дашборд ─────────
|
||||
// Вкладка показывает один сервер — выбранный в сервер-баре; при смене сервера App монтирует её заново.
|
||||
const one = ov?.servers[0];
|
||||
const data = ov?.total ?? null;
|
||||
const cur = data && {
|
||||
down: lastOf(data.down), up: lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy),
|
||||
busy: !!one?.congested,
|
||||
};
|
||||
// Одна единица на всю ось (Мбит/с, а не «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 && (
|
||||
<>
|
||||
{/* Период — единственный фильтр: он задаёт окно для всего ниже */}
|
||||
<div className="mon-filters" role="group" aria-label="Период">
|
||||
{RANGES.map(([id, label]) => (
|
||||
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
|
||||
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ovErr && <p className="login-error">{ovErr}</p>}
|
||||
{ov && !one?.ok && (
|
||||
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
|
||||
)}
|
||||
|
||||
<div className="mon-tiles">
|
||||
<div className="mon-tile">
|
||||
<span className="mon-tile-label">Загрузка сейчас</span>
|
||||
<span className="mon-tile-value">{cur?.down != null ? rate(cur.down) : '—'}</span>
|
||||
</div>
|
||||
<div className="mon-tile">
|
||||
<span className="mon-tile-label">Отдача сейчас</span>
|
||||
<span className="mon-tile-value">{cur?.up != null ? rate(cur.up) : '—'}</span>
|
||||
</div>
|
||||
<div className="mon-tile">
|
||||
<span className="mon-tile-label">Устройств онлайн</span>
|
||||
<span className="mon-tile-value">{cur?.online != null ? nf(cur.online, 0) : '—'}</span>
|
||||
</div>
|
||||
<div className="mon-tile">
|
||||
<span className="mon-tile-label">В фоне</span>
|
||||
<span className="mon-tile-value">{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}</span>
|
||||
<span className="mon-tile-sub">{cur?.busy ? 'канал занят' : 'канал свободен'}</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="Загрузка процессора" />
|
||||
<span className="mon-tile-sub">{one?.cores ? `${nf(one.cores, 0)} ядер` : ''}</span>
|
||||
</div>
|
||||
<div className="mon-tile">
|
||||
<span className="mon-tile-label">Память</span>
|
||||
<span className="mon-tile-value">{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}</span>
|
||||
<Bar v={one?.mem} label="Занято оперативной памяти" />
|
||||
<span className="mon-tile-sub">{one?.mem_total ? `из ${gb(one.mem_total)}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ov && data && (
|
||||
<div className="mon-charts">
|
||||
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||
format={v => 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 },
|
||||
]} />
|
||||
<LineChart title="Процессор и память, %" from={ov.from} to={ov.to} step={ov.step} dim={loading} max={100}
|
||||
format={v => `${nf(v, 0)} %`}
|
||||
series={[
|
||||
{ id: 'cpu', name: 'процессор', color: 'var(--series-1)', points: data.cpu },
|
||||
{ id: 'mem', name: 'память', color: 'var(--series-2)', points: data.mem },
|
||||
]} />
|
||||
<LineChart title="Устройства" from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||
format={v => nf(v, 0)}
|
||||
series={[
|
||||
{ id: 'online', name: 'онлайн', color: 'var(--series-1)', points: data.online },
|
||||
{ id: 'heavy', name: 'в фоне', color: 'var(--series-2)', points: data.heavy },
|
||||
]} />
|
||||
<LineChart title="Отброшено очередью, пакетов/с" from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||
format={v => nf(v, 1)}
|
||||
series={[{ id: 'drops', name: 'отброшено', color: 'var(--series-1)', points: data.drops }]} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{server && (
|
||||
<p className="mon-server">
|
||||
<span className="mon-server-label">Сервер</span>
|
||||
<b>{server.health?.server || server.name}</b>
|
||||
<span className={`chip chip--${server.online ? 'online' : 'offline'}`}><span className="chip-dot" />{server.online ? 'на связи' : 'нет связи'}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!status && <p className="mon-hint">Загрузка…</p>}
|
||||
{setup}
|
||||
{dashboard}
|
||||
{manage}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "monitor",
|
||||
"label": "Монитор",
|
||||
"icon": "pulse",
|
||||
"group": "server",
|
||||
"order": 1.7,
|
||||
"chrome": {
|
||||
"serverBar": false,
|
||||
"restart": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
/* Стили вкладки «Монитор». Общая дизайн-система — в App.css. */
|
||||
.mon-card {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.mon-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
.mon-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||
.mon-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.mon-code {
|
||||
display: block; word-break: break-all; user-select: all;
|
||||
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
|
||||
background: var(--surface-container);
|
||||
border-radius: var(--r-table);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mon-actions .btn { flex: 1; }
|
||||
}
|
||||
|
||||
/* ───── Графики ─────
|
||||
Цвета рядов — токены темы. Проверено validate_palette: светлая #6a45b8 / #c4620a на #fdfaf6,
|
||||
тёмная #8f6be0 / #d9772a на #2b251d (CVD ΔE ≥ 19, контраст ≥ 3:1). Подписи и значения цветом ряда не красим. */
|
||||
:root { --series-1: #6a45b8; --series-2: #c4620a; }
|
||||
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
|
||||
|
||||
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
||||
.mon-range {
|
||||
min-height: 44px; padding: 0 18px;
|
||||
font: inherit; font-size: 14px; color: var(--on-surface);
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline); border-radius: var(--r-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mon-range:hover { background: var(--state-primary); }
|
||||
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
|
||||
|
||||
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.mon-tile {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant); border-radius: var(--r-table);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.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-sub { font-size: 12px; color: var(--neutral-text); }
|
||||
|
||||
.mon-charts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
@media (min-width: 1000px) {
|
||||
.mon-charts { grid-template-columns: 1fr 1fr; }
|
||||
.mon-charts .mon-chart:first-child { grid-column: 1 / -1; }
|
||||
}
|
||||
.mon-chart {
|
||||
margin: 0; min-width: 0;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant); border-radius: var(--r-card);
|
||||
padding: 16px;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.mon-chart--dim { opacity: .6; }
|
||||
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
|
||||
.mon-chart-title { font-size: 15px; font-weight: 500; color: var(--on-surface); }
|
||||
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--neutral-text); }
|
||||
.mon-legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: 2px; flex: none; }
|
||||
.mon-chart-toggle {
|
||||
margin-left: auto; min-height: 44px; padding: 0 12px;
|
||||
font: inherit; font-size: 13px; color: var(--on-primary-container);
|
||||
background: none; border: 0; border-radius: var(--r-pill); cursor: pointer;
|
||||
}
|
||||
.mon-chart-toggle:hover { background: var(--state-primary); }
|
||||
|
||||
.mon-plot { position: relative; }
|
||||
.mon-plot svg { display: block; max-width: 100%; }
|
||||
.mon-plot svg:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 8px; }
|
||||
.mon-grid { stroke: var(--outline-variant); stroke-width: 1; }
|
||||
.mon-tick { fill: var(--neutral-text); font-size: 12px; font-family: var(--font); }
|
||||
.mon-cross { stroke: var(--outline); stroke-width: 1; }
|
||||
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
|
||||
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: 13px; color: var(--neutral-text); }
|
||||
.mon-tip {
|
||||
position: absolute; pointer-events: none; z-index: 2;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
background: var(--surface-container);
|
||||
border: 1px solid var(--outline-variant); border-radius: 12px;
|
||||
padding: 8px 10px; font-size: 12px; color: var(--neutral-text);
|
||||
}
|
||||
.mon-tip-time { color: var(--neutral-text); }
|
||||
.mon-tip-row { display: flex; align-items: center; gap: 8px; }
|
||||
.mon-tip-row b { font-weight: 500; font-size: 13px; color: var(--on-surface); }
|
||||
|
||||
.mon-table-wrap { overflow-x: auto; }
|
||||
.mon-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.mon-table th { text-align: left; font-weight: 500; color: var(--label); padding: 8px 10px; white-space: nowrap; }
|
||||
.mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
.mon-log {
|
||||
margin: 0; max-height: 240px; overflow: auto;
|
||||
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
|
||||
background: var(--surface-container); border-radius: var(--r-table);
|
||||
padding: 12px; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
|
||||
.mon-notice { margin-bottom: 12px; }
|
||||
|
||||
/* Полоски процессора и памяти в плитках */
|
||||
.mon-bar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); margin: 2px 0; }
|
||||
.mon-bar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .3s; }
|
||||
.mon-bar i.mon-bar--warn { background: var(--warn-dot); }
|
||||
.mon-bar i.mon-bar--bad { background: var(--error-dot); }
|
||||
|
||||
/* Какой сервер показан (выбирается в других вкладках) */
|
||||
.mon-server { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 12px; font-size: 16px; color: var(--on-surface); }
|
||||
.mon-server b { font-weight: 500; }
|
||||
.mon-server-label { font-size: 12px; color: var(--neutral-text); }
|
||||
|
||||
|
||||
/* Прогресс операции рядом с кнопкой */
|
||||
.mon-actions { align-items: center; }
|
||||
.mon-progress { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.mon-progress-label { font-size: 12px; color: var(--neutral-text); }
|
||||
.mon-pbar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); }
|
||||
.mon-pbar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .4s ease; }
|
||||
.mon-pbar--ok i { background: var(--success-dot); }
|
||||
.mon-pbar--bad i { background: var(--error-dot); }
|
||||
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
|
||||
.mon-pbar--wait i { width: 35% !important; animation: mon-wait 1.2s ease-in-out infinite; }
|
||||
@keyframes mon-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .mon-pbar--wait i { animation: none; } .mon-pbar i { transition: none; } }
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type User, type PageProps, type AwgGen,
|
||||
} from '../../lib/shared';
|
||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||
import PriorityChip from '../../components/PriorityChip';
|
||||
import './users.css';
|
||||
|
||||
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
|
||||
@@ -28,7 +29,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
const data = await apiFetch('GET', '/api/users/stats', tok);
|
||||
setUsers(prev => prev.map(u => {
|
||||
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
|
||||
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx } : u;
|
||||
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority } : u;
|
||||
}));
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
@@ -162,6 +163,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Статус</th>
|
||||
<th>Приоритет</th>
|
||||
<th>Имя</th>
|
||||
<th>IP</th>
|
||||
<th>↓ rx</th>
|
||||
@@ -171,7 +173,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr>
|
||||
<tr><td colSpan={8} className="empty">Нет пользователей</td></tr>
|
||||
) : users.map((u, i) => (
|
||||
<tr key={u.name}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
@@ -185,6 +187,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
: 'никогда'}
|
||||
</span>
|
||||
</td>
|
||||
<td><PriorityChip priority={u.priority} /></td>
|
||||
<td>
|
||||
{u.name}
|
||||
{u.key_gen !== serverGen && (
|
||||
@@ -251,6 +254,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
<div className="user-card-ip">
|
||||
<IcoGlobe />
|
||||
<span className="user-card-ip-text">{u.ip}</span>
|
||||
<PriorityChip priority={u.priority} />
|
||||
</div>
|
||||
<div className="user-card-metrics">
|
||||
<div className="metric-block">
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"id": "users",
|
||||
"label": "Пользователи",
|
||||
"icon": "users",
|
||||
"group": "main",
|
||||
"order": 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user