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, MathProposalSummary,
MathProposalDetail, MathProposalDetail,
MathRatifyResult, MathRatifyResult,
HealthStatus,
} from "../types/api"; } from "../types/api";
export class WorkbenchApiError extends Error { 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[]> { export async function fetchEvalLanes(): Promise<EvalLaneSummary[]> {
const envelope = await apiFetch<{ lanes: EvalLaneSummary[] }>("/evals"); const envelope = await apiFetch<{ lanes: EvalLaneSummary[] }>("/evals");
return envelope.lanes; return envelope.lanes;

View file

@ -6,6 +6,7 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
apiFetch, apiFetch,
fetchHealth,
fetchDemos, fetchDemos,
fetchEvalLanes, fetchEvalLanes,
runEvalLane, runEvalLane,
@ -46,6 +47,7 @@ import {
} from "./client"; } from "./client";
import type { WorkbenchApiError } from "./client"; import type { WorkbenchApiError } from "./client";
import type { import type {
HealthStatus,
RuntimeStatus, RuntimeStatus,
DemoRunResult, DemoRunResult,
DemoSummary, 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() { export function useArtifacts() {
return useQuery<ArtifactRef[], WorkbenchApiError>({ return useQuery<ArtifactRef[], WorkbenchApiError>({
queryKey: ["api", "artifacts"], queryKey: ["api", "artifacts"],

View file

@ -3,14 +3,14 @@ import userEvent from "@testing-library/user-event";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestQueryClient } from "../test/createTestQueryClient"; import { createTestQueryClient } from "../test/createTestQueryClient";
import type { RuntimeStatus } from "../types/api"; import type { HealthStatus, RuntimeStatus } from "../types/api";
vi.mock("../api/queries", async (importOriginal) => { vi.mock("../api/queries", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/queries")>(); 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"; import { StatusFooter } from "./StatusFooter";
const status: RuntimeStatus = { const status: RuntimeStatus = {
@ -23,6 +23,17 @@ const status: RuntimeStatus = {
mutation_mode: "read_only", 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() { function renderFooter() {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
@ -36,6 +47,7 @@ beforeEach(() => {
data: status, data: status,
isError: false, isError: false,
} as unknown as ReturnType<typeof useRuntimeStatus>); } as unknown as ReturnType<typeof useRuntimeStatus>);
mockHealth({ data: { status: "ok" } });
}); });
afterEach(() => { afterEach(() => {
@ -76,4 +88,50 @@ describe("StatusFooter", () => {
expect(checkpoint.getAttribute("title")).toMatch(/checkpoint revision/i); expect(checkpoint.getAttribute("title")).toMatch(/checkpoint revision/i);
expect(checkpoint.getAttribute("title")).toMatch(/unknown/); 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 { useState } from "react";
import { useIsMutating } from "@tanstack/react-query"; import { useIsMutating } from "@tanstack/react-query";
import { useRuntimeStatus } from "../api/queries"; import { useHealth, useRuntimeStatus } from "../api/queries";
import { useCopyToClipboard } from "../design/hooks/useCopyToClipboard"; 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() { export function StatusFooter() {
const { data, isError } = useRuntimeStatus(); const { data, isError } = useRuntimeStatus();
const chatTurnsPending = useIsMutating({ mutationKey: ["chat-turn"] }) > 0; const chatTurnsPending = useIsMutating({ mutationKey: ["chat-turn"] }) > 0;
@ -13,8 +43,9 @@ export function StatusFooter() {
return ( return (
<footer <footer
data-region="statusfooter" 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> <span className="text-[var(--color-state-danger-text)]">Status unavailable</span>
</footer> </footer>
); );
@ -52,6 +83,8 @@ export function StatusFooter() {
data-region="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" 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} {mutationModeEl}
<button <button

View file

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