milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
This commit is contained in:
@@ -5,6 +5,7 @@ import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { EVENT_COPY } from "./eventCopy";
|
||||
|
||||
const NOW_S = Math.floor(Date.now() / 1000);
|
||||
@@ -35,6 +36,8 @@ beforeEach(() => {
|
||||
requested = [];
|
||||
responses = {
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
// The shell reads health for the Diagnostics nav badge on every route.
|
||||
"/api/health": health(),
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types";
|
||||
|
||||
// Ages are rendered against the wall clock, so the fixtures are anchored to it
|
||||
@@ -68,6 +69,9 @@ beforeEach(() => {
|
||||
requested = [];
|
||||
responses = {
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
// The health strip at the top of the page; quiet on a healthy box, which is
|
||||
// what every test below wants it to be.
|
||||
"/api/health": health(),
|
||||
"/api/diagnostics?state=active": ACTIVE,
|
||||
"/api/diagnostics?state=resolved": RESOLVED,
|
||||
};
|
||||
@@ -256,7 +260,7 @@ test("only the resolved history offers a purge", async () => {
|
||||
|
||||
// An episode still failing is the state of the box, not history: no purge
|
||||
// affordance anywhere on its card.
|
||||
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
|
||||
const active = (await screen.findByText("Blocklist source failed to update")).closest("li")!;
|
||||
expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull();
|
||||
|
||||
const row = screen.getByText("Disk space low").closest("tr")!;
|
||||
|
||||
@@ -17,6 +17,7 @@ import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import HealthStrip from "./HealthStrip";
|
||||
import SeverityBadge from "./SeverityBadge";
|
||||
import { diagnosticsFilterOf } from "./filter";
|
||||
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
|
||||
@@ -401,6 +402,8 @@ export default function DiagnosticsPage() {
|
||||
repeats, and closes when the subject recovers.
|
||||
</p>
|
||||
|
||||
<HealthStrip />
|
||||
|
||||
<RangeNotice since={search.since} until={search.until} />
|
||||
|
||||
<div {...stylex.props(styles.filterGrid)}>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* The health strip on the Diagnostics page, through the real router.
|
||||
*
|
||||
* Its load contract is migrated whole from the deleted Overview status section:
|
||||
* a visible loading state before the first reading, an error row with Retry when
|
||||
* the first read fails, and a refetch failure that marks the conditions on
|
||||
* screen as the last reading rather than the current state. What is new is that
|
||||
* a condition explained on this page narrows this page instead of navigating.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Health } from "@/lib/types";
|
||||
|
||||
let healthBody: Health;
|
||||
let healthFails: boolean;
|
||||
let requested: string[];
|
||||
/** Held open to keep a health request in flight while a test looks at the strip. */
|
||||
let pendingHealth: Promise<void> | null;
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
healthBody = health();
|
||||
healthFails = false;
|
||||
requested = [];
|
||||
pendingHealth = null;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
requested.push(url);
|
||||
if (url === "/api/health") {
|
||||
if (pendingHealth !== null) await pendingHealth;
|
||||
return healthFails ? json({ error: "health unavailable" }, 400) : json(healthBody);
|
||||
}
|
||||
if (url === "/api/version")
|
||||
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 });
|
||||
if (url.startsWith("/api/diagnostics"))
|
||||
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderDiagnostics(path = "/diagnostics") {
|
||||
const queryClient = createQueryClient();
|
||||
const defaults = queryClient.getDefaultOptions();
|
||||
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return { router, queryClient };
|
||||
}
|
||||
|
||||
function strip(): HTMLElement {
|
||||
return screen.getByRole("list", { name: "Current status" });
|
||||
}
|
||||
|
||||
function fact(label: string): HTMLElement {
|
||||
// First match, not only match: a condition's label and the link it offers can
|
||||
// be the same word — Upstreams links to Upstreams — and the label comes first.
|
||||
const cell = within(strip()).getAllByText(label)[0];
|
||||
const item = cell.closest("li");
|
||||
if (item === null) throw new Error(`no health fact for ${label}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
test("the five conditions are stated in words, healthy ones without a way out", async () => {
|
||||
renderDiagnostics();
|
||||
await waitFor(() => expect(strip()).toBeTruthy());
|
||||
|
||||
for (const [label, value] of [
|
||||
["Protection", "Active"],
|
||||
["Upstreams", "Available"],
|
||||
["Query history", "Recording"],
|
||||
["Diagnostics", "Recording"],
|
||||
["Storage", "OK"],
|
||||
] as const) {
|
||||
expect(within(fact(label)).getByText(value)).toBeTruthy();
|
||||
}
|
||||
expect(within(strip()).queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
test("protection unavailable sends the reader to Blocklists, upstreams to Upstreams", async () => {
|
||||
healthBody = health({
|
||||
protection: { state: "unavailable", until: null },
|
||||
upstreams: { state: "unavailable", available: 0, total: 2 },
|
||||
});
|
||||
renderDiagnostics();
|
||||
await waitFor(() => expect(strip()).toBeTruthy());
|
||||
|
||||
expect(within(fact("Protection")).getByRole("link", { name: "Blocklists" }).getAttribute("href")).toBe(
|
||||
"/blocklists",
|
||||
);
|
||||
expect(within(fact("Upstreams")).getByRole("link", { name: "Upstreams" }).getAttribute("href")).toBe("/upstreams");
|
||||
});
|
||||
|
||||
test("a losing query log narrows this page to the disk, a failed writer to the query log", async () => {
|
||||
healthBody = health({ query_history: { state: "losing", dropped_total: 4, last_drop_s: null } });
|
||||
const { router } = renderDiagnostics();
|
||||
await waitFor(() => expect(strip()).toBeTruthy());
|
||||
expect(within(fact("Query history")).getByText("4 queries dropped")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(fact("Query history")).getByRole("link", { name: "Disk diagnostics" }));
|
||||
|
||||
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
|
||||
await waitFor(() => expect(requested.some((url) => url.includes("component=disk"))).toBe(true));
|
||||
});
|
||||
|
||||
test("a filter link drops a time window that would hide the episodes it points at", async () => {
|
||||
healthBody = health({ disk: { state: "critical", free_bytes: 0 } });
|
||||
const { router } = renderDiagnostics("/diagnostics?since=1000&until=2000&severity=error&state=resolved");
|
||||
await waitFor(() => expect(strip()).toBeTruthy());
|
||||
|
||||
fireEvent.click(within(fact("Storage")).getByRole("link", { name: "Disk diagnostics" }));
|
||||
|
||||
// Everything that could hide the episode goes with the bounds: `state=resolved`
|
||||
// would exclude the active disk episode this link exists to show, and an
|
||||
// `error` severity would exclude it whenever it is a warning.
|
||||
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
|
||||
});
|
||||
|
||||
test("an unavailable diagnostics store explains itself and offers no link into itself", async () => {
|
||||
healthBody = health({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } });
|
||||
renderDiagnostics();
|
||||
await waitFor(() => expect(strip()).toBeTruthy());
|
||||
|
||||
const row = fact("Diagnostics");
|
||||
expect(within(row).getByText(/not being recorded/)).toBeTruthy();
|
||||
expect(within(row).queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
test("the strip says it is loading before the first reading, never empty conditions", async () => {
|
||||
// Through the route, which is the path that matters: the loader starts the
|
||||
// health request without waiting for it, so the page paints while the reading
|
||||
// is still in flight and the strip has to say so.
|
||||
let release = () => {};
|
||||
pendingHealth = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
renderDiagnostics();
|
||||
|
||||
expect(await screen.findByText("Loading status…")).toBeTruthy();
|
||||
expect(screen.queryByText("Protection")).toBeNull();
|
||||
|
||||
release();
|
||||
await waitFor(() => expect(strip()).toBeTruthy());
|
||||
expect(screen.queryByText("Loading status…")).toBeNull();
|
||||
});
|
||||
|
||||
test("a failed first health read is an error row with Retry, not a healthy strip", async () => {
|
||||
healthFails = true;
|
||||
renderDiagnostics();
|
||||
|
||||
await screen.findByText("health unavailable");
|
||||
expect(screen.queryByRole("list", { name: "Current status" })).toBeNull();
|
||||
});
|
||||
|
||||
test("a reading that has gone stale says so rather than passing for current", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderDiagnostics();
|
||||
await vi.waitFor(() => expect(strip()).toBeTruthy());
|
||||
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
|
||||
|
||||
// The next poll fails. The conditions on screen are the last that arrived and
|
||||
// must not keep passing for the current state.
|
||||
healthFails = true;
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
|
||||
await vi.waitFor(() => expect(screen.getByText(/last reading that arrived/)).toBeTruthy());
|
||||
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
|
||||
|
||||
// Recovery clears the caption rather than leaving the page permanently unsure.
|
||||
healthFails = false;
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
await vi.waitFor(() => expect(screen.queryByText(/last reading that arrived/)).toBeNull());
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* The five health conditions, compactly, at the top of the page that explains
|
||||
* failures. Healthy conditions stay quiet; a degraded one is highlighted and
|
||||
* offers the way out.
|
||||
*
|
||||
* A degraded condition whose explanation is on this page narrows this page
|
||||
* rather than navigating away: the filter link sets `component` and drops every
|
||||
* other filter. A time window, a severity or a `state=resolved` left from an
|
||||
* earlier investigation would each hide the very episode the reader was sent to
|
||||
* read, and a link that lands on "no events" states something false.
|
||||
*
|
||||
* The load contract is the one the deleted Overview status section carried: a
|
||||
* visible loading state before the first reading, and a refetch failure that
|
||||
* says so — the conditions on screen become the last reading that arrived, never
|
||||
* a claim about the current state, until a poll succeeds again.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { healthQuery } from "@/lib/queries";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { healthFacts, type FactLink, type FactTone, type HealthFact } from "./healthFacts";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
list: {
|
||||
marginTop: "0.75rem",
|
||||
display: "grid",
|
||||
gap: "0.5rem",
|
||||
// Five is prime, so every count between one and five leaves a short last
|
||||
// row; three columns made it 3 then 2, which reads as a layout that ran out
|
||||
// of room rather than one that chose. So the strip goes from one column
|
||||
// straight to two and then to a single row of five, and is never ragged.
|
||||
gridTemplateColumns: {
|
||||
default: "minmax(0, 1fr)",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
|
||||
},
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
},
|
||||
fact: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "baseline",
|
||||
gap: "0.375rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
paddingInline: "0.625rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** Quiet: a healthy condition is the normal state and gets no emphasis. */
|
||||
quiet: {
|
||||
borderColor: colors.border,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
highlighted: {
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
value: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
detail: {
|
||||
flexBasis: "100%",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
link: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
ok: {
|
||||
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
|
||||
},
|
||||
notice: {
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||
},
|
||||
warn: {
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||
},
|
||||
danger: {
|
||||
color: colors.dangerText,
|
||||
},
|
||||
message: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
|
||||
|
||||
/** Text and icon carry the state; the colour only agrees with them. */
|
||||
const ICONS: Record<FactTone, string> = { ok: "●", notice: "‖", warn: "!", danger: "✕" };
|
||||
|
||||
function FactLinkAnchor({ link }: { link: FactLink }) {
|
||||
if (link.kind === "filter") {
|
||||
return (
|
||||
<Link
|
||||
to="/diagnostics"
|
||||
// Sets the component and clears every filter that could hide what it
|
||||
// points at: a time window from an older investigation, and a severity
|
||||
// or state — `resolved` above all — that would exclude the very episode
|
||||
// explaining the condition this link came from.
|
||||
search={() => ({ component: link.component })}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link to={link.to} {...stylex.props(styles.link, shared.focusRing)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ fact }: { fact: HealthFact }) {
|
||||
return (
|
||||
<li {...stylex.props(styles.fact, fact.tone === "ok" ? styles.quiet : styles.highlighted)}>
|
||||
<span aria-hidden="true" {...stylex.props(TONES[fact.tone])}>
|
||||
{ICONS[fact.tone]}
|
||||
</span>
|
||||
<span {...stylex.props(styles.label)}>{fact.label}</span>
|
||||
<span {...stylex.props(styles.value, TONES[fact.tone])}>{fact.value}</span>
|
||||
{fact.link !== undefined && <FactLinkAnchor link={fact.link} />}
|
||||
{fact.detail !== undefined && <span {...stylex.props(styles.detail)}>{fact.detail}</span>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HealthStrip() {
|
||||
const health = useQuery(healthQuery());
|
||||
|
||||
if (health.data === undefined) {
|
||||
return health.isError ? (
|
||||
<InlineError error={health.error} onRetry={() => void health.refetch()} />
|
||||
) : (
|
||||
<p role="status" {...stylex.props(styles.message, shared.pulse)}>
|
||||
Loading status…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul aria-label="Current status" {...stylex.props(styles.list)}>
|
||||
{healthFacts(health.data).map((fact) => (
|
||||
<Fact key={fact.key} fact={fact} />
|
||||
))}
|
||||
</ul>
|
||||
{health.isError && (
|
||||
<>
|
||||
<p role="status" {...stylex.props(styles.message)}>
|
||||
This is the last reading that arrived. The current state is unknown.
|
||||
</p>
|
||||
<InlineError error={health.error} onRetry={() => void health.refetch()} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -48,3 +48,20 @@ test("component labels read as prose without inventing a name", () => {
|
||||
expect(componentLabel("query_log")).toBe("Query log");
|
||||
expect(componentLabel("disk")).toBe("Disk");
|
||||
});
|
||||
|
||||
test("the legacy upstream_history code keeps its copy, so a retained episode still reads as prose", () => {
|
||||
// Nothing emits it any more, but stored rows outlive the subsystem and the
|
||||
// list endpoint passes their codes through verbatim.
|
||||
expect(DIAGNOSTIC_CODES).toContain("upstream_history.write");
|
||||
const copy = EVENT_COPY["upstream_history.write"];
|
||||
expect(copy.title).toBe("Upstream history write failed");
|
||||
expect(copy.impact).toMatch(/no longer runs/);
|
||||
});
|
||||
|
||||
test("the recreated-log copy covers a planned schema change as well as an unreadable file", () => {
|
||||
const copy = EVENT_COPY["query_log.recreated"];
|
||||
// The planned cause leads, because it is the one an upgrade produces; the
|
||||
// unreadable file is the other cause and must not be dropped from the copy.
|
||||
expect(copy.impact).toMatch(/schema/);
|
||||
expect(copy.impact).toMatch(/could not be read/);
|
||||
});
|
||||
|
||||
@@ -111,14 +111,20 @@ export const EVENT_COPY: Record<DiagnosticCode, EventCopy> = {
|
||||
},
|
||||
"query_log.recreated": {
|
||||
title: "Query log recreated",
|
||||
impact: "The old log database was unreadable and was moved aside; the history it held is not in the new one.",
|
||||
impact: "The old log database was moved aside — a release changed its schema, or the file could not be read — and the history it held is not in the new one.",
|
||||
remediation: "Keep or delete the aside file named below. Nothing else is required — logging is running.",
|
||||
link: SETTINGS,
|
||||
},
|
||||
/**
|
||||
* Legacy. Nothing emits this code any more: milestone 30 deleted the
|
||||
* upstream-minute history subsystem. Stored rows outlive it, and the list
|
||||
* endpoint passes their codes through verbatim, so the copy stays — a
|
||||
* retained episode must still read as prose rather than as a dotted string.
|
||||
*/
|
||||
"upstream_history.write": {
|
||||
title: "Upstream history write failed",
|
||||
impact: "Resolution is unaffected; the per-upstream success and failure aggregates lose the affected window.",
|
||||
remediation: "Check free disk space and the configuration database's permissions.",
|
||||
impact: "A recorded failure of a subsystem this version no longer runs. Resolution was unaffected; the per-upstream aggregates it fed are gone.",
|
||||
remediation: "Nothing to do. Purge the entry once you have read it.",
|
||||
link: UPSTREAMS,
|
||||
},
|
||||
"upstream.exchange": {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The health-fact matrix, migrated whole from the Overview status rows this
|
||||
* replaces. Same states, same words, same link matrix — with the Diagnostics
|
||||
* links now narrowing the page the strip sits on rather than navigating to it.
|
||||
*/
|
||||
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { healthFacts, type HealthFact } from "./healthFacts";
|
||||
|
||||
const LOCALE = "en-GB";
|
||||
const TZ = "UTC";
|
||||
|
||||
function factsBy(overrides: Parameters<typeof health>[0] = {}): Record<string, HealthFact> {
|
||||
return Object.fromEntries(healthFacts(health(overrides), LOCALE, TZ).map((fact) => [fact.key, fact]));
|
||||
}
|
||||
|
||||
test("a healthy box is five quiet facts, none of them linking anywhere", () => {
|
||||
const facts = healthFacts(health(), LOCALE, TZ);
|
||||
expect(facts.map((fact) => fact.key)).toEqual(["protection", "upstreams", "query_history", "diagnostics", "disk"]);
|
||||
expect(facts.every((fact) => fact.tone === "ok")).toBe(true);
|
||||
expect(facts.every((fact) => fact.link === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("protection: active, paused indefinitely, paused until a time, unavailable", () => {
|
||||
expect(factsBy()["protection"].value).toBe("Active");
|
||||
expect(factsBy({ protection: { state: "paused", until: null } })["protection"].value).toBe("Paused");
|
||||
const timed = factsBy({ protection: { state: "paused", until: Date.UTC(2026, 0, 1, 14, 5) / 1000 } })["protection"];
|
||||
expect(timed.value).toBe("Paused until 14:05");
|
||||
const gone = factsBy({ protection: { state: "unavailable", until: null } })["protection"];
|
||||
expect(gone.value).toBe("Unavailable");
|
||||
expect(gone.link).toEqual({ kind: "route", to: "/blocklists", label: "Blocklists" });
|
||||
});
|
||||
|
||||
test("a pause never reads as a fault, and never carries a way out", () => {
|
||||
const paused = factsBy({ protection: { state: "paused", until: null } })["protection"];
|
||||
expect(paused.tone).toBe("notice");
|
||||
expect(paused.link).toBeUndefined();
|
||||
});
|
||||
|
||||
test("upstreams count the enabled pool, and only an empty one links out", () => {
|
||||
const ok = factsBy({ upstreams: { state: "ok", available: 1, total: 3 } })["upstreams"];
|
||||
expect(ok.value).toBe("Available");
|
||||
expect(ok.detail).toBe("1 of 3 enabled");
|
||||
expect(ok.link).toBeUndefined();
|
||||
const none = factsBy({ upstreams: { state: "unavailable", available: 0, total: 3 } })["upstreams"];
|
||||
expect(none.value).toBe("None reachable");
|
||||
expect(none.link).toEqual({ kind: "route", to: "/upstreams", label: "Upstreams" });
|
||||
});
|
||||
|
||||
test("query history: losing blames the disk gate, a failed writer blames the query log", () => {
|
||||
const losing = factsBy({ query_history: { state: "losing", dropped_total: 0, last_drop_s: null } })[
|
||||
"query_history"
|
||||
];
|
||||
expect(losing.value).toBe("Losing rows");
|
||||
expect(losing.link).toEqual({ kind: "filter", component: "disk", label: "Disk diagnostics" });
|
||||
const failed = factsBy({ query_history: { state: "failed", dropped_total: 0, last_drop_s: null } })[
|
||||
"query_history"
|
||||
];
|
||||
expect(failed.value).toBe("Writer failed");
|
||||
expect(failed.link).toEqual({ kind: "filter", component: "query_log", label: "Query log diagnostics" });
|
||||
});
|
||||
|
||||
test("drops are reported while recording, with or without a stamp on the last one", () => {
|
||||
const stamped = factsBy({
|
||||
query_history: { state: "recording", dropped_total: 5, last_drop_s: Date.UTC(2026, 0, 1, 9, 30) / 1000 },
|
||||
})["query_history"];
|
||||
expect(stamped.tone).toBe("ok");
|
||||
expect(stamped.detail).toBe("5 queries dropped, last at 09:30");
|
||||
const unstamped = factsBy({ query_history: { state: "recording", dropped_total: 1, last_drop_s: null } })[
|
||||
"query_history"
|
||||
];
|
||||
expect(unstamped.detail).toBe("1 query dropped");
|
||||
expect(factsBy()["query_history"].detail).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an unavailable diagnostics store explains itself and links nowhere", () => {
|
||||
const fact = factsBy({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } })[
|
||||
"diagnostics"
|
||||
];
|
||||
expect(fact.value).toBe("Unavailable");
|
||||
expect(fact.detail).toContain("not being recorded");
|
||||
// The page a link would filter is the thing that is broken.
|
||||
expect(fact.link).toBeUndefined();
|
||||
});
|
||||
|
||||
test("storage names the three disk states and always shows what is free", () => {
|
||||
expect(factsBy()["disk"].value).toBe("OK");
|
||||
expect(factsBy()["disk"].link).toBeUndefined();
|
||||
const low = factsBy({ disk: { state: "low", free_bytes: 1024 } })["disk"];
|
||||
expect(low.value).toBe("Low");
|
||||
expect(low.tone).toBe("warn");
|
||||
expect(low.link).toEqual({ kind: "filter", component: "disk", label: "Disk diagnostics" });
|
||||
const critical = factsBy({ disk: { state: "critical", free_bytes: 0 } })["disk"];
|
||||
expect(critical.value).toBe("Critical");
|
||||
expect(critical.tone).toBe("danger");
|
||||
expect(critical.detail).toBe("0 B free");
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The five conditions `GET /api/health` reports, as facts.
|
||||
*
|
||||
* A pure projection of `Health` so the whole matrix — every state of every
|
||||
* condition, and every way out a degraded one offers — is testable without a
|
||||
* router or a fetch. `HealthStrip` only paints what this returns.
|
||||
*
|
||||
* A healthy fact is quiet: no badge, no panel, no green reassurance, so the one
|
||||
* condition that is not healthy is the thing the eye lands on.
|
||||
*/
|
||||
|
||||
import { formatBytes, formatClock } from "@/lib/format";
|
||||
import type { Health } from "@/lib/types";
|
||||
|
||||
export type FactTone = "ok" | "notice" | "warn" | "danger";
|
||||
|
||||
/**
|
||||
* Where a degraded fact sends the reader. A `filter` link stays on this page and
|
||||
* narrows it to the component that failed; a `route` link leaves for the surface
|
||||
* that can fix the condition.
|
||||
*/
|
||||
export type FactLink =
|
||||
| { kind: "route"; to: "/blocklists" | "/upstreams"; label: string }
|
||||
| { kind: "filter"; component: string; label: string };
|
||||
|
||||
export interface HealthFact {
|
||||
key: "protection" | "upstreams" | "query_history" | "diagnostics" | "disk";
|
||||
label: string;
|
||||
tone: FactTone;
|
||||
/** The state, in the reader's words. Never colour alone. */
|
||||
value: string;
|
||||
detail?: string;
|
||||
link?: FactLink;
|
||||
}
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
/** Kept out of the state rule: rows are lost whether or not the box is losing them now. */
|
||||
function dropText(dropped: number, lastDrop: number | null, locale?: string, timeZone?: string): string | undefined {
|
||||
if (dropped <= 0) return undefined;
|
||||
const count = `${numberFormat.format(dropped)} ${dropped === 1 ? "query" : "queries"} dropped`;
|
||||
return lastDrop === null ? count : `${count}, last at ${formatClock(lastDrop, locale, timeZone)}`;
|
||||
}
|
||||
|
||||
function protectionFact(protection: Health["protection"], locale?: string, timeZone?: string): HealthFact {
|
||||
if (protection.state === "unavailable") {
|
||||
return {
|
||||
key: "protection",
|
||||
label: "Protection",
|
||||
tone: "danger",
|
||||
value: "Unavailable",
|
||||
detail: "No filter snapshot is published, so queries are not being filtered.",
|
||||
link: { kind: "route", to: "/blocklists", label: "Blocklists" },
|
||||
};
|
||||
}
|
||||
if (protection.state === "paused") {
|
||||
return {
|
||||
key: "protection",
|
||||
label: "Protection",
|
||||
tone: "notice",
|
||||
value:
|
||||
protection.until === null
|
||||
? "Paused"
|
||||
: `Paused until ${formatClock(protection.until, locale, timeZone)}`,
|
||||
};
|
||||
}
|
||||
return { key: "protection", label: "Protection", tone: "ok", value: "Active" };
|
||||
}
|
||||
|
||||
function queryHistoryFact(history: Health["query_history"], locale?: string, timeZone?: string): HealthFact {
|
||||
const detail = dropText(history.dropped_total, history.last_drop_s, locale, timeZone);
|
||||
if (history.state === "failed") {
|
||||
return {
|
||||
key: "query_history",
|
||||
label: "Query history",
|
||||
tone: "danger",
|
||||
value: "Writer failed",
|
||||
detail,
|
||||
link: { kind: "filter", component: "query_log", label: "Query log diagnostics" },
|
||||
};
|
||||
}
|
||||
if (history.state === "losing") {
|
||||
// The disk gate is what is holding the writes back, and it may be the only
|
||||
// thing that has reported: query_log itself need not have an episode open.
|
||||
return {
|
||||
key: "query_history",
|
||||
label: "Query history",
|
||||
tone: "danger",
|
||||
value: "Losing rows",
|
||||
detail,
|
||||
link: { kind: "filter", component: "disk", label: "Disk diagnostics" },
|
||||
};
|
||||
}
|
||||
return { key: "query_history", label: "Query history", tone: "ok", value: "Recording", detail };
|
||||
}
|
||||
|
||||
export function healthFacts(health: Health, locale?: string, timeZone?: string): HealthFact[] {
|
||||
const { upstreams, diagnostics, disk } = health;
|
||||
return [
|
||||
protectionFact(health.protection, locale, timeZone),
|
||||
{
|
||||
key: "upstreams",
|
||||
label: "Upstreams",
|
||||
tone: upstreams.state === "unavailable" ? "danger" : "ok",
|
||||
value: upstreams.state === "unavailable" ? "None reachable" : "Available",
|
||||
detail: `${upstreams.available} of ${upstreams.total} enabled`,
|
||||
...(upstreams.state === "unavailable"
|
||||
? { link: { kind: "route", to: "/upstreams", label: "Upstreams" } as FactLink }
|
||||
: {}),
|
||||
},
|
||||
queryHistoryFact(health.query_history, locale, timeZone),
|
||||
diagnostics.state === "unavailable"
|
||||
? {
|
||||
key: "diagnostics",
|
||||
label: "Diagnostics",
|
||||
tone: "danger",
|
||||
value: "Unavailable",
|
||||
// No link: the page it would filter is the thing that is broken.
|
||||
detail: "Diagnostics are not being recorded. Check free disk space and the configuration database's permissions.",
|
||||
}
|
||||
: { key: "diagnostics", label: "Diagnostics", tone: "ok", value: "Recording" },
|
||||
{
|
||||
key: "disk",
|
||||
label: "Storage",
|
||||
tone: disk.state === "critical" ? "danger" : disk.state === "low" ? "warn" : "ok",
|
||||
value: disk.state === "critical" ? "Critical" : disk.state === "low" ? "Low" : "OK",
|
||||
detail: `${formatBytes(disk.free_bytes)} free`,
|
||||
...(disk.state === "ok"
|
||||
? {}
|
||||
: { link: { kind: "filter", component: "disk", label: "Disk diagnostics" } as FactLink }),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user