diff --git a/web/src/hooks/useMonitor.ts b/web/src/hooks/useMonitor.ts new file mode 100644 index 0000000..06efef4 --- /dev/null +++ b/web/src/hooks/useMonitor.ts @@ -0,0 +1,180 @@ +import { useEffect, useRef, useState } from "react"; +import type { + Bucket, + CheckEvent, + HistoryResponse, + Incident, + LiveTarget, + StateEvent, + Target, +} from "../lib/types"; + +const SERIES_MAX = 90; + +export interface MonitorState { + targets: LiveTarget[]; + incidents: Incident[]; + /** Per-target hourly buckets over the last 24h, for the day dial. */ + dayBuckets: Record; + connected: boolean; + /** True once the first snapshot has landed — gates the boot sequence. */ + ready: boolean; +} + +function mergeSnapshot(prev: LiveTarget[], snapshot: Target[]): LiveTarget[] { + const byId = new Map(prev.map((t) => [t.id, t])); + return snapshot.map((t) => { + const existing = byId.get(t.id); + return { + ...t, + pulse: existing?.pulse ?? 0, + stateChangedAt: existing?.stateChangedAt ?? 0, + series: existing?.series ?? [], + }; + }); +} + +export function useMonitor(): MonitorState { + const [targets, setTargets] = useState([]); + const [incidents, setIncidents] = useState([]); + const [dayBuckets, setDayBuckets] = useState>({}); + const [connected, setConnected] = useState(false); + const [ready, setReady] = useState(false); + const seededRef = useRef(false); + + // History seeding: sparkline series + day-dial buckets. + const loadHistory = async (ids: string[]) => { + const results = await Promise.allSettled( + ids.map(async (id) => { + try { + const res = await fetch( + `/api/targets/${encodeURIComponent(id)}/history?range=24h`, + ); + if (!res.ok) return null; + const hist: HistoryResponse = await res.json(); + return hist; + } catch { + /* transient — the stream keeps the UI alive regardless */ + return null; + } + }), + ); + + const histories = results + .map((result) => (result.status === "fulfilled" ? result.value : null)) + .filter((hist): hist is HistoryResponse => hist !== null); + + if (histories.length === 0) return; + + const bucketsById: Record = {}; + const seriesById = new Map(); + for (const hist of histories) { + bucketsById[hist.target_id] = hist.buckets; + seriesById.set(hist.target_id, hist.points.slice(-SERIES_MAX)); + } + + setDayBuckets((prev) => ({ ...prev, ...bucketsById })); + setTargets((prev) => + prev.map((t) => + t.series.length === 0 && seriesById.has(t.id) + ? { ...t, series: seriesById.get(t.id)! } + : t, + ), + ); + }; + + const loadIncidents = async () => { + try { + const res = await fetch("/api/incidents?range=7d"); + if (res.ok) setIncidents(await res.json()); + } catch { + /* ignore */ + } + }; + + useEffect(() => { + const es = new EventSource("/api/stream"); + + es.addEventListener("open", () => setConnected(true)); + es.addEventListener("error", () => setConnected(false)); + + es.addEventListener("snapshot", (e) => { + const snapshot: Target[] = JSON.parse((e as MessageEvent).data); + setTargets((prev) => mergeSnapshot(prev, snapshot)); + setConnected(true); + setReady(true); + if (!seededRef.current) { + seededRef.current = true; + void loadHistory(snapshot.map((t) => t.id)); + void loadIncidents(); + } + }); + + es.addEventListener("check", (e) => { + const ev: CheckEvent = JSON.parse((e as MessageEvent).data); + setTargets((prev) => + prev.map((t) => { + if (t.id !== ev.target_id) return t; + const point = { + ts: ev.ts, + ok: ev.ok, + latency_ms: ev.latency_ms, + detail: ev.detail, + error: ev.error, + }; + return { + ...t, + state: ev.state, + fails: ev.ok ? 0 : t.fails + 1, + last: point, + pulse: t.pulse + 1, + series: [...t.series.slice(-(SERIES_MAX - 1)), point], + cert_days: + t.type === "tls" && ev.detail + ? parseInt(ev.detail, 10) + : t.cert_days, + }; + }), + ); + }); + + es.addEventListener("state", (e) => { + const ev: StateEvent = JSON.parse((e as MessageEvent).data); + setTargets((prev) => + prev.map((t) => + t.id === ev.target_id + ? { + ...t, + state: ev.state, + stateChangedAt: ev.ts, + incident: ev.state === "down" ? ev.incident : undefined, + } + : t, + ), + ); + void loadIncidents(); + }); + + // Refresh aggregates (uptime %, day dial) on a slow cadence; the + // stream covers everything fast-moving. + const refresh = setInterval(async () => { + try { + const res = await fetch("/api/targets"); + if (res.ok) { + const snapshot: Target[] = await res.json(); + setTargets((prev) => mergeSnapshot(prev, snapshot)); + void loadHistory(snapshot.map((t) => t.id)); + } + } catch { + /* ignore */ + } + }, 120_000); + + return () => { + es.close(); + clearInterval(refresh); + }; + }, []); + + return { targets, incidents, dayBuckets, connected, ready }; +}