Netpulse_SasS/web/src/pages/MetricsPage.tsx
byrsapty 387980d655 Картка хоста на вкладках, графіки в шаблонах, пошукові списки
Форма хоста стала вкладками: Хост, Шаблони, Доступи, Збір конфігів,
Ручні перевірки. Інтервалів опитування тут більше немає — вони живуть
у шаблонах, і два джерела правди про одне число розійшлися б саме тоді,
коли треба швидко зрозуміти, чому хост опитується не так, як написано.
Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек.

Групи, шаблони й доступи обираються пошуком із чипами: список галочок
працює на пʼяти позиціях і перестає працювати на пʼятдесяти.

Графіки описуються в шаблоні (tpl.graphs): шість видів, жорсткі межі
осей, перелік ключів метрик замість посилань на елементи — графік має
право показувати й те, що прийшло з іншого шаблону. Графік без жодного
знайденого ряду не показується: порожня рамка з підписом — це обіцянка
даних, яких немає.

Знайдено живим прогоном і виправлено: обробник виділення на полотні
створював новий масив щорендеру й зациклював оновлення. Цикл зʼїдав
головний потік, і мертвими ставали всі сторінки, не лише мапа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:11:24 +03:00

378 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from '../api/client'
import {
Button,
Card,
ErrorNote,
PageBody,
PageHeader,
Spinner,
ago,
inputClass,
} from '../components/ui'
import { ChartLegend, MetricChart, fmtValue, seriesLabel } from '../components/MetricChart'
import type { DeviceGraph, 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 [graphs, setGraphs] = useState<DeviceGraph[]>([])
// Готові графіки з шаблонів чи власний добір метрик.
//
// Шаблонні першими: у дев'яти випадках із десяти людина приходить
// подивитись «як там пам'ять», а не збирати графік з нуля.
const [mode, setMode] = useState<'templates' | 'custom'>('templates')
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
.deviceGraphs(deviceID)
.then((g) => {
if (!alive) return
setGraphs(g)
// Без шаблонних графіків показувати порожню вкладку немає сенсу.
setMode(g.length > 0 ? 'templates' : 'custom')
})
.catch(() => alive && setGraphs([]))
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 (
<>
<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>
{graphs.length > 0 && (
<div className="flex gap-1">
<Button
kind={mode === 'templates' ? 'primary' : 'default'}
onClick={() => setMode('templates')}
>
З шаблонів
</Button>
<Button
kind={mode === 'custom' ? 'primary' : 'default'}
onClick={() => setMode('custom')}
>
Свій добір
</Button>
</div>
)}
<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>
</>
}
/>
<PageBody className="space-y-4">
<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>
) : mode === 'templates' ? (
<div className="grid gap-4 md:grid-cols-2">
{graphs.map((g) => (
<TemplateGraphCard key={`${g.template_name}-${g.key}`} graph={g} deviceID={deviceID} range={range} />
))}
</div>
) : (
<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>
)}
</PageBody>
</>
)
}
/**
* Один графік, описаний у шаблоні.
*
* Вантажить себе сам: графіків на хості буває десяток, і вимикати
* половину з них заради одного спільного запиту означало б чекати на
* найповільніший.
*/
function TemplateGraphCard({
graph,
deviceID,
range,
}: {
graph: DeviceGraph
deviceID: string
range: string
}) {
const [data, setData] = useState<MetricResult | null>(null)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
let alive = true
api
.queryMetrics(deviceID, graph.series_ids, range, 250)
.then((r) => alive && setData(r))
.catch((e) => alive && setErr(e instanceof Error ? e.message : String(e)))
return () => {
alive = false
}
}, [deviceID, range, graph.series_ids])
const single = graph.kind === 'stat' || graph.kind === 'gauge'
const last = data?.series[0]?.points.filter((p) => p.v != null).slice(-1)[0]?.v ?? null
return (
<Card className="p-3">
<div className="mb-2 flex items-baseline justify-between gap-2">
<span className="truncate text-xs font-medium text-slate-300">{graph.name}</span>
<span className="shrink-0 text-[10px] text-slate-600">{graph.template_name}</span>
</div>
{err ? (
<p className="py-6 text-center text-[11px] text-slate-600">{err}</p>
) : !data ? (
<p className="py-6 text-center text-[11px] text-slate-600">Завантаження</p>
) : single ? (
<SingleValue graph={graph} value={last} />
) : (
<>
<MetricChart
series={data.series}
kind={graph.kind}
min={graph.options?.min}
max={graph.options?.max}
/>
<div className="mt-1">
<ChartLegend series={data.series} />
</div>
</>
)}
</Card>
)
}
function SingleValue({ graph, value }: { graph: DeviceGraph; value: number | null }) {
if (value == null) {
return <p className="py-6 text-center text-[11px] text-slate-600">Даних немає</p>
}
if (graph.kind === 'stat') {
return (
<div className="flex flex-col items-center justify-center py-4">
<span className="text-3xl font-semibold tabular-nums text-slate-100">
{fmtValue(value)}
</span>
<span className="mt-1 text-[11px] text-slate-500">{graph.options?.unit}</span>
</div>
)
}
const min = graph.options?.min ?? 0
const max = graph.options?.max ?? 100
const pct = Math.max(0, Math.min(1, (value - min) / Math.max(1e-9, max - min)))
// Ті самі пороги, що на шкалах дашборда: до 70 % спокійно, до 90 %
// варто глянути, далі погано.
const color = pct >= 0.9 ? '#ef4444' : pct >= 0.7 ? '#f59e0b' : '#34d399'
return (
<div className="space-y-2 py-2">
<div className="flex items-baseline justify-between">
<span className="text-2xl font-semibold tabular-nums text-slate-100">
{fmtValue(value)}
</span>
<span className="text-[11px] text-slate-500">{graph.options?.unit}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-800">
<div
className="h-full rounded-full transition-all"
style={{ width: `${pct * 100}%`, background: color }}
/>
</div>
</div>
)
}