From 2ba16640c1be25fa9fa549ec8645f41db7919128 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 19 May 2026 05:21:44 +0000 Subject: [PATCH] feat(admin-ui): persistent worker health banner across admin pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkerHealthAlert lives in the admin layout and renders on every admin page when any worker needs attention. Polls the workers list every 30s in the background. Hidden on the workers page itself (which has its own richer banner with filter chips). Surfaces three categories: - errored: install_state == "error" - offline: installed + no heartbeat for 5+ minutes - in progress: pending / provisioning / uninstalling Tone goes red when there are errored or offline workers, amber otherwise. A "review →" link deep-jumps to /app/admin/workers. --- .../admin/_components/WorkerHealthAlert.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 web/src/app/app/admin/_components/WorkerHealthAlert.tsx diff --git a/web/src/app/app/admin/_components/WorkerHealthAlert.tsx b/web/src/app/app/admin/_components/WorkerHealthAlert.tsx new file mode 100644 index 00000000..6d9f5df8 --- /dev/null +++ b/web/src/app/app/admin/_components/WorkerHealthAlert.tsx @@ -0,0 +1,78 @@ +// Cross-page worker health banner. Renders at the top of every admin page +// when any worker needs attention (errored, offline, or stuck mid-install). +// Click the banner to jump into the workers list with the relevant filter +// pre-applied. + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useLocation } from "react-router-dom"; +import { listManagedWorkers } from "@/lib/api/client/app/admin/workers"; + +const OFFLINE_MS = 5 * 60_000; + +export default function WorkerHealthAlert() { + const { pathname } = useLocation(); + // Don't render on the workers list page itself — that page has its own + // richer banner with filter chips, no point doubling up. + const onWorkersPage = pathname.startsWith("/app/admin/workers"); + + const { data } = useQuery({ + queryKey: ["admin", "workers", "managed"], + queryFn: listManagedWorkers, + refetchInterval: 30_000, + // Stale time covers fast nav between admin pages without re-fetching. + staleTime: 10_000, + }); + + const summary = useMemo(() => { + let errored = 0, offline = 0, inProgress = 0; + for (const w of data?.data ?? []) { + if (w.install_state === "error") { + errored++; + continue; + } + if ( + w.install_state === "pending" || + w.install_state === "provisioning" || + w.install_state === "uninstalling" + ) { + inProgress++; + continue; + } + if (w.install_state !== "installed") continue; + const seenAge = w.last_seen_at ? Date.now() - new Date(w.last_seen_at).getTime() : Infinity; + if (seenAge > OFFLINE_MS) offline++; + } + return { errored, offline, inProgress, total: errored + offline + inProgress }; + }, [data]); + + if (onWorkersPage || summary.total === 0) return null; + + // Pick the most urgent category first. + const tone = summary.errored > 0 || summary.offline > 0 ? "red" : "amber"; + const toneCls = tone === "red" + ? "bg-red-50 border-red-300 text-red-800" + : "bg-amber-50 border-amber-300 text-amber-800"; + + const parts: string[] = []; + if (summary.errored > 0) parts.push(`${summary.errored} errored`); + if (summary.offline > 0) parts.push(`${summary.offline} offline`); + if (summary.inProgress > 0) parts.push(`${summary.inProgress} in progress`); + + return ( +
+
+ + {summary.total} worker{summary.total === 1 ? "" : "s"} need attention + + — {parts.join(" · ")} +
+ + review → + +
+ ); +}