admin: overview redesign, device scope, one formatting contract (milestone 39)
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s

The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only.

GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry.

All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else.

Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash.

Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round.

Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
This commit is contained in:
2026-09-07 23:11:50 +02:00
parent e656670dd4
commit 85b8be50a0
77 changed files with 2869 additions and 1163 deletions
+24 -14
View File
@@ -9,7 +9,7 @@ import { health } from "@/lib/healthFixture";
import type { ConfigStatus, Health } from "@/lib/types";
const NAV_LABELS = ["Overview", "Activity", "Clients", "Diagnostics"];
const CONFIGURATION_LABELS = ["Protection", "Resolution", "System"];
const SYSTEM_LABELS = ["Protection", "Resolution", "System", "Diagnostics"];
/** The pages the redesign folded into the three configuration ones. */
const GONE_LABELS = ["Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", "Settings"];
@@ -110,7 +110,7 @@ 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, ...CONFIGURATION_LABELS]) {
for (const label of [...NAV_LABELS, ...SYSTEM_LABELS]) {
expect(screen.getByRole("link", { name: label })).toBeTruthy();
}
for (const label of GONE_LABELS) {
@@ -127,16 +127,16 @@ test("main carries the ids the router scrolls and restores", async () => {
expect(main.getAttribute("data-scroll-restoration-id")).toBe("main");
});
test("the three configuration pages sit under a labelled group, after the rest", async () => {
test("the configuration pages and Diagnostics sit under the System group, after Monitoring", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const group = screen.getByRole("list", { name: "Configuration" });
const group = screen.getByRole("list", { name: "System" });
expect(
within(group)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual(CONFIGURATION_LABELS);
).toEqual(SYSTEM_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);
@@ -156,14 +156,14 @@ test("under file authority the nav states the file and when it was loaded", asyn
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" });
const group = screen.getByRole("list", { name: "System" });
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" });
await screen.findByRole("list", { name: "System" });
expect(screen.queryByText(/File-managed/)).toBeNull();
});
@@ -253,16 +253,26 @@ test("Log out sits in the sidebar on wide, and only in the header below it", asy
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
});
test("the header carries no protection display at all any more", async () => {
test("the header carries the protection state as a reading, never as a control", 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(header.queryByRole("link", { name: gone })).toBeNull();
expect(header.queryByText(gone)).toBeNull();
}
await waitFor(() => expect(header.getByText("Protection active")).toBeTruthy());
expect(header.queryByRole("link", { name: /^Protection/ })).toBeNull();
expect(header.queryByRole("button", { name: /^Pause/ })).toBeNull();
});
test("the sidebar's foot states protection and uptime, and queries/min only on Overview", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const status = within(aside).getByLabelText("Status");
await waitFor(() => expect(within(status).getByText("Active")).toBeTruthy());
// 1 second of uptime from the fixture, aged by however long the test took.
expect(within(status).getByText("Uptime").nextElementSibling?.textContent).toMatch(/^\d+s$/);
// The fixture's overview body: no queries over a 24h window, stated as a rate.
await waitFor(() => expect(within(status).getByText("Queries/min")).toBeTruthy());
expect(within(status).getByText("Queries/min").nextElementSibling?.textContent).toBe("0.0");
});
test("Pause sits at the foot of the sidebar, above the version label", async () => {
+67 -44
View File
@@ -9,30 +9,31 @@ import { healthQuery, versionQuery } from "@/lib/queries";
import PauseControl from "@/features/pause/PauseControl";
import { diagnosticsBadge } from "./diagnosticsBadge";
import ConfigStatusNotices from "./ConfigStatusNotices";
import SidebarStatus, { ProtectionDot } from "./SidebarStatus";
import AuthorityLine from "@/features/configuration/AuthorityLine";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
import { colors, layers, metrics } from "@/ui/tokens.stylex";
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
const WIDE = "@media (min-width: 768px)";
const DARK = "@media (prefers-color-scheme: dark)";
const WIDE = "@media (min-width: 801px)";
/**
* 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.
* Two labelled groups (ui-visual-redesign.md): MONITORING is what the resolver
* is doing, SYSTEM is what it is told to do and how the box is faring. The
* three configuration pages sit flat in SYSTEM — "Configuration" is not one
* page — with the authority line under them.
*/
const NAV_ITEMS = [
const MONITORING_ITEMS = [
{ to: "/overview", label: "Overview" },
{ to: "/activity", label: "Activity" },
{ to: "/clients", label: "Clients" },
{ to: "/diagnostics", label: "Diagnostics" },
] as const;
const CONFIGURATION_ITEMS = [
const SYSTEM_ITEMS = [
{ to: "/configuration/protection", label: "Protection" },
{ to: "/configuration/resolution", label: "Resolution" },
{ to: "/configuration/system", label: "System" },
{ to: "/diagnostics", label: "Diagnostics" },
] as const;
const styles = stylex.create({
@@ -45,7 +46,8 @@ const styles = stylex.create({
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
minHeight: metrics.hitTarget,
borderRadius: metrics.radius,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
// Carried by every item, active or not: a weight that changes on
@@ -57,16 +59,17 @@ const styles = stylex.create({
flex: 1,
},
navGroup: {
marginTop: "1rem",
marginTop: "1.125rem",
},
navGroupLabel: {
display: "block",
paddingInline: "0.75rem",
paddingBlock: "0.25rem",
fontSize: "0.75rem",
paddingBottom: "0.375rem",
fontSize: "0.6875rem",
lineHeight: "1rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
letterSpacing: "0.09em",
color: colors.textMuted,
},
/**
@@ -90,12 +93,12 @@ const styles = stylex.create({
/** The control sits with the footer, not in the scrolling nav list above it. */
sidebarFooter: {
paddingInline: "1rem",
paddingTop: "0.75rem",
paddingBlock: "0.75rem",
},
/** The current page reads as a filled chip, heavier than the hover fill. */
/** The current page is a soft accent-tinted block, in the accent's own colour. */
navActive: {
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: colors.text,
backgroundColor: colors.primarySurface,
color: colors.primaryOnSurface,
},
navIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
@@ -140,16 +143,18 @@ const styles = stylex.create({
flexDirection: { default: null, [WIDE]: "column" },
minHeight: { default: null, [WIDE]: 0 },
overflow: { default: null, [WIDE]: "hidden" },
backgroundColor: colors.surfaceRaised,
borderRightWidth: 1,
borderRightStyle: "solid",
borderRightColor: colors.border,
},
brand: {
paddingInline: "1rem",
paddingBlock: "1rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
paddingBlock: "1.25rem",
fontSize: "1rem",
lineHeight: "1.5rem",
fontWeight: 650,
letterSpacing: "-0.015em",
},
sidebarNav: {
flex: 1,
@@ -163,11 +168,19 @@ const styles = stylex.create({
minWidth: { default: null, [WIDE]: 0 },
flexDirection: "column",
},
/** Narrow only: on WIDE the sidebar carries everything this row held. */
/**
* Narrow only: on WIDE the sidebar carries everything this row held. Sticky,
* so the brand, the protection state and the menu stay in reach down a long
* page.
*/
header: {
display: { default: "flex", [WIDE]: "none" },
position: "sticky",
top: 0,
zIndex: layers.tooltip,
alignItems: "center",
gap: "0.75rem",
backgroundColor: colors.surfaceRaised,
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
@@ -178,9 +191,10 @@ const styles = stylex.create({
display: { default: null, [WIDE]: "none" },
},
narrowBrand: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
fontSize: "1rem",
lineHeight: "1.5rem",
fontWeight: 650,
letterSpacing: "-0.015em",
display: { default: null, [WIDE]: "none" },
},
headerRight: {
@@ -245,31 +259,37 @@ function NavItem({
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const health = useQuery(healthQuery());
const badge = diagnosticsBadge(health.data, health.isError);
const groupHeadingId = useId();
const monitoringId = useId();
const systemId = useId();
return (
<>
<ul {...stylex.props(styles.navList)}>
{NAV_ITEMS.map((item) => (
<NavItem
key={item.to}
to={item.to}
label={item.label}
onNavigate={onNavigate}
badge={item.to === "/diagnostics" ? badge : undefined}
/>
))}
</ul>
<div {...stylex.props(styles.navGroup)}>
<div>
{/* 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 id={monitoringId} {...stylex.props(styles.navGroupLabel)}>
Monitoring
</span>
<ul aria-labelledby={groupHeadingId} {...stylex.props(styles.navList)}>
{CONFIGURATION_ITEMS.map((item) => (
<ul aria-labelledby={monitoringId} {...stylex.props(styles.navList)}>
{MONITORING_ITEMS.map((item) => (
<NavItem key={item.to} to={item.to} label={item.label} onNavigate={onNavigate} />
))}
</ul>
</div>
<div {...stylex.props(styles.navGroup)}>
<span id={systemId} {...stylex.props(styles.navGroupLabel)}>
System
</span>
<ul aria-labelledby={systemId} {...stylex.props(styles.navList)}>
{SYSTEM_ITEMS.map((item) => (
<NavItem
key={item.to}
to={item.to}
label={item.label}
onNavigate={onNavigate}
badge={item.to === "/diagnostics" ? badge : undefined}
/>
))}
</ul>
<AuthorityLine />
</div>
</>
@@ -279,7 +299,8 @@ 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.
* with the status block and the version label instead of in the header of every
* route.
*/
function SidebarFooter() {
return (
@@ -287,6 +308,7 @@ function SidebarFooter() {
<div {...stylex.props(styles.sidebarFooter)}>
<PauseControl />
</div>
<SidebarStatus />
<VersionFooter />
</>
);
@@ -353,6 +375,7 @@ export default function AppShell() {
</Button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}>
<ProtectionDot />
<LogoutButton />
</div>
</header>
+160
View File
@@ -0,0 +1,160 @@
/**
* The compact status block at the sidebar's foot (milestone 39): whether
* filtering is in force, how fast queries are arriving, and how long the
* process has been up. Three readings the operator glances at from any page,
* each from data the shell already holds — no request of its own.
*
* Queries/min derives from the Overview body the page in front of the reader
* is drawing, so it appears only while that page is open and its window has
* landed; on every other page the row is absent rather than stale.
*/
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMatch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { formatDuration, formatRate } from "@/lib/format";
import { overviewQuery, versionQuery } from "@/lib/queries";
import { useProtection } from "@/features/pause/protection";
import { DEFAULT_PERIOD } from "@/features/overview/period";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
const styles = stylex.create({
block: {
display: "flex",
flexDirection: "column",
gap: "0.4375rem",
marginInline: "1rem",
marginBottom: "0.75rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surface,
paddingInline: "0.75rem",
paddingBlock: "0.875rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
line: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.625rem",
margin: 0,
},
key: {
display: "inline-flex",
alignItems: "center",
gap: "0.4375rem",
},
value: {
fontWeight: 550,
color: colors.text,
},
dot: {
flexShrink: 0,
width: "7px",
height: "7px",
borderRadius: "999px",
},
dotActive: { backgroundColor: colors.chartGreen },
dotPaused: { backgroundColor: colors.warnBorderStrong },
dotUnavailable: { backgroundColor: colors.chartRed },
dotUnknown: { backgroundColor: colors.borderStrong },
/** The narrow header's rendering: the dot and the state alone, inline. */
inline: {
display: "inline-flex",
alignItems: "center",
gap: "0.4375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
});
const PROTECTION_TEXT = {
active: "Active",
paused: "Paused",
unavailable: "Unavailable",
unknown: "Unknown",
} as const;
const DOT_STYLE = {
active: styles.dotActive,
paused: styles.dotPaused,
unavailable: styles.dotUnavailable,
unknown: styles.dotUnknown,
} as const;
/** The dot and the word, for the narrow header where there is room for nothing more. */
export function ProtectionDot() {
const { state } = useProtection();
return (
<span {...stylex.props(styles.inline)}>
<span aria-hidden="true" {...stylex.props(styles.dot, DOT_STYLE[state])} />
<span>Protection {PROTECTION_TEXT[state].toLowerCase()}</span>
</span>
);
}
/**
* Uptime as of now, not as of the fetch: the version answer is a minute old at
* most, so the seconds it reported are aged by the time since it landed. A
* restart shows within that minute. Ticks every second, because the first
* minute reads in seconds.
*/
function useUptimeSeconds(): number | null {
const version = useQuery(versionQuery());
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
if (version.data === undefined) return null;
return version.data.uptime_seconds + Math.max(0, now - version.dataUpdatedAt) / 1000;
}
function useQueriesPerMinute(): number | null {
const match = useMatch({ from: "/shell/overview", shouldThrow: false });
const period = match?.search.period ?? DEFAULT_PERIOD;
// Reads the body the page fetched; never fetches one of its own. A failed
// refetch leaves the last body in the cache, and the page is then showing
// its error, not that body: the row goes with it.
const overview = useQuery({ ...overviewQuery(period, match?.search.client), enabled: false });
if (match === undefined || overview.isError || overview.data === undefined) return null;
if (overview.data.period !== period) return null;
const minutes = (overview.data.until - overview.data.since) / 60;
return minutes > 0 ? overview.data.totals.queries / minutes : null;
}
export default function SidebarStatus() {
const { state } = useProtection();
const rate = useQueriesPerMinute();
const uptime = useUptimeSeconds();
return (
<dl aria-label="Status" {...stylex.props(styles.block)}>
<div {...stylex.props(styles.line)}>
<dt {...stylex.props(styles.key)}>
<span aria-hidden="true" {...stylex.props(styles.dot, DOT_STYLE[state])} />
Protection
</dt>
<dd {...stylex.props(styles.value)}>{PROTECTION_TEXT[state]}</dd>
</div>
{rate !== null && (
<div {...stylex.props(styles.line)}>
<dt>Queries/min</dt>
<dd {...stylex.props(styles.value, shared.tabularNums)}>{formatRate(rate)}</dd>
</div>
)}
<div {...stylex.props(styles.line)}>
<dt>Uptime</dt>
<dd {...stylex.props(styles.value, shared.tabularNums)}>
{uptime === null ? "—" : formatDuration(uptime)}
</dd>
</div>
</dl>
);
}
+9
View File
@@ -13,6 +13,15 @@ test("open episodes are the count, warnings and errors together", () => {
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" });
});
/** Four digits of open episodes read as one number, grouped like every other count. */
test("a four-figure count is grouped", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 1200, active_errors: 34 } }),
false,
);
expect(badge).toEqual({ text: "1,234", label: "1,234 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 } }),
+5 -1
View File
@@ -14,6 +14,7 @@
*/
import type { Health } from "@/lib/types";
import { formatCount } from "@/lib/format";
export interface NavBadge {
/** What the badge shows. Shape and text, never colour alone. */
@@ -30,7 +31,10 @@ export function diagnosticsBadge(health: Health | undefined, pollFailed: boolean
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"}` };
return {
text: formatCount(open),
label: `${formatCount(open)} active diagnostic ${open === 1 ? "event" : "events"}`,
};
}
if (health.status === "degraded") return { text: "!", label: "Health degraded" };
return null;