«Просить логін після кожного оновлення» — дві поломки в одному симптомі. Перша: RotateSession читав email::text без COALESCE, а пошта стала необов'язковою разом із входом за логіном. Користувач без адреси міг увійти й не міг поновити сесію взагалі. Друга: ротація не переживала подвійного обміну. React у режимі розробки виконує ефекти двічі, дві вкладки в проді дають ту саму гонку: перший запит відкликав токен, другий приносив уже відкликаний. Тепер core.sessions.replaced_by тримає ланцюг, вікно 30 секунд — вистачає на гонку від того самого клієнта й замало для реального повтору. PageBody один на всіх сторінках: відступи й прокрутка більше не залежать від того, хто писав сторінку. Заміряно — рівно 16 px скрізь. Мапа: видалення (ендпоїнт існував із Етапу 4, кнопки не було) з підтвердженням, і вигляд вузла — форма (картка/пігулка/крапка), товщина рамки, заливка, приховування підпису й цифр. Аварія й попередження світяться ореолом: рамка іншого кольору не помітна периферійним зором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
8.6 KiB
TypeScript
238 lines
8.6 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 { 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>
|
||
</>
|
||
)
|
||
}
|