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
This commit is contained in:
Shay 2026-06-14 17:16:43 -07:00
parent 9b3c291d22
commit 0bdc2eef6d
5 changed files with 120 additions and 5 deletions

View file

@ -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<T>(
}
}
export async function fetchHealth(): Promise<HealthStatus> {
// Cheap liveness probe — keep the timeout short so an unresponsive server
// surfaces as "Unhealthy" quickly rather than blocking the footer poll.
return apiFetch<HealthStatus>("/health", { timeoutMs: 3000 });
}
export async function fetchEvalLanes(): Promise<EvalLaneSummary[]> {
const envelope = await apiFetch<{ lanes: EvalLaneSummary[] }>("/evals");
return envelope.lanes;

View file

@ -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<HealthStatus, WorkbenchApiError>({
queryKey: ["api", "health"],
queryFn: fetchHealth,
refetchInterval: 15_000,
retry: false,
});
}
export function useArtifacts() {
return useQuery<ArtifactRef[], WorkbenchApiError>({
queryKey: ["api", "artifacts"],

View file

@ -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<typeof import("../api/queries")>();
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<ReturnType<typeof useHealth>> & { data?: HealthStatus },
) {
vi.mocked(useHealth).mockReturnValue({
data: undefined,
isError: false,
isLoading: false,
...value,
} as unknown as ReturnType<typeof useHealth>);
}
function renderFooter() {
return render(
<QueryClientProvider client={createTestQueryClient()}>
@ -36,6 +47,7 @@ beforeEach(() => {
data: status,
isError: false,
} as unknown as ReturnType<typeof useRuntimeStatus>);
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<typeof useRuntimeStatus>);
mockHealth({ data: { status: "ok" } });
renderFooter();
expect(screen.getByText("Status unavailable")).toBeInTheDocument();
expect(screen.getByTestId("health-indicator")).toHaveAttribute(
"data-health",
"healthy",
);
});
});

View file

@ -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 (
<span
data-testid="health-indicator"
data-health={pending ? "checking" : healthy ? "healthy" : "unhealthy"}
className="flex items-center gap-1.5 text-[var(--color-text-secondary)]"
title="Server liveness probe (GET /health)"
aria-label={`Server health: ${label}`}
>
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: dotColor }}
/>
{label}
</span>
);
}
export function StatusFooter() {
const { data, isError } = useRuntimeStatus();
const chatTurnsPending = useIsMutating({ mutationKey: ["chat-turn"] }) > 0;
@ -13,8 +43,9 @@ export function StatusFooter() {
return (
<footer
data-region="statusfooter"
className="flex items-center 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"
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"
>
<HealthIndicator />
<span className="text-[var(--color-state-danger-text)]">Status unavailable</span>
</footer>
);
@ -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"
>
<HealthIndicator />
{mutationModeEl}
<button

View file

@ -50,6 +50,10 @@ export type AuditSource =
| "reboot_telemetry"
| "teaching_proposal_log";
export interface HealthStatus {
status: string;
}
export interface RuntimeStatus {
backend: Backend;
git_revision: string;