Система вміла малювати мапу, але мовчала, коли щось падало. Схема 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>
361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|