Форма хоста стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки. Інтервалів опитування тут більше немає — вони живуть у шаблонах, і два джерела правди про одне число розійшлися б саме тоді, коли треба швидко зрозуміти, чому хост опитується не так, як написано. Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек. Групи, шаблони й доступи обираються пошуком із чипами: список галочок працює на пʼяти позиціях і перестає працювати на пʼятдесяти. Графіки описуються в шаблоні (tpl.graphs): шість видів, жорсткі межі осей, перелік ключів метрик замість посилань на елементи — графік має право показувати й те, що прийшло з іншого шаблону. Графік без жодного знайденого ряду не показується: порожня рамка з підписом — це обіцянка даних, яких немає. Знайдено живим прогоном і виправлено: обробник виділення на полотні створював новий масив щорендеру й зациклював оновлення. Цикл зʼїдав головний потік, і мертвими ставали всі сторінки, не лише мапа. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|