From 0bdc2eef6d8794eaed379230f225592928cb2f83 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 14 Jun 2026 17:16:43 -0700 Subject: [PATCH] feat(workbench): wire /health liveness probe into the status footer The /health endpoint was the only W-026 API route with no UI consumer. Surface it as a liveness indicator (colored dot + label) in the status footer, isolated from /runtime/status so a live server still reads "Healthy" even when runtime status is degraded. - types: HealthStatus matching the server's {"status":"ok"} payload - client: fetchHealth() with a short 3s timeout for fast failure - queries: useHealth() polling every 15s, retry disabled - footer: HealthIndicator rendered in both normal and degraded branches; any non-ok status fails safe to "Unhealthy" - tests: healthy / errored / non-ok / checking / survives-runtime-failure --- workbench-ui/src/api/client.ts | 7 +++ workbench-ui/src/api/queries.ts | 13 +++++ workbench-ui/src/app/StatusFooter.test.tsx | 64 +++++++++++++++++++++- workbench-ui/src/app/StatusFooter.tsx | 37 ++++++++++++- workbench-ui/src/types/api.ts | 4 ++ 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index 78489df8..9c593704 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -37,6 +37,7 @@ import type { MathProposalSummary, MathProposalDetail, MathRatifyResult, + HealthStatus, } from "../types/api"; export class WorkbenchApiError extends Error { @@ -71,6 +72,12 @@ export async function apiFetch( } } +export async function fetchHealth(): Promise { + // Cheap liveness probe — keep the timeout short so an unresponsive server + // surfaces as "Unhealthy" quickly rather than blocking the footer poll. + return apiFetch("/health", { timeoutMs: 3000 }); +} + export async function fetchEvalLanes(): Promise { const envelope = await apiFetch<{ lanes: EvalLaneSummary[] }>("/evals"); return envelope.lanes; diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index fc57fa97..ba1ca652 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -6,6 +6,7 @@ import { } from "@tanstack/react-query"; import { apiFetch, + fetchHealth, fetchDemos, fetchEvalLanes, runEvalLane, @@ -46,6 +47,7 @@ import { } from "./client"; import type { WorkbenchApiError } from "./client"; import type { + HealthStatus, RuntimeStatus, DemoRunResult, DemoSummary, @@ -104,6 +106,17 @@ export function useRuntimeStatus() { }); } +export function useHealth() { + // Liveness probe — polled faster than runtime status and isolated from it so + // a healthy server still reads green even if /runtime/status is degraded. + return useQuery({ + queryKey: ["api", "health"], + queryFn: fetchHealth, + refetchInterval: 15_000, + retry: false, + }); +} + export function useArtifacts() { return useQuery({ queryKey: ["api", "artifacts"], diff --git a/workbench-ui/src/app/StatusFooter.test.tsx b/workbench-ui/src/app/StatusFooter.test.tsx index a76e3f0c..3d089555 100644 --- a/workbench-ui/src/app/StatusFooter.test.tsx +++ b/workbench-ui/src/app/StatusFooter.test.tsx @@ -3,14 +3,14 @@ import userEvent from "@testing-library/user-event"; import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestQueryClient } from "../test/createTestQueryClient"; -import type { RuntimeStatus } from "../types/api"; +import type { HealthStatus, RuntimeStatus } from "../types/api"; vi.mock("../api/queries", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useRuntimeStatus: vi.fn() }; + return { ...actual, useRuntimeStatus: vi.fn(), useHealth: vi.fn() }; }); -import { useRuntimeStatus } from "../api/queries"; +import { useHealth, useRuntimeStatus } from "../api/queries"; import { StatusFooter } from "./StatusFooter"; const status: RuntimeStatus = { @@ -23,6 +23,17 @@ const status: RuntimeStatus = { mutation_mode: "read_only", }; +function mockHealth( + value: Partial> & { data?: HealthStatus }, +) { + vi.mocked(useHealth).mockReturnValue({ + data: undefined, + isError: false, + isLoading: false, + ...value, + } as unknown as ReturnType); +} + function renderFooter() { return render( @@ -36,6 +47,7 @@ beforeEach(() => { data: status, isError: false, } as unknown as ReturnType); + mockHealth({ data: { status: "ok" } }); }); afterEach(() => { @@ -76,4 +88,50 @@ describe("StatusFooter", () => { expect(checkpoint.getAttribute("title")).toMatch(/checkpoint revision/i); expect(checkpoint.getAttribute("title")).toMatch(/unknown/); }); + + it("shows a healthy liveness indicator when GET /health reports ok", () => { + renderFooter(); + const health = screen.getByTestId("health-indicator"); + expect(health).toHaveAttribute("data-health", "healthy"); + expect(health).toHaveTextContent("Healthy"); + }); + + it("marks the server unhealthy when /health errors", () => { + mockHealth({ isError: true }); + renderFooter(); + const health = screen.getByTestId("health-indicator"); + expect(health).toHaveAttribute("data-health", "unhealthy"); + expect(health).toHaveTextContent("Unhealthy"); + }); + + it("marks the server unhealthy when /health returns a non-ok status", () => { + mockHealth({ data: { status: "degraded" } }); + renderFooter(); + expect(screen.getByTestId("health-indicator")).toHaveAttribute( + "data-health", + "unhealthy", + ); + }); + + it("shows a checking state before the first probe resolves", () => { + mockHealth({ isLoading: true }); + renderFooter(); + const health = screen.getByTestId("health-indicator"); + expect(health).toHaveAttribute("data-health", "checking"); + expect(health).toHaveTextContent("Checking"); + }); + + it("still reports health when runtime status is unavailable", () => { + vi.mocked(useRuntimeStatus).mockReturnValue({ + data: undefined, + isError: true, + } as unknown as ReturnType); + mockHealth({ data: { status: "ok" } }); + renderFooter(); + expect(screen.getByText("Status unavailable")).toBeInTheDocument(); + expect(screen.getByTestId("health-indicator")).toHaveAttribute( + "data-health", + "healthy", + ); + }); }); diff --git a/workbench-ui/src/app/StatusFooter.tsx b/workbench-ui/src/app/StatusFooter.tsx index 021341df..6512e077 100644 --- a/workbench-ui/src/app/StatusFooter.tsx +++ b/workbench-ui/src/app/StatusFooter.tsx @@ -1,8 +1,38 @@ import { useState } from "react"; import { useIsMutating } from "@tanstack/react-query"; -import { useRuntimeStatus } from "../api/queries"; +import { useHealth, useRuntimeStatus } from "../api/queries"; import { useCopyToClipboard } from "../design/hooks/useCopyToClipboard"; +function HealthIndicator() { + const { data, isError, isLoading } = useHealth(); + const healthy = !isError && data?.status === "ok"; + const pending = isLoading && !data && !isError; + + const label = pending ? "Checking" : healthy ? "Healthy" : "Unhealthy"; + const dotColor = pending + ? "var(--color-state-neutral-text)" + : healthy + ? "var(--color-state-success-text)" + : "var(--color-state-danger-text)"; + + return ( + + + ); +} + export function StatusFooter() { const { data, isError } = useRuntimeStatus(); const chatTurnsPending = useIsMutating({ mutationKey: ["chat-turn"] }) > 0; @@ -13,8 +43,9 @@ export function StatusFooter() { return (
+ Status unavailable
); @@ -52,6 +83,8 @@ export function StatusFooter() { data-region="statusfooter" className="flex items-center gap-[var(--density-shell-gap)] border-t border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] px-[var(--density-shell-padding-x)] py-[var(--density-footer-padding-y)] text-xs" > + + {mutationModeEl}