milestone 32: task-shaped configuration, file mode as a rendering, config status api
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s

This commit is contained in:
2026-08-22 22:42:50 +02:00
parent 025edbb093
commit 7e0df5fd94
89 changed files with 6101 additions and 3750 deletions
+101 -17
View File
@@ -1,25 +1,22 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, 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 { formatClock, formatTime } from "@/lib/format";
import { health } from "@/lib/healthFixture";
import type { Health } from "@/lib/types";
import type { ConfigStatus, Health } from "@/lib/types";
const NAV_LABELS = [
"Overview",
"Activity",
"Clients",
"Groups",
"Blocklists",
"Rules",
"Local DNS",
"Upstreams",
"Diagnostics",
"Settings",
];
const NAV_LABELS = ["Overview", "Activity", "Clients", "Diagnostics"];
const CONFIGURATION_LABELS = ["Protection", "Resolution", "System"];
/** The pages the redesign folded into the three configuration ones. */
const GONE_LABELS = ["Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", "Settings"];
const CONFIG_PATH = "/etc/nxdns/config.zon";
const RECONCILED_AT = 1754899200;
const DATABASE: ConfigStatus = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
const RESPONSES: Record<string, unknown> = {
"/api/stats?period=24h": {
@@ -69,6 +66,8 @@ const RESPONSES: Record<string, unknown> = {
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
let healthBody: Health | null;
/** Null makes the config status poll fail, which the shell has to say out loud. */
let configStatus: ConfigStatus | null;
function stubFetch(extra: (url: string) => Response | null = () => null) {
vi.stubGlobal(
@@ -77,6 +76,13 @@ function stubFetch(extra: (url: string) => Response | null = () => null) {
const url = String(input);
const override = extra(url);
if (override !== null) return override;
if (url === "/api/config/status") {
const failed = configStatus === null;
return new Response(JSON.stringify(failed ? { error: "config status unavailable" } : configStatus), {
status: failed ? 503 : 200,
headers: { "content-type": "application/json" },
});
}
if (url === "/api/health") {
const failed = healthBody === null;
return new Response(JSON.stringify(failed ? { error: "health unavailable" } : healthBody), {
@@ -117,6 +123,7 @@ beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
healthBody = health();
configStatus = DATABASE;
stubFetch();
});
@@ -131,9 +138,82 @@ test("shell renders the overview route with all nav links", async () => {
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav).toBeTruthy();
for (const label of NAV_LABELS) {
for (const label of [...NAV_LABELS, ...CONFIGURATION_LABELS]) {
expect(screen.getByRole("link", { name: label })).toBeTruthy();
}
for (const label of GONE_LABELS) {
expect(screen.queryByRole("link", { name: label })).toBeNull();
}
});
test("the three configuration pages sit under a labelled group, after the rest", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const group = screen.getByRole("list", { name: "Configuration" });
expect(
within(group)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual(CONFIGURATION_LABELS);
// The group is a section of Main, not a nav of its own.
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav.contains(group)).toBe(true);
const clients = within(nav).getByRole("link", { name: "Clients" });
expect(clients.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("under file authority the nav states the file and when it was loaded", async () => {
configStatus = {
authority: "managed_file",
path: CONFIG_PATH,
reconciled_at: RECONCILED_AT,
restart_pending: false,
};
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const line = await screen.findByText(/^File-managed ·/);
expect(line.textContent).toBe(`File-managed · ${CONFIG_PATH} · loaded ${formatTime(RECONCILED_AT)}`);
const group = screen.getByRole("list", { name: "Configuration" });
expect(group.parentElement?.contains(line)).toBe(true);
});
test("under database authority there is no authority line to read", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await screen.findByRole("list", { name: "Configuration" });
expect(screen.queryByText(/File-managed/)).toBeNull();
});
test("a pending restart is announced on every page, with no way to dismiss it", async () => {
configStatus = { ...DATABASE, restart_pending: true };
renderShell();
const notice = await screen.findByText(/Saved changes are not running yet\. Restart nxdns to apply them\./);
expect(within(notice).queryByRole("button")).toBeNull();
expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull();
});
test("the restart notice is server state, so a browser refresh does not clear it", async () => {
configStatus = { ...DATABASE, restart_pending: true };
renderShell();
await screen.findByText(/Saved changes are not running yet/);
// A refresh: everything client-side is thrown away and rebuilt from the API.
cleanup();
renderShell();
await screen.findByText(/Saved changes are not running yet/);
});
test("a failed config status is stated rather than passed off as database authority", async () => {
configStatus = null;
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await screen.findByText(/Configuration status unavailable — file authority and pending restarts cannot be shown\./);
});
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
@@ -162,8 +242,12 @@ test("mount probe reveals the logout button and a failed logout surfaces inline"
test("the header carries no protection display at all any more", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
// Scoped to the header: "Protection" is a nav destination now, and that is
// not the status pill this test buried.
const header = within(document.querySelector("header") as HTMLElement);
for (const gone of [/^Protection/, /^Paused/]) {
expect(screen.queryByRole("link", { name: gone })).toBeNull();
expect(header.queryByRole("link", { name: gone })).toBeNull();
expect(header.queryByText(gone)).toBeNull();
}
});
+88 -33
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -7,8 +7,8 @@ import InlineError from "@/lib/InlineError";
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 ConfigStatusNotices from "./ConfigStatusNotices";
import AuthorityLine from "@/features/configuration/AuthorityLine";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -16,17 +16,22 @@ import { colors } from "@/ui/tokens.stylex";
const WIDE = "@media (min-width: 768px)";
const DARK = "@media (prefers-color-scheme: dark)";
/**
* The four operational surfaces, then the configuration group. Configuration
* is a labelled group rather than a collapsible tree: three items do not earn
* a disclosure, and a tree would hide the authority line under it.
*/
const NAV_ITEMS = [
{ to: "/overview", label: "Overview" },
{ to: "/activity", label: "Activity" },
{ to: "/clients", label: "Clients" },
{ to: "/groups", label: "Groups" },
{ to: "/blocklists", label: "Blocklists" },
{ to: "/rules", label: "Rules" },
{ to: "/local-dns", label: "Local DNS" },
{ to: "/upstreams", label: "Upstreams" },
{ to: "/diagnostics", label: "Diagnostics" },
{ to: "/settings", label: "Settings" },
] as const;
const CONFIGURATION_ITEMS = [
{ to: "/configuration/protection", label: "Protection" },
{ to: "/configuration/resolution", label: "Resolution" },
{ to: "/configuration/system", label: "System" },
] as const;
const styles = stylex.create({
@@ -47,6 +52,19 @@ const styles = stylex.create({
navLabel: {
flex: 1,
},
navGroup: {
marginTop: "1rem",
},
navGroupLabel: {
paddingInline: "0.75rem",
paddingBlock: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: colors.textMuted,
},
/**
* 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.
@@ -163,33 +181,71 @@ const styles = stylex.create({
},
});
function NavItem({
to,
label,
onNavigate,
badge,
}: {
to: string;
label: string;
onNavigate?: () => void;
badge?: { text: string; label: string } | null;
}) {
return (
<li>
<Link
to={to}
onClick={onNavigate}
activeProps={{
"aria-current": "page",
className: stylex.props(styles.navActive).className,
}}
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
{...stylex.props(styles.navLink, shared.focusRing)}
>
<span {...stylex.props(styles.navLabel)}>{label}</span>
{badge !== undefined && badge !== null && (
<span aria-label={badge.label} {...stylex.props(styles.badge)}>
{badge.text}
</span>
)}
</Link>
</li>
);
}
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const health = useQuery(healthQuery());
const badge = diagnosticsBadge(health.data, health.isError);
const groupHeadingId = useId();
return (
<ul {...stylex.props(styles.navList)}>
{NAV_ITEMS.map((item) => (
<li key={item.to}>
<Link
<>
<ul {...stylex.props(styles.navList)}>
{NAV_ITEMS.map((item) => (
<NavItem
key={item.to}
to={item.to}
onClick={onNavigate}
activeProps={{
"aria-current": "page",
className: stylex.props(styles.navActive).className,
}}
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
{...stylex.props(styles.navLink, shared.focusRing)}
>
<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>
))}
</ul>
label={item.label}
onNavigate={onNavigate}
badge={item.to === "/diagnostics" ? badge : undefined}
/>
))}
</ul>
<div {...stylex.props(styles.navGroup)}>
{/* A span, not a heading: the sidebar label is not a section of the
page, and an h2 here lands in the middle of the page's own outline. */}
<span id={groupHeadingId} {...stylex.props(styles.navGroupLabel)}>
Configuration
</span>
<ul aria-labelledby={groupHeadingId} {...stylex.props(styles.navList)}>
{CONFIGURATION_ITEMS.map((item) => (
<NavItem key={item.to} to={item.to} label={item.label} onNavigate={onNavigate} />
))}
</ul>
<AuthorityLine />
</div>
</>
);
}
@@ -270,8 +326,7 @@ export default function AppShell() {
<LogoutButton />
</div>
</header>
<RestartBanner />
<ReadOnlyConfigBanner />
<ConfigStatusNotices />
{drawerOpen && (
<div id="mobile-nav" {...stylex.props(styles.drawer)}>
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
+62
View File
@@ -0,0 +1,62 @@
import * as stylex from "@stylexjs/stylex";
import { useAuthority } from "@/features/configuration/authority";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
notice: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
unavailable: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
backgroundColor: colors.surfaceHover,
color: colors.textSecondary,
paddingInline: "1rem",
paddingBlock: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
},
});
/**
* What `/api/config/status` says, on every route.
*
* The restart notice has no dismiss control on purpose: `restart_pending` is
* process state the server owns, so a reload cannot clear it and neither
* should a click. It goes away when the process it describes does.
*
* The unavailable indicator is the other half. A dead status endpoint would
* otherwise hide both file authority and a pending restart from every page
* outside configuration, and silence would read as "nothing to report".
*/
export default function ConfigStatusNotices() {
const authority = useAuthority();
if (authority.state === "failed") {
return (
<div role="status" {...stylex.props(styles.unavailable)}>
Configuration status unavailable file authority and pending restarts cannot be shown.
</div>
);
}
if (authority.state !== "resolved" || !authority.status.restart_pending) return null;
return (
<div role="status" {...stylex.props(styles.notice)}>
Saved changes are not running yet. Restart nxdns to apply them.
</div>
);
}