Merge pull request #751 from AssetOverflow/feat/workbench-health-footer

feat(workbench): wire /health liveness probe into the status footer
This commit is contained in:
Shay 2026-06-14 17:23:57 -07:00 committed by GitHub
commit 0cb01ee3f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 125 additions and 6 deletions

View file

@ -375,8 +375,12 @@ Settings / Runtime
No giant sidebar. No collapsible sub-trees in v1.
StatusFooter surfaces three signals at all times:
StatusFooter surfaces four signals at all times:
- `status` from `GET /health` (`Healthy` / `Unhealthy` / `Checking`) —
a liveness dot polled independently of `/runtime/status` so a live
server still reads healthy when runtime metadata is degraded; any
non-`ok` status fails safe to `Unhealthy`
- `mutation_mode` from `GET /runtime/status` (`read_only` or
`runtime_turn`) — color-encoded **and** labeled
- `git_revision` (short SHA, mono)

View file

@ -38,6 +38,7 @@ import type {
MathProposalSummary,
MathProposalDetail,
MathRatifyResult,
HealthStatus,
} from "../types/api";
export class WorkbenchApiError extends Error {
@ -72,6 +73,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,
@ -47,6 +48,7 @@ import {
} from "./client";
import type { WorkbenchApiError } from "./client";
import type {
HealthStatus,
RuntimeStatus,
DemoRunResult,
DemoSummary,
@ -106,6 +108,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;