import { useI18n } from "@/i18n/I18nProvider";
import { type Heartbeat, listHeartbeats } from "@/lib/queries";
import { useEffect, useState } from "react";
const REFRESH_MS = 10_000;
const FRESH_THRESHOLD_MS = 30_000; // green
const STALE_THRESHOLD_MS = 120_000; // yellow → red beyond this
type Health = "fresh" | "stale" | "dead" | "missing";
function ageMs(iso: string): number {
return Date.now() - new Date(iso).getTime();
}
function classify(beats: Heartbeat[]): {
health: Health;
worstAgeMs: number;
perThread: { thread: string; client_id: string; ageMs: number; health: Health }[];
} {
if (beats.length === 0) {
return { health: "missing", worstAgeMs: 0, perThread: [] };
}
const perThread = beats.map((b) => {
const a = ageMs(b.last_beat_at);
let h: Health;
if (a < FRESH_THRESHOLD_MS) h = "fresh";
else if (a < STALE_THRESHOLD_MS) h = "stale";
else h = "dead";
return { thread: b.thread, client_id: b.client_id, ageMs: a, health: h };
});
const worst = perThread.reduce(
(acc, t) => (t.ageMs > acc ? t.ageMs : acc),
0,
);
let health: Health;
if (worst < FRESH_THRESHOLD_MS) health = "fresh";
else if (worst < STALE_THRESHOLD_MS) health = "stale";
else health = "dead";
return { health, worstAgeMs: worst, 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>
);
}