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:
@@ -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
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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); }
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user