Import source: web/src/hooks/useMonitor

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:20:13 +03:30
parent 6cedb053f8
commit 9b14f50730
+180
View File
@@ -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<string, Bucket[]>;
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<LiveTarget[]>([]);
const [incidents, setIncidents] = useState<Incident[]>([]);
const [dayBuckets, setDayBuckets] = useState<Record<string, Bucket[]>>({});
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<string, Bucket[]> = {};
const seriesById = new Map<string, HistoryResponse["points"]>();
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 };
}