Netpulse_SasS/web/src/pages/MetricsPage.tsx
byrsapty a4a44b1d76 Сесія переживає перезавантаження, однакові відступи, вигляд вузлів мапи
«Просить логін після кожного оновлення» — дві поломки в одному симптомі.

Перша: RotateSession читав email::text без COALESCE, а пошта стала
необов'язковою разом із входом за логіном. Користувач без адреси міг
увійти й не міг поновити сесію взагалі.

Друга: ротація не переживала подвійного обміну. React у режимі розробки
виконує ефекти двічі, дві вкладки в проді дають ту саму гонку: перший
запит відкликав токен, другий приносив уже відкликаний. Тепер
core.sessions.replaced_by тримає ланцюг, вікно 30 секунд — вистачає на
гонку від того самого клієнта й замало для реального повтору.

PageBody один на всіх сторінках: відступи й прокрутка більше не
залежать від того, хто писав сторінку. Заміряно — рівно 16 px скрізь.

Мапа: видалення (ендпоїнт існував із Етапу 4, кнопки не було) з
підтвердженням, і вигляд вузла — форма (картка/пігулка/крапка), товщина
рамки, заливка, приховування підпису й цифр. Аварія й попередження
світяться ореолом: рамка іншого кольору не помітна периферійним зором.

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

238 lines
8.6 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 { 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 (
<>
<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>
</>
}
/>
<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>
) : (
<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>
</>
)
}