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:
commit
0cb01ee3f1
6 changed files with 125 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue