Netpulse_SasS/web/src/App.tsx
byrsapty 215bf8c4db Етап 9: алерти й сповіщення
Система вміла малювати мапу, але мовчала, коли щось падало. Схема alr.*
лежала готовою з Етапу 1 і повністю порожньою.

Движок:
- обчислює правила з icmp / interface / metric / no_data і не тримає
  стану між тіками: вікно for_seconds — це запит по часу до TSDB, тож
  перезапуск нічого не збиває
- дві семантики вікна: без agg умова має триматися всі виміри (антифлап),
  з agg порівнюється агрегат
- кореляція за топологією: причина аварії — той, у кого лишився живий
  сусід; хто оточений мертвими, той наслідок. Заради цього й будувалась
  topo.links: інакше падіння маршрутизатора дає сорок сповіщень
- вікна обслуговування, ручне заглушення зі стелею в тиждень
- advisory-блокування на тік: кілька API за балансувальником безпечні

Доставка:
- Telegram, webhook, SMTP; токени в core.secrets тим самим кільцем, що
  й паролі від обладнання
- тенант без маршрутів отримує все в усі придатні канали — підключили
  Telegram, має працювати
- тиха година не глушить disaster
- вебхуки на внутрішні адреси заблоковано; для self-hosted знімається
  прапорцем процесу, бо там ця мережа своя

UI: індикатор у шапці, панель зі списком, ack/mute/close, окремий фільтр
придушених, спільна шина подій замість другого WebSocket.

Знайдено живою роботою й виправлено:
- вимкнене або видалене правило лишало алерти сиротами назавжди
- перехід у suppressed не публікував події — UI дізнавався лише після
  перезавантаження сторінки
- кнопки дій на телефоні були 26 px

20 нових тестів, go vet і tsc чисто. Живий прогін: поріг посередині
розділив два справжні пристрої, 5 доставок на 5 подій без повторів.

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

361 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 { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { api, staticTokenPresent } from './api/client'
import { session } from './api/session'
import { useLiveMap } from './hooks/useLiveMap'
import { useAlerts } from './hooks/useAlerts'
import { LoginPage } from './components/LoginPage'
import { MapCanvas } from './components/MapCanvas'
import { AlertBadge, AlertsPanel } from './components/AlertsPanel'
import type { AgentSummary, MapSummary } from './types'
export default function App() {
// Сесія живе поза React, бо її читає й шар API. useSyncExternalStore
// підписує компонент на неї без дублювання стану.
const me = useSyncExternalStore(session.subscribe, session.me)
const [booting, setBooting] = useState(true)
// Після перезавантаження сторінки access-токен зник разом із памʼяттю
// вкладки, але httpOnly-кукі жива — пробуємо тихо відновити сесію.
useEffect(() => {
if (staticTokenPresent) {
setBooting(false)
return
}
void api.restore().finally(() => setBooting(false))
}, [])
if (booting) {
return (
<div className="flex h-screen items-center justify-center bg-slate-950 text-sm text-slate-600">
Відновлення сесії
</div>
)
}
if (!me && !staticTokenPresent) {
return <LoginPage onDone={() => setBooting(false)} />
}
return <Workspace />
}
function Workspace() {
const [maps, setMaps] = useState<MapSummary[]>([])
const [mapID, setMapID] = useState<string | null>(null)
const [agents, setAgents] = useState<AgentSummary[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [listError, setListError] = useState<string | null>(null)
// Мобільний сайдбар відкривається поверх полотна: на 375 px ширини
// постійна колонка з'їдала б дві третини карти.
const [sidebarOpen, setSidebarOpen] = useState(false)
const [alertsOpen, setAlertsOpen] = useState(false)
const live = useLiveMap(mapID)
const alerts = useAlerts()
const canWrite = session.can('maps:write')
const canSeeAlerts = session.can('alerts:read')
useEffect(() => {
api
.listMaps()
.then((m) => {
setMaps(m)
setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
})
.catch((e) => setListError(e instanceof Error ? e.message : String(e)))
api.listAgents().then(setAgents).catch(() => {})
}, [])
const counts = useMemo(() => {
const nodes = live.state?.nodes ?? []
return {
total: nodes.length,
up: nodes.filter((n) => n.status === 'up').length,
down: nodes.filter((n) => n.status === 'down').length,
unknown: nodes.filter((n) => !n.status || n.status === 'unknown').length,
}
}, [live.state])
const node = live.state?.nodes.find((n) => n.id === selected) ?? null
const me = session.me()
return (
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
<header
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-slate-800
bg-slate-900 px-3 py-2 md:px-4"
>
<button
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
onClick={() => setSidebarOpen((v) => !v)}
aria-label="Меню"
>
</button>
<span className="text-base font-semibold tracking-tight text-slate-100 md:text-lg">
NetPulse
</span>
<select
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm
md:flex-none"
value={mapID ?? ''}
onChange={(e) => setMapID(e.target.value || null)}
>
{maps.length === 0 && <option value="">мап немає</option>}
{maps.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.node_count}/{m.edge_count})
</option>
))}
</select>
{canWrite && (
<>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
hover:bg-slate-700 disabled:opacity-40"
disabled={!mapID}
onClick={() => void live.build()}
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
>
<span className="hidden lg:inline">Добудувати з топології</span>
<span className="lg:hidden">Добудувати</span>
</button>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
hover:bg-slate-700 disabled:opacity-40"
disabled={!live.canUndo}
onClick={() => void live.undo()}
title="Відкотити останню зміну полотна"
>
<span className="ml-1 hidden lg:inline">Відкотити</span>
</button>
</>
)}
<div className="ml-auto flex items-center gap-3 text-xs">
<span className="hidden xl:block">
<Legend />
</span>
{canSeeAlerts && (
<AlertBadge counts={alerts.counts} onClick={() => setAlertsOpen((v) => !v)} />
)}
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
{live.state && (
<span className="hidden tabular-nums text-slate-500 sm:inline">
ревізія {live.state.revision}
</span>
)}
{me?.email && (
<button
className="max-w-[10rem] truncate rounded border border-slate-700 px-2 py-1
text-slate-400 hover:bg-slate-800"
title={`${me.email} — вийти`}
onClick={() => void api.logout()}
>
{me.email}
</button>
)}
</div>
</header>
{(live.error || listError) && (
<div className="shrink-0 border-b border-amber-900/50 bg-amber-950/60 px-4 py-1.5 text-sm text-amber-200">
{live.error ?? listError}
</div>
)}
<div className="relative flex min-h-0 flex-1">
{/* Затемнення під висувним сайдбаром — лише на вузьких екранах. */}
{sidebarOpen && (
<div
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside
className={`absolute inset-y-0 left-0 z-20 flex w-64 flex-col gap-4 overflow-y-auto
border-r border-slate-800 bg-slate-900 p-3 transition-transform
md:static md:z-0 md:w-60 md:translate-x-0 md:bg-slate-900/60
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Стан вузлів
</h2>
<div className="space-y-1 text-sm">
<Counter label="Усього" value={counts.total} />
<Counter label="Працюють" value={counts.up} color="text-emerald-400" />
<Counter label="Недоступні" value={counts.down} color="text-red-400" />
<Counter label="Невідомо" value={counts.unknown} color="text-slate-400" />
</div>
</section>
{session.can('agents:read') && (
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Зонди
</h2>
{agents.length === 0 && <p className="text-xs text-slate-600">немає</p>}
<ul className="space-y-2">
{agents.map((a) => (
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
<div className="flex items-center gap-1.5">
<span
className={`h-1.5 w-1.5 rounded-full ${
a.status === 'online' ? 'bg-emerald-400' : 'bg-slate-600'
}`}
/>
<span className="truncate font-medium text-slate-200">{a.name}</span>
</div>
<div className="mt-1 text-slate-500">
{a.os}/{a.arch} · {a.device_count} пристроїв
</div>
{typeof a.health?.rss_bytes === 'number' && (
<div className="tabular-nums text-slate-500">
RSS {((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ
</div>
)}
</li>
))}
</ul>
</section>
)}
</aside>
<main className="min-w-0 flex-1">
{live.state ? (
<MapCanvas
state={live.state}
onPatch={live.patch}
onSelect={setSelected}
readOnly={!canWrite}
/>
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
</div>
)}
</main>
{alertsOpen && canSeeAlerts && (
<AlertsPanel
live={alerts}
onClose={() => setAlertsOpen(false)}
// Клік по алерту веде до пристрою на мапі: інакше зв'язок
// між рядком у списку й вузлом доводиться шукати очима.
onFocusDevice={(deviceID) => {
const n = live.state?.nodes.find((x) => x.device_id === deviceID)
if (n) setSelected(n.id)
}}
/>
)}
</div>
{/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */}
{node && (
<div
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
>
<div className="mb-1 flex items-center justify-between">
<span className="font-medium text-slate-100">{node.label}</span>
<button className="px-2 text-slate-500 hover:text-slate-300" onClick={() => setSelected(null)}>
</button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
<Row k="статус" v={node.status || 'unknown'} />
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
</div>
</div>
)}
</div>
)
}
function Counter({ label, value, color }: { label: string; value: number; color?: string }) {
return (
<div className="flex justify-between">
<span className="text-slate-400">{label}</span>
<span className={`tabular-nums font-medium ${color ?? 'text-slate-200'}`}>{value}</span>
</div>
)
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-2 md:w-full">
<span className="text-slate-500">{k}</span>
<span className="tabular-nums text-slate-300">{v}</span>
</div>
)
}
function Legend() {
return (
<span className="flex items-center gap-2 text-slate-500">
<Dot c="bg-emerald-400" t="норма" />
<Dot c="bg-amber-400" t="навантажено" />
<Dot c="bg-red-500" t="обрив" />
<Dot c="bg-slate-500" t="невідомо" />
</span>
)
}
function Dot({ c, t }: { c: string; t: string }) {
return (
<span className="flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${c}`} />
{t}
</span>
)
}
/**
* Стан з'єднання показується завжди й помітно.
*
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
* само, як мапа, де все гаразд, — і це найнебезпечніший стан
* моніторингу.
*/
function ConnectionBadge({
state,
lastEventAt,
}: {
state: 'connecting' | 'online' | 'offline'
lastEventAt: number | null
}) {
const [, force] = useState(0)
useEffect(() => {
const t = setInterval(() => force((n) => n + 1), 1000)
return () => clearInterval(t)
}, [])
const label =
state === 'online' ? 'наживо' : state === 'connecting' ? 'підключення…' : 'немає зв\'язку'
const color =
state === 'online'
? 'bg-emerald-400'
: state === 'connecting'
? 'bg-amber-400'
: 'bg-red-500 animate-pulse'
const ago = lastEventAt ? Math.round((Date.now() - lastEventAt) / 1000) : null
return (
<span className="flex items-center gap-1.5 text-slate-400">
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
<span className="hidden sm:inline">{label}</span>
{ago !== null && state === 'online' && (
<span className="hidden tabular-nums text-slate-600 lg:inline">{ago} с тому</span>
)}
</span>
)
}