devist 0.14.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
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>
  );
}