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:
parent
9b3c291d22
commit
0bdc2eef6d
5 changed files with 120 additions and 5 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue