Сторінка метрик: графіки замість psql
Свій SVG-графік замість recharts: із сотні кілобайт бібліотеки потрібна одна ламана, а SVG ще й масштабується без переобчислення на ресайз. Ряди групуються за одиницею виміру — відсотки й біти на секунду на спільній осі дають пряму лінію біля нуля. Пропуск у даних розриває лінію, а не з'єднує сусідів прямою. Перевірено наживо: 6 годин читаються з сирих даних (крок 72 с), тиждень — з роллапу 5m (крок 2016 с), підписи портів і одиниці на місці. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cd8d4c62ed
commit
05adce6e7b
9 changed files with 529 additions and 1 deletions
25
HISTORY.md
25
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 — рівно через годину після того, як зонд
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 />} />} />
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
185
web/src/components/MetricChart.tsx
Normal file
185
web/src/components/MetricChart.tsx
Normal 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 }
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
235
web/src/pages/MetricsPage.tsx
Normal file
235
web/src/pages/MetricsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue