From 05adce6e7b67da7d35a467afd8c1e8a1356fb58a Mon Sep 17 00:00:00 2001 From: byrsapty Date: Mon, 24 Aug 2026 17:33:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=BE=D1=80=D1=96=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA:=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=84=D1=96=D0=BA=D0=B8=20=D0=B7=D0=B0=D0=BC=D1=96=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20psql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Свій SVG-графік замість recharts: із сотні кілобайт бібліотеки потрібна одна ламана, а SVG ще й масштабується без переобчислення на ресайз. Ряди групуються за одиницею виміру — відсотки й біти на секунду на спільній осі дають пряму лінію біля нуля. Пропуск у даних розриває лінію, а не з'єднує сусідів прямою. Перевірено наживо: 6 годин читаються з сирих даних (крок 72 с), тиждень — з роллапу 5m (крок 2016 с), підписи портів і одиниці на місці. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 25 +++ server/API.md | 30 ++++ web/src/App.tsx | 2 + web/src/api/client.ts | 15 ++ web/src/components/AppShell.tsx | 1 + web/src/components/MetricChart.tsx | 185 +++++++++++++++++++++++ web/src/pages/CredentialsPage.tsx | 2 +- web/src/pages/MetricsPage.tsx | 235 +++++++++++++++++++++++++++++ web/src/types.ts | 35 +++++ 9 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 web/src/components/MetricChart.tsx create mode 100644 web/src/pages/MetricsPage.tsx diff --git a/HISTORY.md b/HISTORY.md index 88e3316..f155b9b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1497,6 +1497,31 @@ Mikrotik, де індекс фіксований. Таблиці `ts.*` не під RLS (несумісно зі стисненням), і без цієї перевірки чужий ідентифікатор віддав би чужі дані. +### Графік без бібліотеки + +`recharts` коштує понад сотню кілобайт стисненого коду, а потрібна з них +одна ламана. SVG до того ж масштабується під будь-який контейнер без +переобчислення на ресайз. + +**Ряди групуються за одиницею виміру.** Відсотки й біти на секунду на +спільній осі перетворюють графік на пряму лінію біля нуля. + +**Пропуск розриває лінію.** Кожен відрізок — окремий `M…L…`, і між ними +лишається порожнеча — саме те, що сталося насправді. + +**Метрика, яка не буває від'ємною, не отримує від'ємну вісь.** Відступ +знизу корисний для читабельності, але «−3 % втрат» читається як помилка +даних. + +### Перевірено наживо + +``` +6 год крок 72 с, джерело сирі дані +тиждень крок 2016 с, джерело роллап 5m +snmp-host if.in_bps · eth0 = 18.5k bps, sys.processes = 41, + sys.uptime = 87.5M ticks — усе з підписом порту й одиницею +``` + ### Знайдено живим прогоном: зонд працює рівно годину Метрики SNMP замовкли о 12:05 — рівно через годину після того, як зонд diff --git a/server/API.md b/server/API.md index 59ffcae..ac2cae7 100644 --- a/server/API.md +++ b/server/API.md @@ -602,6 +602,36 @@ Sec-WebSocket-Protocol: netpulse.token.<токен> задачі й падав на кожній із «немає SNMP-креденшелів» — пачка доступів видається на `Hello`, а тоді цього хоста в ній ще не було. +### Історія метрик + +| Метод | Шлях | Призначення | +|-------|------|-------------| +| `GET` | `/api/v1/devices/{id}/series` | які метрики є в хоста + останнє значення | +| `GET` | `/api/v1/devices/{id}/metrics` | точки для графіка | + +Параметри запиту точок: `series=1,2,3` (обов'язково, до 20), +`range=6h` або пара `from`/`to` в RFC3339, `points` (типово 300, +стеля 2000). + +**Крок рахується з бажаної кількості точок**, а не приходить ззовні: +інакше вузьке вікно з дрібним кроком повернуло б десятки тисяч точок, з +яких екран покаже сотні. + +**Джерело обирається за кроком** і повідомляється в полі `source`: +крок до 5 хвилин — сирі `ts.samples`, до години — роллап +`ts.samples_5m`, далі — `ts.samples_1h`. Читати сирі точки за місяць — +це мільйони рядків заради трьохсот пікселів; брати годинні бакети на +вікні в п'ять хвилин — це графік з однієї точки. + +Значення точки може бути `null`. Пропуск і нуль — різні речі: лінія, +проведена через діру в даних, каже «все було добре», хоча насправді +нічого не відомо. + +`series_id` приходить від клієнта, тож належність хосту перевіряється +явним запитом до `ts.series` під RLS. Самі `ts.*` під RLS не стоять +(несумісно зі стисненням), і без цієї перевірки чужий ідентифікатор +віддав би чужі дані. + ### Доступи до обладнання SNMP-community, паролі SSH і Telnet живуть в `inv.credentials`, diff --git a/web/src/App.tsx b/web/src/App.tsx index 850b76a..b098200 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { DevicesPage } from './pages/DevicesPage' import { GroupsPage } from './pages/GroupsPage' import { TemplatesPage } from './pages/TemplatesPage' import { CredentialsPage } from './pages/CredentialsPage' +import { MetricsPage } from './pages/MetricsPage' import { ConfigsPage } from './pages/ConfigsPage' import { AlertsPage } from './pages/AlertsPage' import { RulesPage } from './pages/RulesPage' @@ -54,6 +55,7 @@ export default function App() { } />} /> } />} /> } />} /> + } />} /> } />} /> } />} /> } />} /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6d696dd..5b1e578 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -27,6 +27,8 @@ import type { UserGroup, UndoResult, BackupDefaults, + MetricResult, + SeriesInfo, Template, } from '../types' @@ -542,4 +544,17 @@ export const api = { body: JSON.stringify({ ...(doc as object), update_existing: updateExisting }), }, ), + + // --- історія метрик --- + + listSeries: (deviceID: string) => + request<{ series: SeriesInfo[] }>(`/api/v1/devices/${deviceID}/series`).then( + (r) => r.series ?? [], + ), + + queryMetrics: (deviceID: string, seriesIDs: number[], range: string, points = 300) => + request( + `/api/v1/devices/${deviceID}/metrics?series=${seriesIDs.join(',')}` + + `&range=${range}&points=${points}`, + ), } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index e04aa9b..3d90db6 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -19,6 +19,7 @@ const navItems: NavItem[] = [ { to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' }, { to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' }, { to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' }, + { to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' }, { to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' }, { to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' }, { to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' }, diff --git a/web/src/components/MetricChart.tsx b/web/src/components/MetricChart.tsx new file mode 100644 index 0000000..ed52f33 --- /dev/null +++ b/web/src/components/MetricChart.tsx @@ -0,0 +1,185 @@ +import { useMemo } from 'react' +import type { MetricPoint, MetricSeries } from '../types' + +// Палітра ліній. Кольори підібрані так, щоб розрізнятися й у відтінках +// сірого: половина мережевих інженерів дивиться на це через VNC. +const COLORS = ['#38bdf8', '#a78bfa', '#34d399', '#fbbf24', '#f87171', '#f472b6'] + +const W = 800 +const H = 220 +const PAD = { top: 8, right: 8, bottom: 20, left: 48 } + +/** + * Лінійний графік без бібліотеки. + * + * Своя реалізація, бо recharts коштує понад сотню кілобайт стисненого + * коду, а потрібна з них одна ламана. SVG до того ж масштабується під + * будь-який контейнер без переобчислення на ресайз. + * + * Пропуск у даних розриває лінію, а не з'єднує сусідів прямою. Пряма + * через діру каже «все було добре» там, де насправді нічого не відомо, — + * у моніторингу це найгірший різновид брехні. + */ +export function MetricChart({ series }: { series: MetricSeries[] }) { + const model = useMemo(() => build(series), [series]) + + if (!model) { + return ( +
+ За цей період даних немає +
+ ) + } + + const { minX, maxX, minY, maxY, lines, ticks } = model + const x = (t: number) => + PAD.left + ((t - minX) / Math.max(1, maxX - minX)) * (W - PAD.left - PAD.right) + const y = (v: number) => + H - PAD.bottom - ((v - minY) / Math.max(1e-9, maxY - minY)) * (H - PAD.top - PAD.bottom) + + return ( + + {ticks.map((t) => ( + + + + {t.label} + + + ))} + + {lines.map((ln, i) => ( + + seg + .map((p, n) => `${n === 0 ? 'M' : 'L'} ${x(p.t).toFixed(1)} ${y(p.v).toFixed(1)}`) + .join(' '), + ) + .join(' ')} + fill="none" + stroke={COLORS[i % COLORS.length]} + strokeWidth={1.5} + vectorEffect="non-scaling-stroke" + /> + ))} + + ) +} + +/** Легенда окремо від полотна: підписи не мають розтягуватись із SVG. */ +export function ChartLegend({ series }: { series: MetricSeries[] }) { + return ( +
+ {series.map((s, i) => ( + + + {seriesLabel(s)} + {s.unit && {s.unit}} + + ))} +
+ ) +} + +export function seriesLabel(s: { metric_key: string; interface_name?: string }): string { + return s.interface_name ? `${s.metric_key} · ${s.interface_name}` : s.metric_key +} + +type Line = { id: number; segments: { t: number; v: number }[][] } + +function build(series: MetricSeries[]) { + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + const lines: Line[] = [] + + for (const s of series) { + const segments: { t: number; v: number }[][] = [] + let cur: { t: number; v: number }[] = [] + for (const p of s.points) { + if (p.v == null) { + // Пропуск закриває поточний відрізок: наступна точка почне новий, + // і між ними лишиться порожнеча — саме те, що сталося насправді. + if (cur.length > 0) segments.push(cur) + cur = [] + continue + } + const t = Date.parse(p.ts) + cur.push({ t, v: p.v }) + if (t < minX) minX = t + if (t > maxX) maxX = t + if (p.v < minY) minY = p.v + if (p.v > maxY) maxY = p.v + } + if (cur.length > 0) segments.push(cur) + if (segments.length > 0) lines.push({ id: s.id, segments }) + } + + if (lines.length === 0) return null + + // Рівна лінія все одно має бути видимою, а не притиснутою до краю. + if (maxY - minY < 1e-9) { + const pad = Math.abs(maxY) * 0.1 || 1 + minY -= pad + maxY += pad + } else { + const pad = (maxY - minY) * 0.08 + minY -= pad + maxY += pad + } + // Метрики, які не бувають від'ємними, не мають отримувати від'ємну + // вісь через відступ: «−3 % втрат» читається як помилка даних. + if (minY < 0 && series.every((s) => s.points.every((p) => p.v == null || p.v >= 0))) { + minY = 0 + } + + return { minX, maxX, minY, maxY, lines, ticks: makeTicks(minY, maxY) } +} + +function makeTicks(min: number, max: number) { + const out: { v: number; label: string }[] = [] + for (let i = 0; i <= 4; i++) { + const v = min + ((max - min) * i) / 4 + out.push({ v, label: fmtValue(v) }) + } + return out +} + +/** + * Коротке число для осі. + * + * Біти на секунду й байти пам'яті на одній осі з відсотками не + * поміщаються без скорочення, а «14437155.5» на графіку не читає ніхто. + */ +export function fmtValue(v: number): string { + const a = Math.abs(v) + if (a >= 1e9) return `${(v / 1e9).toFixed(1)}G` + if (a >= 1e6) return `${(v / 1e6).toFixed(1)}M` + if (a >= 1e3) return `${(v / 1e3).toFixed(1)}k` + if (a >= 10) return v.toFixed(0) + if (a >= 1) return v.toFixed(1) + if (a === 0) return '0' + return v.toFixed(3) +} + +export type { MetricPoint } diff --git a/web/src/pages/CredentialsPage.tsx b/web/src/pages/CredentialsPage.tsx index 536ecb6..3f517ab 100644 --- a/web/src/pages/CredentialsPage.tsx +++ b/web/src/pages/CredentialsPage.tsx @@ -115,7 +115,7 @@ export function CredentialsPage() { ), detail: (usage[c.id] ?? 0) > 0 - ? `Його використовує ${plural(usage[c.id], 'хост', 'хости', 'хостів')} — вони перестануть опитуватись усім, крім пінга.` + ? `Його використовує ${plural(usage[c.id], 'хост', 'хости', 'хостів')}: опитування цим доступом припиниться, лишиться тільки пінг.` : undefined, onConfirm: async () => { await api.deleteCredential(c.id) diff --git a/web/src/pages/MetricsPage.tsx b/web/src/pages/MetricsPage.tsx new file mode 100644 index 0000000..573af82 --- /dev/null +++ b/web/src/pages/MetricsPage.tsx @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { api } from '../api/client' +import { + Button, + Card, + ErrorNote, + PageHeader, + Spinner, + ago, + inputClass, +} from '../components/ui' +import { ChartLegend, MetricChart, fmtValue, seriesLabel } from '../components/MetricChart' +import type { DeviceSummary, MetricResult, SeriesInfo } from '../types' + +// Вікна перегляду. Ширші за добу вже читаються з роллапів, тому +// коштують стільки ж, скільки вузькі. +const RANGES: { key: string; label: string }[] = [ + { key: '30m', label: '30 хв' }, + { key: '6h', label: '6 год' }, + { key: '24h', label: 'доба' }, + { key: '7d', label: 'тиждень' }, + { key: '30d', label: 'місяць' }, +] + +// Скільки ліній малювати одразу. Більше однаково не читається, а сервер +// відмовляє після двадцяти. +const MAX_LINES = 6 + +/** + * Історія метрик. + * + * Сторінка існує тому, що без неї збір даних безглуздий: агент писав у + * `ts.samples`, а подивитись на зібране можна було лише через psql. + */ +export function MetricsPage() { + const [devices, setDevices] = useState([]) + const [deviceID, setDeviceID] = useState('') + const [series, setSeries] = useState([]) + const [selected, setSelected] = useState([]) + const [range, setRange] = useState('6h') + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + useEffect(() => { + api + .listDevices() + .then((d) => { + setDevices(d) + if (d.length > 0) setDeviceID(d[0].id) + }) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + .finally(() => setLoading(false)) + }, []) + + // Перелік рядів хоста. Вибір скидається разом із хостом: id рядів + // наскрізні, і лишити їх означало б попросити чужі дані. + useEffect(() => { + if (!deviceID) return + let alive = true + setSeries([]) + setSelected([]) + setData(null) + api + .listSeries(deviceID) + .then((s) => { + if (!alive) return + setSeries(s) + // Одразу показуємо перші кілька: порожній графік із проханням + // щось відмітити — це зайвий крок перед відповіддю на питання + // «що тут відбувається». + setSelected(s.slice(0, 3).map((x) => x.id)) + }) + .catch((e) => alive && setErr(e instanceof Error ? e.message : String(e))) + return () => { + alive = false + } + }, [deviceID]) + + const load = useCallback(() => { + if (!deviceID || selected.length === 0) { + setData(null) + return + } + setBusy(true) + api + .queryMetrics(deviceID, selected, range) + .then(setData) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + .finally(() => setBusy(false)) + }, [deviceID, selected, range]) + + useEffect(load, [load]) + + // Групуємо за одиницею виміру: відсотки й біти на секунду на спільній + // осі перетворюють графік на пряму лінію біля нуля. + const groups = useMemo(() => { + const by = new Map['series']>() + for (const s of data?.series ?? []) { + const k = s.unit || '—' + const list = by.get(k) ?? [] + list.push(s) + by.set(k, list) + } + return [...by.entries()] + }, [data]) + + if (loading) return + + const toggle = (id: number) => + setSelected((prev) => + prev.includes(id) + ? prev.filter((x) => x !== id) + : prev.length >= MAX_LINES + ? prev + : [...prev, id], + ) + + return ( +
+ + +
+ {RANGES.map((r) => ( + + ))} +
+ + } + /> + + {err} + + {devices.length === 0 ? ( +

Хостів ще немає

+ ) : series.length === 0 ? ( +

+ Для цього хоста ще нічого не зібрано. Метрики з’являться після першого успішного + опитування — перевірте, що хост увімкнено й до нього прив’язано зонд. +

+ ) : ( +
+ +
+ Вибрано {selected.length} з {MAX_LINES} можливих +
+
    + {series.map((s) => ( +
  • + +
  • + ))} +
+
+ +
+ {data && ( +

+ Крок {data.step_sec} с, джерело{' '} + {data.source === 'raw' ? 'сирі дані' : `роллап ${data.source}`} + {busy && ' · оновлюємо…'} +

+ )} + + {groups.length === 0 ? ( + +

+ {selected.length === 0 + ? 'Відмітьте метрику зліва' + : 'За цей період даних немає'} +

+
+ ) : ( + groups.map(([unit, list]) => ( + +
+ + {unit === '—' ? 'без одиниці' : unit} + + +
+ +
+ )) + )} +
+
+ )} +
+ ) +} diff --git a/web/src/types.ts b/web/src/types.ts index f7df871..f19ec4c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -504,3 +504,38 @@ export type BackupDefaults = { following_count: number custom_count: number } + +// --- історія метрик --- + +export type SeriesInfo = { + id: number + metric_key: string + unit?: string + interface_id?: string + interface_name?: string + labels?: Record + last_value?: number + last_at?: string +} + +/** Значення може бути null: пропуск і нуль — різні речі. */ +export type MetricPoint = { ts: string; v: number | null; min?: number; max?: number } + +export type MetricSeries = { + id: number + metric_key: string + unit?: string + interface_name?: string + labels?: Record + points: MetricPoint[] +} + +export type MetricResult = { + from: string + to: string + step_sec: number + /** raw | 5m | 1h — з якого джерела читали. */ + source: string + series: MetricSeries[] +} +