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:
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user