import { useI18n } from "@/i18n/I18nProvider";
import { type Heartbeat, listHeartbeats } from "@/lib/queries";
import { useEffect, useState } from "react";
const REFRESH_MS = 10_000;
// Each daemon thread has its own heartbeat cadence; a single global
// "30s = fresh" threshold mis-classifies the slow ones. Fresh = within
// 3× the expected cadence, stale = within 9×, dead = beyond. Unknown
// thread names fall back to a 30s baseline (the old default).
const EXPECTED_CADENCE_MS: Record<string, number> = {
main: 10_000,
advice: 10_000,
jobs: 5_000,
verify: 20_000,
consolidate: 30_000,
audit: 30_000,
};
const DEFAULT_CADENCE_MS = 30_000;
function thresholds(thread: string): { fresh: number; stale: number } {
const cadence = EXPECTED_CADENCE_MS[thread] ?? DEFAULT_CADENCE_MS;
return { fresh: cadence * 3, stale: cadence * 9 };
}
const HEALTH_RANK: Record<Health, number> = {
fresh: 0,
stale: 1,
dead: 2,
missing: 3,
};
type Health = "fresh" | "stale" | "dead" | "missing";
function ageMs(iso: string): number {
return Date.now() - new Date(iso).getTime();
}
function classify(beats: Heartbeat[]): {
health: Health;
perThread: { thread: string; client_id: string; ageMs: number; health: Health }[];
} {
if (beats.length === 0) {
return { health: "missing", perThread: [] };
}
const perThread = beats.map((b) => {
const a = ageMs(b.last_beat_at);
const { fresh, stale } = thresholds(b.thread);
let h: Health;
if (a < fresh) h = "fresh";
else if (a < stale) h = "stale";
else h = "dead";
return { thread: b.thread, client_id: b.client_id, ageMs: a, health: h };
});
// Overall = worst per-thread classification (not worst absolute age).
// A slow-cadence thread at the edge of its own healthy window
// shouldn't drag the summary down if it's still "fresh" by its own
// scale.
const overall = perThread.reduce<Health>(
(worst, t) => (HEALTH_RANK[t.health] > HEALTH_RANK[worst] ? t.health : worst),
"fresh",
);
return { health: overall, perThread };
}
const COLORS: Record<Health, { dot: string; text: string }> = {
fresh: { dot: "bg-green-500", text: "text-green-600" },
stale: { dot: "bg-yellow-500", text: "text-yellow-600" },
dead: { dot: "bg-red-500", text: "text-red-600" },
missing: { dot: "bg-muted-foreground", text: "text-muted-foreground" },
};
export default function DaemonStatus() {
const { t } = useI18n();
const [beats, setBeats] = useState<Heartbeat[] | null>(null);
const [showDetail, setShowDetail] = useState(false);
const labels: Record<Health, string> = {
fresh: t("daemon.running"),
stale: t("daemon.slow"),
dead: t("daemon.stopped"),
missing: t("daemon.never"),
};
useEffect(() => {
let alive = true;
function tick() {
listHeartbeats()
.then((rows) => {
if (alive) setBeats(rows);
})
.catch(() => {
if (alive) setBeats([]);
});
}
tick();
const t = setInterval(tick, REFRESH_MS);
return () => {
alive = false;
clearInterval(t);
};
}, []);
if (beats === null) {
return <div className="text-xs text-muted-foreground">{t("daemon.checking")}</div>;
}
const { health, perThread } = classify(beats);
const c = COLORS[health];
return (
<div className="text-xs">
<button
type="button"
onClick={() => setShowDetail((v) => !v)}
className={`w-full flex items-center gap-2 ${c.text} hover:opacity-80`}
title={t("daemon.detailHint")}
>
<span
className={`inline-block w-2 h-2 rounded-full ${c.dot} ${health === "fresh" ? "animate-pulse" : ""}`}
/>
<span className="truncate">{labels[health]}</span>
</button>
{showDetail && perThread.length > 0 && (
<div className="mt-1 space-y-0.5 pl-4">
{perThread.map((t) => (
<div
key={`${t.client_id}/${t.thread}`}
className={`flex items-center justify-between ${COLORS[t.health].text}`}
>
<span className="font-mono">{t.thread}</span>
<span>{Math.round(t.ageMs / 1000)}s</span>
</div>
))}
</div>
)}
</div>
);
}