Сторінка метрик: графіки замість psql

Свій SVG-графік замість recharts: із сотні кілобайт бібліотеки потрібна
одна ламана, а SVG ще й масштабується без переобчислення на ресайз.

Ряди групуються за одиницею виміру — відсотки й біти на секунду на
спільній осі дають пряму лінію біля нуля. Пропуск у даних розриває
лінію, а не з'єднує сусідів прямою.

Перевірено наживо: 6 годин читаються з сирих даних (крок 72 с),
тиждень — з роллапу 5m (крок 2016 с), підписи портів і одиниці на місці.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 17:33:15 +03:00
parent cd8d4c62ed
commit 05adce6e7b
9 changed files with 529 additions and 1 deletions

View file

@ -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 — рівно через годину після того, як зонд

View file

@ -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`,

View file

@ -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() {
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
<Route path="/metrics" element={<Guard perm="devices:read" element={<MetricsPage />} />} />
<Route path="/credentials" element={<Guard perm="devices:read" element={<CredentialsPage />} />} />
<Route path="/templates" element={<Guard perm="devices:read" element={<TemplatesPage />} />} />
<Route path="/configs" element={<Guard perm="ncm:read" element={<ConfigsPage />} />} />

View file

@ -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<MetricResult>(
`/api/v1/devices/${deviceID}/metrics?series=${seriesIDs.join(',')}` +
`&range=${range}&points=${points}`,
),
}

View file

@ -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' },

View file

@ -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 (
<div className="flex h-40 items-center justify-center text-xs text-slate-600">
За цей період даних немає
</div>
)
}
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 (
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" preserveAspectRatio="none" role="img">
{ticks.map((t) => (
<g key={t.v}>
<line
x1={PAD.left}
x2={W - PAD.right}
y1={y(t.v)}
y2={y(t.v)}
stroke="#1e293b"
strokeWidth={1}
/>
<text
x={PAD.left - 6}
y={y(t.v) + 3}
textAnchor="end"
fill="#64748b"
fontSize={9}
fontFamily="ui-monospace, monospace"
>
{t.label}
</text>
</g>
))}
{lines.map((ln, i) => (
<path
key={ln.id}
d={ln.segments
.map((seg) =>
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>
)
}
/** Легенда окремо від полотна: підписи не мають розтягуватись із SVG. */
export function ChartLegend({ series }: { series: MetricSeries[] }) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-1">
{series.map((s, i) => (
<span key={s.id} className="flex items-center gap-1.5 text-[11px] text-slate-400">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: COLORS[i % COLORS.length] }}
/>
{seriesLabel(s)}
{s.unit && <span className="text-slate-600">{s.unit}</span>}
</span>
))}
</div>
)
}
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 }

View file

@ -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)

View file

@ -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<DeviceSummary[]>([])
const [deviceID, setDeviceID] = useState('')
const [series, setSeries] = useState<SeriesInfo[]>([])
const [selected, setSelected] = useState<number[]>([])
const [range, setRange] = useState('6h')
const [data, setData] = useState<MetricResult | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(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<string, typeof data extends null ? never : NonNullable<typeof data>['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 <Spinner />
const toggle = (id: number) =>
setSelected((prev) =>
prev.includes(id)
? prev.filter((x) => x !== id)
: prev.length >= MAX_LINES
? prev
: [...prev, id],
)
return (
<div className="space-y-4">
<PageHeader
title="Метрики"
subtitle="Історія того, що зібрали зонди"
actions={
<>
<select
className={`${inputClass} w-44 md:w-56`}
value={deviceID}
onChange={(e) => setDeviceID(e.target.value)}
>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<div className="flex gap-1">
{RANGES.map((r) => (
<Button
key={r.key}
kind={range === r.key ? 'primary' : 'default'}
onClick={() => setRange(r.key)}
>
{r.label}
</Button>
))}
</div>
</>
}
/>
<ErrorNote>{err}</ErrorNote>
{devices.length === 0 ? (
<p className="py-8 text-center text-xs text-slate-600">Хостів ще немає</p>
) : series.length === 0 ? (
<p className="py-8 text-center text-xs leading-relaxed text-slate-600">
Для цього хоста ще нічого не зібрано. Метрики зявляться після першого успішного
опитування перевірте, що хост увімкнено й до нього привязано зонд.
</p>
) : (
<div className="grid gap-4 xl:grid-cols-[18rem_1fr]">
<Card className="max-h-[32rem] overflow-y-auto">
<div className="border-b border-slate-800 px-3 py-2 text-[11px] text-slate-500">
Вибрано {selected.length} з {MAX_LINES} можливих
</div>
<ul className="divide-y divide-slate-800/70">
{series.map((s) => (
<li key={s.id}>
<label className="flex cursor-pointer items-start gap-2 px-3 py-2 hover:bg-slate-800/40">
<input
type="checkbox"
className="mt-1"
checked={selected.includes(s.id)}
disabled={!selected.includes(s.id) && selected.length >= MAX_LINES}
onChange={() => toggle(s.id)}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-slate-200">
{seriesLabel(s)}
</span>
<span className="block text-[11px] text-slate-600">
{s.last_value != null ? (
<>
{fmtValue(s.last_value)} {s.unit}
<span className="ml-1 text-slate-700">{ago(s.last_at)}</span>
</>
) : (
'даних ще немає'
)}
</span>
</span>
</label>
</li>
))}
</ul>
</Card>
<div className="space-y-4">
{data && (
<p className="text-[11px] text-slate-600">
Крок {data.step_sec} с, джерело{' '}
{data.source === 'raw' ? 'сирі дані' : `роллап ${data.source}`}
{busy && ' · оновлюємо…'}
</p>
)}
{groups.length === 0 ? (
<Card className="p-4">
<p className="py-8 text-center text-xs text-slate-600">
{selected.length === 0
? 'Відмітьте метрику зліва'
: 'За цей період даних немає'}
</p>
</Card>
) : (
groups.map(([unit, list]) => (
<Card key={unit} className="p-3">
<div className="mb-2 flex items-baseline justify-between gap-2">
<span className="text-xs font-medium text-slate-400">
{unit === '—' ? 'без одиниці' : unit}
</span>
<ChartLegend series={list} />
</div>
<MetricChart series={list} />
</Card>
))
)}
</div>
</div>
)}
</div>
)
}

View file

@ -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<string, string>
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<string, string>
points: MetricPoint[]
}
export type MetricResult = {
from: string
to: string
step_sec: number
/** raw | 5m | 1h — з якого джерела читали. */
source: string
series: MetricSeries[]
}