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

This commit is contained in:
2026-08-22 16:45:15 +02:00
parent 17422fac21
commit 648d9b4496
89 changed files with 7222 additions and 4239 deletions
+160 -53
View File
@@ -1,12 +1,15 @@
import { fireEvent, render, screen } from "@testing-library/react";
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, resetAuthProbeForTests } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { formatClock } from "@/lib/format";
import { health } from "@/lib/healthFixture";
import type { Health } from "@/lib/types";
const NAV_LABELS = [
"Dashboard",
"Overview",
"Activity",
"Clients",
"Groups",
@@ -25,7 +28,6 @@ const RESPONSES: Record<string, unknown> = {
until: 86400,
queries: 0,
blocked: 0,
cached: 0,
clients: 0,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
@@ -38,27 +40,50 @@ const RESPONSES: Record<string, unknown> = {
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/health": {
status: "ok",
disk: { state: "ok", free_bytes: 0, db_bytes: 0, log_bytes: 0, sample_failures: 0 },
upstreams: { available: 1, total: 1 },
queries_dropped: 0,
writer_failed: false,
refreshes_gated: 0,
snapshot_generation: null,
diagnostics: { state: "recording", active_warnings: 0, active_errors: 0 },
"/api/stats/clients?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
clients: [],
other: [],
coverage: { complete: true, available_since: 0 },
},
"/api/upstream/health": { upstreams: [], available: 1, total: 1 },
"/api/stats/types?period=24h": {
period: "24h",
since: 0,
until: 86400,
types: [],
coverage: { complete: true, available_since: 0 },
},
"/api/stats/routes?period=24h": {
period: "24h",
since: 0,
until: 86400,
routes: [],
coverage: { complete: true, available_since: 0 },
},
"/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } },
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
let healthBody: Health | null;
function stubFetch(extra: (url: string) => Response | null = () => null) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const override = extra(url);
if (override !== null) return override;
if (url === "/api/health") {
const failed = healthBody === null;
return new Response(JSON.stringify(failed ? { error: "health unavailable" } : healthBody), {
status: failed ? 503 : 200,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
@@ -67,13 +92,9 @@ beforeEach(() => {
});
}),
);
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("shell renders the dashboard route with all nav links", async () => {
function renderShell() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
@@ -83,8 +104,30 @@ test("shell renders the dashboard route with all nav links", async () => {
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
await screen.findByRole("heading", { name: "Dashboard" });
function diagnosticsBadgeText(): string | null {
const link = screen.getAllByRole("link", { name: /^Diagnostics/ })[0];
const badge = link.querySelector("[aria-label]");
return badge === null ? null : (badge.textContent ?? "");
}
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
healthBody = health();
stubFetch();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("shell renders the overview route with all nav links", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav).toBeTruthy();
@@ -94,41 +137,105 @@ test("shell renders the dashboard route with all nav links", async () => {
});
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/auth/login")
return new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
});
if (url === "/api/auth/logout")
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "7" },
});
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
stubFetch((url) => {
if (url === "/api/auth/login")
return new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}),
);
if (url === "/api/auth/logout")
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "7" },
});
return null;
});
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
renderShell();
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
await screen.findByText("Rate limited. Try again in 7s.");
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
});
test("the header carries no protection display at all any more", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
for (const gone of [/^Protection/, /^Paused/]) {
expect(screen.queryByRole("link", { name: gone })).toBeNull();
}
});
test("Pause sits at the foot of the sidebar, above the version label", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const pause = await waitFor(() => within(aside).getByRole("button", { name: "Pause" }));
const version = within(aside).getByText(/^nxdns v/);
// Node order, not styling: the control precedes the version footer.
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("the mobile drawer carries the same control, not a header one it lost", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
// Both renderings are mounted; the viewport decides which is painted.
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(2));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
const pause = within(drawer).getByRole("button", { name: "Pause" });
const version = within(drawer).getByText(/^nxdns v/);
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
// The trace a pause leaves on every page. With the header indicator and the
// Overview status row both gone, a reader who is not on Diagnostics has only
// this line to tell them filtering is off.
const until = Math.floor(Date.now() / 1000) + 90;
healthBody = health({ protection: { state: "paused", until } });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
await waitFor(() => expect(within(aside).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
await waitFor(() => expect(within(drawer).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
});
test("nothing open and a healthy rollup leaves the Diagnostics item unbadged", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(document.querySelector("aside")?.textContent).toContain("Diagnostics"));
expect(diagnosticsBadgeText()).toBeNull();
});
test("open episodes are counted on the nav item", async () => {
healthBody = health({ diagnostics: { state: "recording", active_warnings: 1, active_errors: 2 } });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("3"));
expect(screen.getAllByLabelText("3 active diagnostic events").length).toBeGreaterThan(0);
});
test("a degraded rollup with nothing open is still marked, and a failed poll too", async () => {
healthBody = health({ status: "degraded" });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
expect(screen.getAllByLabelText("Health degraded").length).toBeGreaterThan(0);
});
test("a health poll that failed is marked unknown rather than left looking healthy", async () => {
healthBody = null;
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
expect(screen.getAllByLabelText("Health unavailable").length).toBeGreaterThan(0);
});
+59 -9
View File
@@ -4,8 +4,9 @@ import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget";
import { healthQuery, versionQuery } from "@/lib/queries";
import PauseControl from "@/features/pause/PauseControl";
import { diagnosticsBadge } from "./diagnosticsBadge";
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
import RestartBanner from "../features/settings/RestartBanner";
import { styles as shared } from "@/ui/styles";
@@ -16,7 +17,7 @@ const WIDE = "@media (min-width: 768px)";
const DARK = "@media (prefers-color-scheme: dark)";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
{ to: "/overview", label: "Overview" },
{ to: "/activity", label: "Activity" },
{ to: "/clients", label: "Clients" },
{ to: "/groups", label: "Groups" },
@@ -35,12 +36,40 @@ const styles = stylex.create({
gap: "0.25rem",
},
navLink: {
display: "block",
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textDecorationLine: "none",
},
navLabel: {
flex: 1,
},
/**
* Neutral chrome: the mark is the message, and a coloured pill here would be
* the page's loudest element on every route. Text and shape carry it.
*/
badge: {
minWidth: "1.25rem",
borderRadius: "0.625rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceHover,
paddingInline: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1.125rem",
fontWeight: 500,
textAlign: "center",
color: colors.textSecondary,
},
/** The control sits with the footer, not in the scrolling nav list above it. */
sidebarFooter: {
paddingInline: "1rem",
paddingTop: "0.75rem",
},
/** The current page reads as a filled chip, heavier than the hover fill. */
navActive: {
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
@@ -135,6 +164,8 @@ const styles = stylex.create({
});
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const health = useQuery(healthQuery());
const badge = diagnosticsBadge(health.data, health.isError);
return (
<ul {...stylex.props(styles.navList)}>
{NAV_ITEMS.map((item) => (
@@ -142,7 +173,6 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
<Link
to={item.to}
onClick={onNavigate}
activeOptions={{ exact: item.to === "/" }}
activeProps={{
"aria-current": "page",
className: stylex.props(styles.navActive).className,
@@ -150,7 +180,12 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
{...stylex.props(styles.navLink, shared.focusRing)}
>
{item.label}
<span {...stylex.props(styles.navLabel)}>{item.label}</span>
{item.to === "/diagnostics" && badge !== null && (
<span aria-label={badge.label} {...stylex.props(styles.badge)}>
{badge.text}
</span>
)}
</Link>
</li>
))}
@@ -158,6 +193,22 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
);
}
/**
* The sidebar's foot, in both renderings. Pause is a runtime action on the whole
* resolver rather than on the page in front of the reader, which is why it sits
* with the version label instead of in the header of every route.
*/
function SidebarFooter() {
return (
<>
<div {...stylex.props(styles.sidebarFooter)}>
<PauseControl />
</div>
<VersionFooter />
</>
);
}
function VersionFooter() {
const { data } = useQuery(versionQuery());
return (
@@ -201,7 +252,7 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
<NavLinks />
</nav>
<VersionFooter />
<SidebarFooter />
</aside>
<div {...stylex.props(styles.column)}>
<header {...stylex.props(styles.header)}>
@@ -216,7 +267,6 @@ export default function AppShell() {
</button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}>
<PauseWidget />
<LogoutButton />
</div>
</header>
@@ -227,7 +277,7 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
<NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav>
<VersionFooter />
<SidebarFooter />
</div>
)}
<main {...stylex.props(styles.main)}>
+38
View File
@@ -0,0 +1,38 @@
import { health } from "@/lib/healthFixture";
import { diagnosticsBadge } from "./diagnosticsBadge";
test("a healthy box with nothing open wears no badge", () => {
expect(diagnosticsBadge(health(), false)).toBeNull();
});
test("open episodes are the count, warnings and errors together", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 2, active_errors: 1 } }),
false,
);
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" });
});
test("one open episode is counted in the singular", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }),
false,
);
expect(badge).toEqual({ text: "1", label: "1 active diagnostic event" });
});
test("a degraded rollup with no open episode still shows, so no degraded state is invisible", () => {
const badge = diagnosticsBadge(health({ status: "degraded" }), false);
expect(badge).toEqual({ text: "!", label: "Health degraded" });
});
test("a failed poll is not evidence of health, badge and all", () => {
// Cached body says everything is fine; the poll that would have confirmed it
// never landed. An unbadged item here claims health on no evidence.
expect(diagnosticsBadge(health(), true)).toEqual({ text: "!", label: "Health unavailable" });
expect(diagnosticsBadge(undefined, true)).toEqual({ text: "!", label: "Health unavailable" });
});
test("the first poll being in flight is the one unknown that hides", () => {
expect(diagnosticsBadge(undefined, false)).toBeNull();
});
+37
View File
@@ -0,0 +1,37 @@
/**
* The count beside the Diagnostics nav item.
*
* Three things have to be visible and only one of them is a number. Open
* episodes are the count. A `degraded` rollup with no open episode still has to
* show something, or a degraded box looks exactly like a healthy one. And a
* health poll that failed is not evidence of health: it shows the same neutral
* mark, because the alternative is an unbadged item claiming all is well on no
* evidence at all.
*
* "Fresh" here means the latest poll succeeded, never TanStack's `isStale`:
* healthQuery's `staleTime` is 0, so staleness is true in every gap between
* polls and would badge the item permanently.
*/
import type { Health } from "@/lib/types";
export interface NavBadge {
/** What the badge shows. Shape and text, never colour alone. */
text: string;
/** What a screen reader hears in its place. */
label: string;
}
export function diagnosticsBadge(health: Health | undefined, pollFailed: boolean): NavBadge | null {
// The one hidden unknown, and only because it is momentary: the first poll has
// not answered yet, and there is nothing to be right or wrong about.
if (health === undefined && !pollFailed) return null;
if (pollFailed) return { text: "!", label: "Health unavailable" };
if (health === undefined) return null;
const open = health.diagnostics.active_warnings + health.diagnostics.active_errors;
if (open > 0) {
return { text: String(open), label: `${open} active diagnostic ${open === 1 ? "event" : "events"}` };
}
if (health.status === "degraded") return { text: "!", label: "Health degraded" };
return null;
}