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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user