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