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
@@ -1,279 +0,0 @@
import { act, fireEvent, render, screen, waitFor } 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 { clearRefreshStatus } from "@/features/blocklists/refreshStore";
const BLOCKLISTS = {
blocklists: [
{
id: 1,
url: "https://example.com/hosts.txt",
name: "StevenBlack",
enabled: true,
is_suggested: true,
last_updated: 1700000000,
domain_count: 1000,
wildcard_count: 10,
exception_count: 7,
skipped_regex_count: 3,
skipped_unsupported_count: 21,
checksum: "abc",
},
{
id: 2,
url: "https://example.org/list.txt",
name: "Custom",
enabled: false,
is_suggested: false,
last_updated: null,
domain_count: 0,
wildcard_count: 0,
exception_count: 0,
skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null,
},
],
};
const RESPONSES: Record<string, unknown> = {
"/api/blocklists": BLOCKLISTS,
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null;
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => {
resolveUpdate = resolve;
});
}
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,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderBlocklistsRoute(queryClient = createQueryClient()) {
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
const view = render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { queryClient, unmount: view.unmount };
}
const SNAPSHOT = {
sources: [
{
id: 1,
state: "loaded",
loaded: true,
last_attempt: 1700000100,
last_success: 1700000100,
url: "https://example.com/hosts.txt",
last_error: "",
domains: 1200,
wildcards: 12,
exceptions: 9,
skipped_regex: 4,
skipped_unsupported: 17,
},
],
};
test("renders the source table and the status empty state", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
expect(screen.getByText("StevenBlack")).toBeTruthy();
expect(screen.getByText("https://example.com/hosts.txt")).toBeTruthy();
expect(screen.getByText("Suggested")).toBeTruthy();
expect(screen.getByText("1000")).toBeTruthy();
expect(screen.getByText("10")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("21")).toBeTruthy();
expect(screen.getByText("never")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
expect(enabledToggle.checked).toBe(true);
const disabledToggle = screen.getByLabelText("Custom enabled") as HTMLInputElement;
expect(disabledToggle.checked).toBe(false);
expect(screen.getByText(/run .Update now. to fetch status/)).toBeTruthy();
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});
test("update now disables the button, then replaces the status section from the 202 snapshot", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
const button = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
fireEvent.click(button);
const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement;
expect(pending.disabled).toBe(true);
expect(resolveUpdate).not.toBeNull();
const snapshot = {
sources: [
{
id: 1,
state: "loaded",
loaded: true,
last_attempt: 1700000100,
last_success: 1700000100,
url: "https://example.com/hosts.txt",
last_error: "",
domains: 1200,
wildcards: 12,
exceptions: 9,
skipped_regex: 4,
skipped_unsupported: 17,
},
{
id: 2,
state: "fetch_failed",
loaded: false,
last_attempt: 1700000100,
last_success: 0,
url: "https://example.org/list.txt",
last_error: "connect timed out",
domains: 0,
wildcards: 0,
exceptions: 0,
skipped_regex: 0,
skipped_unsupported: 0,
},
],
};
resolveUpdate!(
new Response(JSON.stringify(snapshot), { status: 202, headers: { "content-type": "application/json" } }),
);
await screen.findByText("loaded");
expect(screen.getByText("fetch_failed")).toBeTruthy();
expect(screen.getByText("connect timed out")).toBeTruthy();
expect(screen.getByText("1200")).toBeTruthy();
expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("9")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy();
expect(screen.getByText("17")).toBeTruthy();
expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2);
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
// The store notifies one flush before the mutation's success state lands.
await screen.findByText(/Update completed/);
await waitFor(() => {
const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
expect(idle.disabled).toBe(false);
});
});
test("update now shows a countdown when rate limited with Retry-After", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await screen.findByRole("button", { name: "Updating…" });
expect(resolveUpdate).not.toBeNull();
resolveUpdate!(
new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "7" },
}),
);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
});
test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { queryClient, unmount } = renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await screen.findByRole("button", { name: "Updating…" });
resolveUpdate!(
new Response(JSON.stringify(SNAPSHOT), {
status: 202,
headers: { "content-type": "application/json" },
}),
);
await screen.findByText("loaded");
unmount();
// Well past the default 5-minute gcTime: an unsubscribed cache entry is
// collected by now, which is what used to erase the snapshot.
await act(async () => {
await vi.advanceTimersByTimeAsync(6 * 60_000);
});
renderBlocklistsRoute(queryClient);
await screen.findByRole("heading", { name: "Blocklists" });
expect(await screen.findByText("loaded")).toBeTruthy();
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
} finally {
vi.useRealTimers();
}
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleted).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that source", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
@@ -1,134 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { SourceStatus } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
function formatAttempt(unixSeconds: number): string {
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
}
interface SourceStatusSectionProps {
sources: SourceStatus[] | null;
namesById: ReadonlyMap<number, string>;
}
const styles = stylex.create({
section: {
marginTop: "2rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "0.5rem",
overflowX: "auto",
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
url: {
marginTop: "0.125rem",
display: "block",
maxWidth: "16rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
/** Green has no token: a loaded source is the only success state in the app. */
loaded: {
color: {
default: "oklch(52.7% 0.154 150.069)",
"@media (prefers-color-scheme: dark)": "oklch(79.2% 0.209 151.711)",
},
},
failed: {
color: colors.danger,
},
absent: {
color: colors.textMuted,
},
});
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
return (
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Source status</h2>
{sources === null ? (
<p {...stylex.props(styles.note)}>
No status snapshot yet run Update now to fetch status for every enabled source.
</p>
) : sources.length === 0 ? (
<p {...stylex.props(styles.note)}>The last update ran against no enabled sources.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>State</th>
<th {...stylex.props(shared.th)}>Last attempt</th>
<th {...stylex.props(shared.th)}>Last success</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last error</th>
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr key={source.id}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>
{namesById.get(source.id) ?? source.url}
</span>
<span {...stylex.props(styles.url)}>{source.url}</span>
</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(source.loaded ? styles.loaded : styles.failed)}>
{source.state}
</span>
</td>
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_attempt)}</td>
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.domains}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{source.skipped_unsupported}
</td>
<td {...stylex.props(shared.td)}>
{source.last_error === "" ? (
<span {...stylex.props(styles.absent)}></span>
) : (
<span {...stylex.props(styles.failed)}>{source.last_error}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
@@ -1,31 +0,0 @@
import { useSyncExternalStore } from "react";
import type { SourceStatus } from "@/lib/types";
// Client UI state, not server state: the snapshot exists only as the 202 body of
// POST /api/blocklists/update and no GET can refetch it. Held here so it outlives
// the query cache's gcTime instead of vanishing from an unsubscribed cache entry.
let snapshot: SourceStatus[] | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): SourceStatus[] | null {
return snapshot;
}
export function setRefreshStatus(sources: SourceStatus[]): void {
snapshot = sources;
for (const listener of listeners) listener();
}
export function clearRefreshStatus(): void {
snapshot = null;
for (const listener of listeners) listener();
}
export function useRefreshStatus(): SourceStatus[] | null {
return useSyncExternalStore(subscribe, getSnapshot);
}
@@ -0,0 +1,107 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { BASE, MANAGED_FILE, NEVER, renderAt } from "./testFixtures";
afterEach(() => {
vi.unstubAllGlobals();
});
test("a cold deep link fetches the list once and renders the client (D9)", async () => {
const { fetchMock } = renderAt("/clients/1", BASE);
await screen.findByRole("heading", { name: "laptop" });
expect(screen.getByText("192.168.1.10")).toBeTruthy();
const listCalls = fetchMock.mock.calls.filter(([input]) => String(input) === "/api/clients");
expect(listCalls).toHaveLength(1);
});
test("identity carries the name, its provenance and both timestamps", async () => {
renderAt("/clients/2", BASE);
// The unnamed row is the learned one, and Clients is where the marker shows.
const heading = await screen.findByRole("heading", { name: /kids-tablet\.lan/ });
expect(within(heading).getByText("learned")).toBeTruthy();
expect(screen.getByText(/This client appeared from DNS traffic/)).toBeTruthy();
expect(screen.getByText("First seen")).toBeTruthy();
expect(screen.getByText("Last seen")).toBeTruthy();
});
test("a hand-set row reads as edited under database authority", async () => {
renderAt("/clients/1", BASE);
await waitFor(() => expect(screen.getByText(/The name and group were set here/)).toBeTruthy());
});
test("the same row reads as declared under file authority", async () => {
renderAt("/clients/1", { ...BASE, "GET /api/config/status": MANAGED_FILE });
await screen.findByText(/Declared in \/etc\/nxdns\/config\.zon/);
});
test("an unresolved authority names the doubt instead of picking a provenance", async () => {
renderAt("/clients/1", { ...BASE, "GET /api/config/status": NEVER });
await screen.findByText(/Either declared in the configuration file or edited here/);
});
test("an id the list does not contain renders the missing-client state (D9)", async () => {
renderAt("/clients/99", BASE);
await screen.findByRole("heading", { name: "No such client" });
expect(screen.getByText(/no client with id 99/i)).toBeTruthy();
expect(screen.getByRole("link", { name: "← All clients" })).toBeTruthy();
});
test("policy links to the group that filters this client", async () => {
renderAt("/clients/2", BASE);
const link = await screen.findByRole("link", { name: "Group settings" });
expect(link.getAttribute("href")).toBe("/configuration/protection?tab=groups&group=2");
});
test("the Activity affordance is a real link, with bounds minted at interaction time", async () => {
const { router } = renderAt("/clients/1", BASE);
const link = await screen.findByRole("link", { name: /last 24 hours/i });
// A real <a href> is what makes middle-click, copy-link and open-in-new-tab
// work; the bounds must be in that href before the click, not minted by a
// handler the browser never runs on those gestures.
expect(link.tagName).toBe("A");
expect(link.getAttribute("href")).toMatch(/^\/activity\?/);
const before = Math.floor(Date.now() / 1000);
fireEvent.pointerDown(link);
fireEvent.click(link);
const after = Math.floor(Date.now() / 1000);
await waitFor(() => expect(router.state.location.pathname).toBe("/activity"));
const search = router.state.location.search as { mode: string; client: string; since: number; until: number };
expect(search.mode).toBe("history");
expect(search.client).toBe("192.168.1.10");
expect(search.until).toBeGreaterThanOrEqual(before - 1);
expect(search.until).toBeLessThanOrEqual(after + 1);
expect(search.until - search.since).toBe(24 * 60 * 60);
});
test("keyboard activation re-mints the bounds, not the focus that preceded it by hours", async () => {
const { router } = renderAt("/clients/1", BASE);
const link = await screen.findByRole("link", { name: /last 24 hours/i });
// Focus can be hours old by the time Enter lands, so the window it minted is
// stale; the keypress itself is what must be bracketed.
fireEvent.focus(link);
const pressedAt = Date.now() + 3 * 60 * 60 * 1000;
const clock = vi.spyOn(Date, "now").mockReturnValue(pressedAt);
try {
fireEvent.keyDown(link, { key: "Enter" });
// jsdom does not synthesize the click Enter produces on a real anchor.
fireEvent.click(link);
} finally {
clock.mockRestore();
}
await waitFor(() => expect(router.state.location.pathname).toBe("/activity"));
const search = router.state.location.search as { client: string; since: number; until: number };
expect(search.client).toBe("192.168.1.10");
expect(search.until).toBe(Math.floor(pressedAt / 1000));
expect(search.until - search.since).toBe(24 * 60 * 60);
});
@@ -0,0 +1,225 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { formatTime } from "@/lib/format";
import { clientsQuery } from "@/lib/queries";
import { useAuthority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ClientDisplayName, provenanceOf } from "./clientIdentity";
/** The Activity window this page offers: the last day, in seconds. */
const ACTIVITY_WINDOW_SECONDS = 24 * 60 * 60;
const nowInSeconds = () => Math.floor(Date.now() / 1000);
const styles = stylex.create({
back: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
address: {
marginTop: "0.25rem",
color: colors.textSecondary,
},
panel: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
},
facts: {
display: "grid",
gap: "0.5rem 1rem",
gridTemplateColumns: {
default: "auto",
"@media (min-width: 640px)": "max-content 1fr",
},
margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
term: {
color: colors.textMuted,
},
value: {
margin: 0,
},
provenanceDetail: {
marginTop: "0.125rem",
color: colors.textMuted,
},
sectionHeading: {
marginTop: "1.5rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
prose: {
marginTop: "0.5rem",
maxWidth: "48rem",
fontSize: "0.875rem",
lineHeight: "1.5rem",
},
links: {
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
link: {
color: colors.primaryOnSurface,
},
loading: {
marginTop: "1rem",
color: colors.textMuted,
},
});
function BackLink() {
return (
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
All clients
</Link>
);
}
/**
* One client, addressed by the row id `PUT /api/clients/{id}` already uses.
*
* No endpoint answers for a single client, so the list is the source: the route
* loader ensures it, which makes a cold deep link one fetch rather than a
* miss. An id the loaded list does not contain is a row that was deleted or
* never existed — a state this page explains once, with no refetch behind it.
*/
export default function ClientDetailPage() {
const { id } = useParams({ from: "/shell/clients/$id" });
const clientId = Number(id);
const { data, error, isPending, refetch } = useQuery(clientsQuery());
const authority = useAuthority();
// The Activity window is relative to now, and a real link must carry its
// bounds in the href before the click — that is what makes middle-click,
// copy-link and open-in-new-tab work. A page left open would otherwise link
// to yesterday's day, so the window is re-minted when the link is about to be
// used: pointerdown precedes the click, and keydown precedes the click Enter
// synthesizes. Focus re-mints too, but it cannot be the last word — a link can
// hold focus for hours before the keypress. All three are discrete events, so
// the href is fresh by the time navigation reads it.
const [activityUntil, setActivityUntil] = useState(nowInSeconds);
const freshenActivityWindow = () => setActivityUntil(nowInSeconds());
if (isPending) {
return (
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
Loading client
</p>
);
}
if (data === undefined) {
return (
<section>
<BackLink />
<InlineError error={error} onRetry={() => void refetch()} />
</section>
);
}
const client = data.find((row) => row.id === clientId);
if (client === undefined) {
return (
<section>
<BackLink />
<h1 {...stylex.props(styles.heading)}>No such client</h1>
<p {...stylex.props(styles.prose)}>
nxdns has no client with id {id}. It was deleted, or the link was to a row that never existed. A
device that is still on the network reappears in the list on its next DNS query.
</p>
</section>
);
}
const provenance = provenanceOf(client, authority);
return (
<section>
<BackLink />
<h1 {...stylex.props(styles.heading)}>
<ClientDisplayName client={client} />
</h1>
<p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p>
<div {...stylex.props(styles.panel)}>
<dl {...stylex.props(styles.facts)}>
<dt {...stylex.props(styles.term)}>Name</dt>
<dd {...stylex.props(styles.value)}>
<ClientDisplayName client={client} />
</dd>
<dt {...stylex.props(styles.term)}>Provenance</dt>
<dd {...stylex.props(styles.value)}>
{provenance.label}
<span {...stylex.props(styles.provenanceDetail)}> {provenance.detail}</span>
</dd>
<dt {...stylex.props(styles.term)}>First seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(client.first_seen)}</dd>
<dt {...stylex.props(styles.term)}>Last seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd>
</dl>
</div>
<h2 {...stylex.props(styles.sectionHeading)}>Policy</h2>
<p {...stylex.props(styles.prose)}>
Filtering for this client follows the <strong>{client.group}</strong> group: its safe search setting,
its blocklist sources and its rules.
</p>
<p {...stylex.props(styles.links)}>
<Link
to="/configuration/protection"
search={{ tab: "groups", group: client.group_id }}
{...stylex.props(styles.link, shared.focusRing)}
>
Group settings
</Link>
</p>
<h2 {...stylex.props(styles.sectionHeading)}>Activity</h2>
<p {...stylex.props(styles.links)}>
<Link
to="/activity"
search={{
mode: "history",
client: client.ip,
since: activityUntil - ACTIVITY_WINDOW_SECONDS,
until: activityUntil,
domain: undefined,
blocked: undefined,
}}
onPointerDown={freshenActivityWindow}
onFocus={freshenActivityWindow}
onKeyDown={(event) => {
if (event.key === "Enter") freshenActivityWindow();
}}
{...stylex.props(styles.link, shared.focusRing)}
>
Queries from this client, last 24 hours
</Link>
</p>
</section>
);
}
+36 -14
View File
@@ -4,10 +4,12 @@ import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
import { useReadOnlyConfig } from "@/features/configuration/authority";
import Dialog from "@/ui/Dialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
import { colors } from "@/ui/tokens.stylex";
interface Props {
client: Client;
@@ -39,20 +41,30 @@ const styles = stylex.create({
},
actions: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
lockNote: {
marginRight: "auto",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientUpdateMutation(queryClient));
const readOnly = useReadOnlyConfig();
// Adopting the learned name as a typed one is the natural gesture, but only
// where the save can land: under file authority the PUT answers 403, and the
// file's declared name is the one that wins.
const [name, setName] = useState(client.name === "" && !readOnly ? client.learned_name : client.name);
// Adopting the learned name as a typed one is the natural gesture.
const [name, setName] = useState(client.name === "" ? client.learned_name : client.name);
const [groupId, setGroupId] = useState(client.group_id);
// The dialog opens only where the save can land, but authority is polled and
// can turn under an open dialog. So the save path consults it on every render
// rather than trusting the state that was true when the dialog opened; the
// draft stays on screen, locked, instead of vanishing mid-edit.
const readOnly = useReadOnlyConfig();
return (
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
@@ -61,6 +73,9 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
{...stylex.props(styles.form)}
onSubmit={(event) => {
event.preventDefault();
// Enter in the name field submits without the Save button, so
// the lock lives here too, not only in what is rendered.
if (readOnly) return;
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
@@ -86,17 +101,24 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
/>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
{readOnly && (
<span {...stylex.props(styles.lockNote)}>
This edit can no longer be saved.
<ConfigLockIndicator />
</span>
)}
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save
</button>
{!readOnly && (
<button
type="submit"
disabled={mutation.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save
</button>
)}
</div>
</form>
</Dialog>
+218 -97
View File
@@ -1,121 +1,78 @@
import { fireEvent, render, screen, 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 { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ClientName, type ClientNames } from "./clientNames";
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "kids-tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr");
if (row === null) throw new Error(`no client row for ${ip}`);
return row;
}
async function renderClientsPage(map: Record<string, unknown>) {
stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Clients" });
function assignmentsSection(): HTMLElement {
const heading = screen.getByRole("heading", { name: /Network assignments/ });
const section = heading.closest("section");
if (section === null) throw new Error("no network assignments section");
return section;
}
const BASE = {
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
"GET /api/groups": GROUPS,
"GET /api/version": VERSION,
};
/**
* Editing a client writes configuration, so the affordance is absent until the
* status query says the database owns it — never disabled, never assumed.
*/
async function unlockedEdit(index: number): Promise<HTMLButtonElement> {
const buttons = await screen.findAllByRole("button", { name: "Edit" });
return buttons[index] as HTMLButtonElement;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("renders the client table with group names and one hand-edited badge", async () => {
await renderClientsPage(BASE);
test("renders the identity-first table: address, name, group, first and last seen", async () => {
await renderClientsPage();
const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]);
expect(screen.getByText("192.168.1.10")).toBeTruthy();
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.getAllByText("edited")).toHaveLength(1);
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
});
test("a named row shows the typed name and hides the learned one", async () => {
await renderClientsPage(BASE);
await renderClientsPage();
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.queryByText("laptop-1.lan")).toBeNull();
});
test("an unnamed row shows the learned name with the learned affordance", async () => {
await renderClientsPage(BASE);
test("an unnamed row shows the learned name with the learned marker", async () => {
await renderClientsPage();
// The cell holds the learned name followed by the tag, so the match is on
// The cell holds the learned name followed by the marker, so the match is on
// the containing span rather than on a bare text node.
const learned = screen.getByText(
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
);
// The affordance is text, not colour, so a screen reader announces it too.
// The marker is text, not colour, so a screen reader announces it too.
expect(within(learned).getByText("learned")).toBeTruthy();
});
test("the learned marker is a Clients-page affordance: query tables render the same name without it", () => {
const names: ClientNames = new Map([["192.168.1.11", { name: "", learned_name: "kids-tablet.lan" }]]);
render(<ClientName ip="192.168.1.11" names={names} />);
expect(screen.getByText("kids-tablet.lan")).toBeTruthy();
expect(screen.queryByText("learned")).toBeNull();
});
test("the address links to the client's detail page", async () => {
await renderClientsPage();
const link = within(clientRow("192.168.1.10")).getByRole("link", { name: "192.168.1.10" });
expect(link.getAttribute("href")).toBe("/clients/1");
});
test("shows the DNS-activity empty state when there are no clients", async () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
@@ -124,9 +81,9 @@ test("shows the DNS-activity empty state when there are no clients", async () =>
});
test("edit opens a dialog seeded with the client's name and group", async () => {
await renderClientsPage(BASE);
await renderClientsPage();
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
fireEvent.click(await unlockedEdit(0));
// The dialog portals out of the table, so every field query is scoped to it.
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
@@ -135,9 +92,9 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
await renderClientsPage();
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
fireEvent.click(await unlockedEdit(0));
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
@@ -148,14 +105,178 @@ test("the group picker offers every group and reports the choice", async () => {
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
});
test("prefix editor starts clean and dirties on add", async () => {
await renderClientsPage(BASE);
test("network assignments start clean and dirty on add", async () => {
await renderClientsPage();
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
expect(range.value).toBe("192.168.1.0/24");
const save = screen.getByRole("button", { name: "Save assignments" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Add prefix" }));
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
expect(save.disabled).toBe(false);
expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe("");
expect((screen.getByLabelText("Range 2") as HTMLInputElement).value).toBe("");
});
test("?group= filters the list to that group", async () => {
const { router } = await renderClientsPage();
await router.navigate({ to: "/clients", search: { group: 2 } });
await waitFor(() => expect(screen.queryByText("192.168.1.10")).toBeNull());
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("Showing clients in kids.")).toBeTruthy();
});
test("an unknown group id filters to nothing and offers a way out", async () => {
const { router } = await renderClientsPage();
await router.navigate({ to: "/clients", search: { group: 999 } });
await screen.findByText("No group with id 999 exists.");
expect(screen.getByText("No clients match this filter.")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "Clear filter" }));
await waitFor(() => expect(screen.getByText("192.168.1.10")).toBeTruthy());
expect(router.state.location.search).toEqual({});
});
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
const declared = clientRow("192.168.1.10");
const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
const section = await waitFor(() => {
const found = assignmentsSection();
if (found.querySelector("table") === null) throw new Error("still editing");
return found;
});
expect(within(section).getByText("192.168.1.0/24")).toBeTruthy();
expect(
section.querySelectorAll(
"input, textarea, select, [role='combobox'], [role='checkbox'], [contenteditable], button",
),
).toHaveLength(0);
});
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
await screen.findAllByLabelText(/Configuration status unavailable/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
expect(within(assignmentsSection()).getByText(/Configuration status unavailable/)).toBeTruthy();
const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
const declared = clientRow("192.168.1.10");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
});
// The status query polls, so authority can turn while a dialog or a delete
// confirmation is already open. `undefined` is the failed status: the fetch stub
// answers 404 for a key it does not hold.
//
// Both statuses lock the declared delete, but only file authority proves the
// file declares the row; the anchors keep the two sentences apart.
const DECLARED_NOTE = /^This client is declared in the configuration file/;
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
describe.each([
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
])("authority turning to %s under an open affordance", (_label, status, lockNote, otherNote) => {
test("locks the open edit dialog's save path and leaves cancel working", async () => {
const map = { ...BASE };
const { queryClient } = await renderClientsPage(map);
fireEvent.click(await unlockedEdit(0));
expect(
within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })).getByRole("button", {
name: "Save",
}),
).toBeTruthy();
await setConfigStatus(map, queryClient, status);
// The draft survives; only the save path goes, and it says why.
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
const map = { ...BASE };
const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to
// database; the transition under test starts from there.
await unlockedEdit(0);
const declared = clientRow("192.168.1.10");
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
await setConfigStatus(map, queryClient, status);
const confirming = clientRow("192.168.1.10");
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
expect(within(confirming).getByText(lockNote)).toBeTruthy();
expect(within(confirming).queryByText(otherNote)).toBeNull();
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
const map = { ...BASE, "DELETE /api/clients/2": {} };
const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to
// database; the transition under test starts from there.
await unlockedEdit(0);
const observed = clientRow("192.168.1.11");
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
await setConfigStatus(map, queryClient, status);
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2"),
),
).toHaveLength(1),
);
});
});
test("a pending config status holds the same line as a failed one (R3-4)", async () => {
// The status request never settles, so authority stays pending for the whole
// test: nothing configuration owns may be offered on that guess.
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
await screen.findAllByLabelText(/Checking which configuration source/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
expect(within(assignmentsSection()).getByText(/Checking which configuration source/)).toBeTruthy();
const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});
+105 -67
View File
@@ -1,22 +1,35 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { Link, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
import { formatTime } from "@/lib/format";
import type { Client } from "@/lib/types";
import type { Client, Group } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import NetworkAssignments from "./NetworkAssignments";
import { ClientDisplayName } from "./clientIdentity";
import InlineError from "@/lib/InlineError";
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
* the file and is the one client DELETE the server answers 403 (ruling 7).
* it stays live under every authority; deleting a declared row contradicts the
* file and is the one client DELETE the server answers 403 (ruling 7).
*
* The lock fails closed, so it also holds while authority is pending or
* unreachable. There the file is a possibility and not a fact — `hand_edited`
* alone cannot say which operator surface set the row — so the sentence names
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
function declaredDeleteNote(authority: Authority): string {
if (authority.state === "resolved") {
return "This client is declared in the configuration file; remove it there and restart.";
}
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
}
const styles = stylex.create({
heading: {
@@ -28,6 +41,15 @@ const styles = stylex.create({
marginTop: "1rem",
color: colors.textMuted,
},
filterBar: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
table: {
width: "100%",
minWidth: "48rem",
@@ -53,19 +75,9 @@ const styles = stylex.create({
right: {
textAlign: "right",
},
dash: {
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.primary,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
color: colors.primaryText,
addressLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
confirmGroup: {
display: "inline-flex",
@@ -76,6 +88,7 @@ const styles = stylex.create({
},
actionGroup: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
},
note: {
@@ -89,36 +102,54 @@ const styles = stylex.create({
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
/**
* The accessible name of the actions column, kept out of the visual table
* without leaving the accessibility tree.
*/
});
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
const { data: groups } = useSuspenseQuery(groupsQuery());
const { group } = useSearch({ from: "/shell/clients" });
const queryClient = useQueryClient();
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const readOnly = useReadOnlyConfig();
const authority = useAuthority();
// A well-formed id the group list does not contain is a link to a group that
// has since been deleted. It filters to nothing, which is the truth, and the
// notice says so rather than quietly showing every client.
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
return (
<section>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
{group !== undefined && (
<div {...stylex.props(styles.filterBar)}>
<span>
{filterGroup !== undefined
? `Showing clients in ${filterGroup.name}.`
: `No group with id ${group} exists.`}
</span>
<Link to="/clients" search={{}} {...stylex.props(shared.linkButton, shared.focusRing)}>
Clear filter
</Link>
</div>
)}
{clients.length === 0 ? (
<p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries there is
nothing to create by hand.
</p>
) : rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>No clients match this filter.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th {...stylex.props(styles.cell)}>IP</th>
<th {...stylex.props(styles.cell)}>Address</th>
<th {...stylex.props(styles.cell)}>Name</th>
<th {...stylex.props(styles.cell)}>Group</th>
<th {...stylex.props(styles.cell)}>First seen</th>
@@ -129,21 +160,19 @@ export default function ClientsPage() {
</tr>
</thead>
<tbody>
{clients.map((client) => (
{rows.map((client) => (
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell, shared.mono)}>
<Link
to="/clients/$id"
params={{ id: String(client.id) }}
{...stylex.props(styles.addressLink, shared.focusRing)}
>
{client.ip}
</Link>
</td>
<td {...stylex.props(styles.cell)}>
{client.name !== "" ? (
client.name
) : client.learned_name !== "" ? (
<span {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span {...stylex.props(shared.learnedTag)}>learned</span>
</span>
) : (
<span {...stylex.props(styles.dash)}></span>
)}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
<ClientDisplayName client={client} />
</td>
<td {...stylex.props(styles.cell)}>{client.group}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
@@ -151,23 +180,32 @@ export default function ClientsPage() {
<td {...stylex.props(styles.cell, styles.right)}>
{confirmingId === client.id ? (
<span {...stylex.props(styles.confirmGroup)}>
{/* Authority is polled, so it can turn while a confirmation
sits open. The confirm path reads it on every render
rather than trusting the state that opened it. */}
<span {...stylex.props(styles.note)}>
Deleted clients re-materialize on their next DNS query.
{readOnly && client.hand_edited
? declaredDeleteNote(authority)
: "Deleted clients re-materialize on their next DNS query."}
</span>
<button
type="button"
onClick={() => {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
{readOnly && client.hand_edited ? (
<ConfigLockIndicator />
) : (
<button
type="button"
onClick={() => {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
)}
<button
type="button"
onClick={() => setConfirmingId(null)}
@@ -178,26 +216,26 @@ export default function ClientsPage() {
</span>
) : (
<span {...stylex.props(styles.actionGroup)}>
<button
type="button"
onClick={() => setEditing(client)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
{/* Naming a client writes configuration, so the affordance is
absent — not disabled — wherever the write cannot land. */}
{readOnly ? (
<ConfigLockIndicator />
) : (
<button
type="button"
onClick={() => setEditing(client)}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Edit
</button>
)}
<button
type="button"
onClick={() => setConfirmingId(client.id)}
disabled={readOnly && client.hand_edited}
title={
readOnly && client.hand_edited
? DECLARED_CLIENT_NOTE
? declaredDeleteNote(authority)
: undefined
}
{...stylex.props(
@@ -220,7 +258,7 @@ export default function ClientsPage() {
)}
<InlineError error={deleteMutation.error} />
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
<PrefixesEditor prefixes={prefixes} groups={groups} />
<NetworkAssignments prefixes={prefixes} groups={groups} />
</section>
);
}
@@ -6,10 +6,10 @@ import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import AuthorityGate from "@/features/configuration/AuthorityGate";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
prefixes: ClientPrefix[];
@@ -25,6 +25,13 @@ const styles = stylex.create({
lineHeight: "1.75rem",
fontWeight: 600,
},
headingKey: {
marginLeft: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
@@ -80,16 +87,84 @@ const styles = stylex.create({
gap: "0.5rem",
marginTop: "1rem",
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export default function PrefixesEditor({ prefixes, groups }: Props) {
/**
* Address ranges that assign a group to every device inside them.
*
* The section is a configuration rendering, so it goes through the authority
* gate: the editor exists only where a save can land, and file authority gets
* the assignments as a table rather than a form nobody may submit.
*/
export default function NetworkAssignments({ prefixes, groups }: Props) {
return (
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>
Network assignments
<code {...stylex.props(shared.mono, styles.headingKey)}>client_prefixes</code>
</h2>
<p {...stylex.props(styles.intro)}>
An address range assigns its group to every device inside it, for devices with no row of their own. The
highest priority match wins.
</p>
<AuthorityGate>
{(status) =>
status.authority === "database" ? (
<AssignmentsEditor prefixes={prefixes} groups={groups} />
) : (
<AssignmentsTable prefixes={prefixes} />
)
}
</AuthorityGate>
</section>
);
}
function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
if (prefixes.length === 0) return <p {...stylex.props(styles.empty)}>The file declares no network assignments.</p>;
return (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Range</th>
<th {...stylex.props(shared.th)}>Group</th>
<th {...stylex.props(shared.th)}>Priority</th>
</tr>
</thead>
<tbody>
{prefixes.map((prefix) => (
<tr key={prefix.id}>
<td {...stylex.props(shared.td, shared.mono)}>{prefix.prefix}</td>
<td {...stylex.props(shared.td)}>{prefix.group}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{prefix.priority}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* The list is saved as a whole, so the editor holds every row and the PUT
* replaces the set. Reached only under resolved database authority: the gate
* above owns that decision, and no control here consults it a second time.
*/
function AssignmentsEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
const save = () => {
@@ -102,21 +177,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
};
return (
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
<p {...stylex.props(styles.intro)}>
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
match wins.
</p>
<>
{state.rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
<p {...stylex.props(styles.empty)}>No network assignments configured.</p>
) : (
<ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => (
<li key={index} {...stylex.props(styles.row)}>
<input
type="text"
aria-label={`Prefix ${index + 1}`}
aria-label={`Range ${index + 1}`}
placeholder="192.168.1.0/24"
value={row.prefix}
onChange={(event) =>
@@ -125,7 +195,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/>
<Select
aria-label={`Group for prefix ${index + 1}`}
aria-label={`Group for range ${index + 1}`}
variant="inline"
value={String(row.group_id)}
onChange={(value) =>
@@ -136,7 +206,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<input
type="text"
inputMode="numeric"
aria-label={`Priority for prefix ${index + 1}`}
aria-label={`Priority for range ${index + 1}`}
placeholder="100"
value={row.priority}
onChange={(event) =>
@@ -167,16 +237,15 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
{...stylex.props(shared.button, shared.focusRing)}
>
Add prefix
Add range
</button>
<button
type="button"
onClick={save}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
disabled={!dirty || mutation.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save prefixes
Save assignments
</button>
{dirty && (
<button
@@ -191,6 +260,6 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</button>
)}
</div>
</section>
</>
);
}
@@ -0,0 +1,72 @@
/**
* How a client says who it is, shared by the list and the detail page.
*
* The learned marker lives here and nowhere else. A client is named once on
* each of these two pages, so the tag is information; the query tables render
* the same muted name through `ClientName` without it, because repeating the
* tag down every row of a log is noise.
*/
import * as stylex from "@stylexjs/stylex";
import type { Client } from "@/lib/types";
import type { Authority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
unnamed: {
color: colors.textMuted,
},
});
export function ClientDisplayName({ client }: { client: Client }) {
if (client.name !== "") return <>{client.name}</>;
if (client.learned_name !== "") {
return (
<span {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span {...stylex.props(shared.learnedTag)}>learned</span>
</span>
);
}
return <span {...stylex.props(styles.unnamed)}></span>;
}
export interface Provenance {
label: string;
detail: string;
}
/**
* Where this row's name and group came from.
*
* `hand_edited` records that an operator settled this client, but not which
* operator surface did: under file authority the reconciler sets it from the
* declaration, and in database mode the admin's own edit does. Authority is the
* only way to tell them apart, so while it is pending or unreachable the page
* says what it knows and names the doubt rather than picking one.
*/
export function provenanceOf(client: Client, authority: Authority): Provenance {
if (!client.hand_edited) {
return {
label: "Learned",
detail: "This client appeared from DNS traffic. Its name, if any, comes from reverse DNS.",
};
}
if (authority.state === "resolved" && authority.status.authority === "managed_file") {
return {
label: "Declared",
detail: `Declared in ${authority.status.path ?? "the configuration file"}. Reverse DNS does not overwrite it.`,
};
}
if (authority.state === "resolved") {
return {
label: "Edited",
detail: "The name and group were set here. Reverse DNS does not overwrite them.",
};
}
return {
label: "Set by hand",
detail: "Either declared in the configuration file or edited here — nxdns cannot say which until it reports its configuration status.",
};
}
+129
View File
@@ -0,0 +1,129 @@
/**
* The Clients pages under a real router: both of them read the same list, and
* the detail route is reached by deep link as often as by click, so the tests
* drive the router rather than the components.
*/
import { act, render, screen } from "@testing-library/react";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import { createAppRouter } from "@/routes";
export const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
export const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "kids-tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
export const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
export const DATABASE = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
export const MANAGED_FILE = {
authority: "managed_file",
path: "/etc/nxdns/config.zon",
reconciled_at: 1754899200,
restart_pending: false,
};
export const BASE: Record<string, unknown> = {
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
"GET /api/groups": GROUPS,
"GET /api/version": VERSION,
"GET /api/config/status": DATABASE,
};
/** A request that never settles, so its query stays pending for the whole test. */
export const NEVER = Symbol("never");
export function stubFetch(map: Record<string, unknown>): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === NEVER) return new Promise<Response>(() => {});
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
export function renderAt(path: string, map: Record<string, unknown>) {
const fetchMock = stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { router, fetchMock, queryClient };
}
export async function renderClientsPage(map: Record<string, unknown> = BASE) {
const handles = renderAt("/clients", map);
await screen.findByRole("heading", { name: "Clients" });
return handles;
}
/**
* Turns configuration authority under a mounted page, the way the status
* query's own poll would: the stub reads the map on each call, so replacing the
* entry and refetching is the whole transition. `undefined` stands for a failed
* status — the stub answers 404 for a key it does not hold.
*/
export async function setConfigStatus(
map: Record<string, unknown>,
queryClient: QueryClient,
status: unknown,
): Promise<void> {
map["GET /api/config/status"] = status;
await act(async () => {
await queryClient.refetchQueries({ queryKey: queryKeys.configStatus });
// The cache lands before its observers are notified — react-query defers
// that notification — so the render pass needs one more turn inside act.
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import type { ConfigStatus } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { useAuthority } from "./authority";
import { styles } from "./styles";
/**
* Guards a configuration rendering on resolved authority (D6).
*
* The two renderings — editable forms and file-mode definition lists — are
* mutually exclusive answers to a question only the server can settle, so
* neither is drawn until it has. Pending is a skeleton; a failed status query
* is an error with a Retry, never a form drawn on a guess.
*
* Runtime actions do not pass through here. Pause, update now and reload
* certificates work under every authority, so their pages render them beside
* the gate rather than inside it.
*/
export default function AuthorityGate({ children }: { children: (status: ConfigStatus) => ReactNode }) {
const authority = useAuthority();
if (authority.state === "pending") {
return (
<p role="status" {...stylex.props(styles.pending, shared.pulse)}>
Checking which configuration source this server obeys
</p>
);
}
if (authority.state === "failed") {
return (
<div {...stylex.props(styles.blocked)}>
<p>
Configuration status unavailable. nxdns cannot say whether a file or the database owns this
configuration, so nothing here can be edited until it answers.
</p>
<InlineError error={authority.error} onRetry={authority.retry} />
</div>
);
}
return <>{children(authority.status)}</>;
}
@@ -0,0 +1,40 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority";
const styles = stylex.create({
line: {
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
path: {
overflowWrap: "anywhere",
},
});
/**
* File authority, stated once, in the configuration sub-navigation (§File
* mode). Not a banner on every route: the fact belongs to configuration, and
* repeating it above Overview and Activity buys nothing.
*
* "loaded" is deliberate. `reconciled_at` is when this process read the file;
* the server cannot prove the file still says what it said then, so the line
* never claims to describe the file's current contents.
*/
export default function AuthorityLine() {
const authority = useAuthority();
if (authority.state !== "resolved" || authority.status.authority !== "managed_file") return null;
const { path, reconciled_at } = authority.status;
return (
<p {...stylex.props(styles.line)}>
{"File-managed · "}
<code {...stylex.props(shared.mono, styles.path)}>{path}</code>
{reconciled_at === null ? null : ` · loaded ${formatTime(reconciled_at)}`}
</p>
);
}
@@ -10,9 +10,7 @@ test("swallowMutationError drops an ApiError and rethrows anything else", () =>
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
const { rerender } = render(
<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
);
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
const url = screen.getByLabelText("URL") as HTMLInputElement;
const name = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
@@ -24,7 +22,7 @@ test("a rejected submit leaves the typed values in place; a resolved one clears
expect(name.value).toBe("Example");
const resolving = vi.fn(() => Promise.resolve());
rerender(<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={resolving} onCancel={undefined} />);
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
await waitFor(() => expect(url.value).toBe(""));
expect(name.value).toBe("");
@@ -4,7 +4,6 @@ import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const styles = stylex.create({
form: {
@@ -56,14 +55,12 @@ export function swallowMutationError(error: unknown): void {
interface BlocklistFormProps {
initial?: Blocklist;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: BlocklistInput) => Promise<void>;
onCancel?: () => void;
}
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -122,12 +119,7 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
Enabled
</label>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
<button type="submit" disabled={busy} {...stylex.props(shared.primaryButton, shared.focusRing)}>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
@@ -0,0 +1,77 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import DefinitionList from "@/ui/DefinitionList";
import ConfigLockIndicator from "./ConfigLockIndicator";
import { CONFIG_PATH, DATABASE, MANAGED_FILE, stubApi } from "./testFixtures";
import type { ConfigStatus } from "@/lib/types";
/**
* The two shared pieces the redesign adds: the file-mode rendering of a scalar
* and the compact lock a configuration control outside a configuration page
* carries.
*/
afterEach(() => {
vi.unstubAllGlobals();
});
function renderIndicator(status: ConfigStatus | "failed") {
if (status === "failed") {
stubApi(DATABASE, {
responses: {
"GET /api/config/status": new Response(JSON.stringify({ error: "unavailable" }), { status: 503 }),
},
});
} else {
stubApi(status);
}
render(
<QueryClientProvider client={createQueryClient()}>
<ConfigLockIndicator />
</QueryClientProvider>,
);
}
test("a definition names the value in words and the file key beside it", () => {
render(
<DefinitionList
items={[
{ label: "Port", zonKey: "dns.port", value: "53" },
{ label: "Authentication", value: "required" },
]}
/>,
);
const port = screen.getByText("Port");
expect(port.tagName).toBe("DT");
expect(port.textContent).toBe("Portdns.port");
// A derived value has no key: inventing one would send the reader looking
// for a line that is not in the file.
expect(screen.getByText("Authentication").textContent).toBe("Authentication");
expect(screen.getByText("required").tagName).toBe("DD");
});
test("the lock is silent when the database owns the configuration", async () => {
renderIndicator(DATABASE);
await waitFor(() => expect(screen.queryByText("Locked")).toBeNull());
});
test("under file authority the lock names the file, in words a reader hears", async () => {
renderIndicator(MANAGED_FILE);
const lock = await screen.findByText("Locked");
await waitFor(() =>
expect(lock.getAttribute("aria-label")).toBe(
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
),
);
});
test("an unanswered status still locks, and says that is why", async () => {
renderIndicator("failed");
const lock = await screen.findByText("Locked");
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
});
@@ -0,0 +1,45 @@
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority";
const styles = stylex.create({
tag: {
marginLeft: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
borderRadius: "0.25rem",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
/**
* The compact lock, for a configuration control that survives outside a
* configuration page — the Clients page edits a group assignment the file may
* own. The configuration pages themselves do not use it: they change rendering
* rather than annotate a control they left up.
*
* The word is real text, not colour or an icon, so a screen reader announces
* the reason the control will not answer.
*/
export default function ConfigLockIndicator() {
const authority = useAuthority();
if (authority.state === "resolved" && authority.status.authority === "database") return null;
const reason =
authority.state === "pending"
? "Checking which configuration source this server obeys"
: authority.state === "failed"
? "Configuration status unavailable, so edits are held back"
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
return (
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
Locked
</span>
);
}
@@ -0,0 +1,17 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { styles } from "./styles";
/**
* The short note every file-managed page carries: where the values come from,
* and what applies a change to them. It replaces the disabled Save button —
* the reader needs the path, not a control that cannot work.
*/
export default function FileModeNote({ path }: { path: string | null }) {
return (
<p {...stylex.props(styles.fileNote)}>
These values are loaded from <code {...stylex.props(shared.mono)}>{path ?? "the configuration file"}</code>.
Edit that file to change them; most changes need an nxdns restart to take effect.
</p>
);
}
@@ -7,7 +7,6 @@ import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
groupId: number;
@@ -48,7 +47,6 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const sources = useQuery(groupSourcesQuery(groupId));
const mutation = useMutation(groupSourcesPutMutation(queryClient));
const [selected, setSelected] = useState<number[] | null>(null);
const readOnly = useReadOnlyConfig();
if (sources.isPending) {
return (
@@ -60,7 +58,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isError) return <InlineError error={sources.error} />;
if (blocklists.length === 0) {
return <p {...stylex.props(styles.note)}>No blocklist sources exist yet add them on the Blocklists page.</p>;
return <p {...stylex.props(styles.note)}>No blocklist sources exist yet add them on the Sources tab.</p>;
}
const current = selected ?? sources.data;
@@ -87,8 +85,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
<div {...stylex.props(styles.buttonRow)}>
<button
type="button"
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
disabled={!dirty || mutation.isPending}
onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
}
@@ -0,0 +1,79 @@
import * as stylex from "@stylexjs/stylex";
import type { ForwardZone, LocalRecord } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { styles as config } from "./styles";
/**
* Local records and forward zones under file authority. Collections are
* tables, not definition lists, and each names the ZON key it comes from once
* rather than repeating it on every row.
*/
export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
return (
<section {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>
Local records
<code {...stylex.props(shared.mono, config.panelKey)}>local_records</code>
</h2>
{records.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no local records.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>Type</th>
<th {...stylex.props(shared.th)}>Value</th>
<th {...stylex.props(shared.th)}>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.id}>
<td {...stylex.props(shared.td, shared.mono)}>{record.name}</td>
<td {...stylex.props(shared.td)}>{record.rtype}</td>
<td {...stylex.props(shared.td, shared.mono)}>{record.value}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{record.ttl}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
return (
<section {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>
Forward zones
<code {...stylex.props(shared.mono, config.panelKey)}>forward_zones</code>
</h2>
{zones.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no forward zones.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Zone</th>
<th {...stylex.props(shared.th)}>Resolver</th>
</tr>
</thead>
<tbody>
{zones.map((zone) => (
<tr key={zone.id}>
<td {...stylex.props(shared.td, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(shared.td, shared.mono)}>{zone.resolver}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
@@ -0,0 +1,596 @@
import { useEffect, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
blocklistsQuery,
clientsQuery,
groupCreateMutation,
groupDeleteMutation,
groupSourcesQuery,
groupUpdateMutation,
groupsQuery,
ruleCreateMutation,
ruleDeleteMutation,
rulesQuery,
} from "@/lib/queries";
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import GroupSourcesEditor from "./GroupSourcesEditor";
import QueryPanel from "./QueryPanel";
import { styles as config } from "./styles";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
{ value: "regex", label: "regex" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
createForm: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.75rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
detailHeading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
controlRow: {
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
spacer: {
marginLeft: "auto",
},
destructive: {
color: colors.danger,
},
ruleForm: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
pattern: {
fontWeight: 500,
},
});
export default function ProtectionGroups() {
const groups = useQuery(groupsQuery());
return (
<AuthorityGate>
{(status) => (
<div>
{status.authority === "managed_file" && <FileModeNote path={status.path} />}
<QueryPanel query={groups}>
{(rows) => <GroupsMasterDetail groups={rows} status={status} />}
</QueryPanel>
</div>
)}
</AuthorityGate>
);
}
/**
* Master/detail on one group at a time. The selection is `?group=`, so a view
* of a group is a link and the browser's back button walks the groups the
* reader looked at.
*/
function GroupsMasterDetail({ groups, status }: { groups: Group[]; status: ConfigStatus }) {
const search = useSearch({ from: "/shell/configuration/protection" });
const navigate = useNavigate({ from: "/configuration/protection" });
const known = groups.some((group) => group.id === search.group);
const selectedId = known ? search.group : groups[0]?.id;
const selected = groups.find((group) => group.id === selectedId);
// An id the URL named that no group has — deleted, or hand-typed — falls
// back to the first group, and the URL is rewritten to say so. `replace`,
// because a corrected address is not a place the reader chose to be and a
// back button that returns to it would trap them.
useEffect(() => {
if (selectedId === undefined || selectedId === search.group) return;
void navigate({ search: { tab: search.tab, group: selectedId }, replace: true });
}, [navigate, search.group, search.tab, selectedId]);
const fileMode = status.authority === "managed_file";
return (
<div {...stylex.props(config.split)}>
<div>
{!fileMode && <CreateGroupForm />}
<nav aria-label="Groups" {...stylex.props(styles.controlRow)}>
<ul {...stylex.props(config.masterList)}>
{groups.map((group) => (
<li key={group.id}>
<Link
to="/configuration/protection"
search={{ tab: search.tab, group: group.id }}
activeOptions={{ includeSearch: true }}
{...stylex.props(
config.masterLink,
group.id === selectedId && config.masterLinkActive,
shared.focusRing,
)}
>
{group.name}
</Link>
</li>
))}
</ul>
</nav>
</div>
{selected === undefined ? (
<p {...stylex.props(config.empty)}>No groups exist.</p>
) : fileMode ? (
<GroupDetailReadOnly group={selected} />
) : (
<GroupDetailEditable group={selected} />
)}
</div>
);
}
function CreateGroupForm() {
const queryClient = useQueryClient();
const create = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
return (
<>
<form
{...stylex.props(styles.createForm)}
onSubmit={(event: FormEvent) => {
event.preventDefault();
const name = newName.trim();
if (name === "") return;
create.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group
</label>
<input
id="new-group-name"
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
/>
<button
type="submit"
disabled={create.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Create
</button>
</form>
<InlineError error={create.error} />
</>
);
}
function ClientCountLink({ group }: { group: Group }) {
const clients = useQuery(clientsQuery());
const count = clients.data?.filter((client) => client.group_id === group.id).length;
return (
<p {...stylex.props(config.note)}>
<Link to="/clients" search={{ group: group.id }} {...stylex.props(shared.focusRing)}>
{count === undefined
? "Clients in this group"
: `${count} client${count === 1 ? "" : "s"} in this group`}
</Link>
</p>
);
}
function GroupDetailReadOnly({ group }: { group: Group }) {
return (
<div>
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
<section {...stylex.props(config.panel)}>
<DefinitionList
items={[
{ label: "Name", zonKey: "groups[].name", value: group.name },
{ label: "Safe search", zonKey: "groups[].safe_search", value: String(group.safe_search) },
]}
/>
</section>
<GroupSourcesReadOnly group={group} />
<GroupRules group={group} editable={false} />
<ClientCountLink group={group} />
</div>
);
}
function GroupSourcesReadOnly({ group }: { group: Group }) {
const blocklists = useQuery(blocklistsQuery());
const assigned = useQuery(groupSourcesQuery(group.id));
return (
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>
Assigned sources
<code {...stylex.props(shared.mono, config.panelKey)}>group_sources</code>
</h3>
<QueryPanel query={assigned}>
{(sourceIds) => (
<QueryPanel query={blocklists}>
{(catalogue) => <AssignedSources sourceIds={sourceIds} catalogue={catalogue} />}
</QueryPanel>
)}
</QueryPanel>
</section>
);
}
function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) {
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
return (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>URL</th>
</tr>
</thead>
<tbody>
{assigned.map((source) => (
<tr key={source.id}>
<td {...stylex.props(shared.td)}>{source.name}</td>
<td {...stylex.props(shared.td, shared.mono)}>{source.url}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function GroupDetailEditable({ group }: { group: Group }) {
const queryClient = useQueryClient();
const blocklists = useQuery(blocklistsQuery());
const update = useMutation(groupUpdateMutation(queryClient));
const remove = useMutation(groupDeleteMutation(queryClient));
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(group.name);
const [confirming, setConfirming] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
return (
<div>
{renaming ? (
<form
{...stylex.props(styles.controlRow)}
onSubmit={(event: FormEvent) => {
event.preventDefault();
const trimmed = name.trim();
if (trimmed === "") return;
update.mutate(
{ id: group.id, input: { name: trimmed, safe_search: group.safe_search } },
{ onSuccess: () => setRenaming(false) },
);
}}
>
<input
type="text"
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus
/>
<button
type="submit"
disabled={update.isPending}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Save
</button>
<button
type="button"
onClick={() => {
setName(group.name);
setRenaming(false);
}}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</form>
) : (
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
)}
<div {...stylex.props(styles.controlRow)}>
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={group.safe_search}
disabled={update.isPending}
{...stylex.props(shared.focusRing)}
onChange={(event) =>
update.mutate({
id: group.id,
input: { name: group.name, safe_search: event.target.checked },
})
}
/>
Safe search
</label>
<span {...stylex.props(styles.spacer)}>
{!renaming && (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => {
setName(group.name);
setRenaming(true);
}}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Rename group
</button>
)}{" "}
<button
type="button"
disabled={isDefault || remove.isPending}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => setConfirming(true)}
{...stylex.props(shared.smallButton, styles.destructive, shared.focusRing)}
>
Delete group
</button>
</span>
</div>
{isDefault && <p {...stylex.props(config.note)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={update.error ?? remove.error} />
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>Assigned sources</h3>
<QueryPanel query={blocklists}>
{(catalogue) => <GroupSourcesEditor groupId={group.id} blocklists={catalogue} />}
</QueryPanel>
</section>
<GroupRules group={group} editable />
<ClientCountLink group={group} />
<ConfirmDialog
isOpen={confirming}
title="Delete group"
message={`Delete group "${group.name}"? Its clients fall back to the default group.`}
confirmLabel="Delete"
onConfirm={() => {
setConfirming(false);
remove.mutate(group.id);
}}
onCancel={() => setConfirming(false)}
/>
</div>
);
}
/**
* The rules that apply to the selected group, and nothing else. Rules are
* always scoped to a group, so the group-centred page is the only place they
* need to be read: a flat list of every rule in the household was a list of
* facts about no particular policy.
*/
function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
const rules = useQuery(rulesQuery());
return (
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>
Rules
{!editable && <code {...stylex.props(shared.mono, config.panelKey)}>rules</code>}
</h3>
<QueryPanel query={rules}>
{(all) => {
const scoped = all.filter((rule) => rule.group_id === group.id);
return (
<>
{scoped.length === 0 ? (
<p {...stylex.props(config.empty)}>No allow or block rules for this group.</p>
) : (
<RulesTable rules={scoped} editable={editable} />
)}
{editable && <CreateRuleForm group={group} />}
</>
);
}}
</QueryPanel>
</section>
);
}
function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
const queryClient = useQueryClient();
const remove = useMutation(ruleDeleteMutation(queryClient));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
return (
<>
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Created</th>
{editable && (
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
)}
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
</span>
</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
{editable && (
<td {...stylex.props(shared.td)}>
<button
type="button"
onClick={() => setPendingDelete(rule)}
disabled={remove.isPending}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<InlineError error={remove.error} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={() => {
if (pendingDelete !== null) remove.mutate(pendingDelete.id);
setPendingDelete(null);
}}
onCancel={() => setPendingDelete(null)}
/>
</>
);
}
function CreateRuleForm({ group }: { group: Group }) {
const queryClient = useQueryClient();
const create = useMutation(ruleCreateMutation(queryClient));
const [pattern, setPattern] = useState("");
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
function onSubmit(event: FormEvent) {
event.preventDefault();
// A regex pattern is stored and matched byte for byte, so the UI must not
// edit it: trimming here would make a UI-created rule differ from the same
// bytes posted to /api/rules. Name-shaped kinds are normalized server-side,
// so trimming them only spares a pasted space a 400.
const sent = kind === "regex" ? pattern : pattern.trim();
create.mutate({ group_id: group.id, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
}
return (
<form onSubmit={onSubmit} {...stylex.props(styles.ruleForm)}>
<h4 {...stylex.props(styles.fieldLabel)}>Create rule in {group.name}</h4>
<div>
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern
</label>
<input
id="rule-pattern"
type="text"
required
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
// A phone keyboard capitalizing the first letter is silent for
// exact and wildcard (normalized server-side) but fatal for a
// regex, which matches the lowercase query name byte for byte.
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.fieldGrid)}>
<Select
label="Kind"
value={kind}
onChange={(value) => setKind(value as RuleKind)}
options={KIND_OPTIONS}
/>
<Select
label="Action"
value={action}
onChange={(value) => setAction(value as RuleAction)}
options={ACTION_OPTIONS}
/>
</div>
<div>
<button
type="submit"
disabled={create.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
</div>
<InlineError error={create.error} />
</form>
);
}
@@ -0,0 +1,295 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
/**
* Protection in database mode: the group-centred master/detail, the rules
* scoped to the selected group, and the shared source catalogue with its one
* runtime action.
*/
let calls: Call[];
afterEach(() => {
vi.unstubAllGlobals();
});
async function openProtection(group?: number) {
calls = stubApi(DATABASE);
const suffix = group === undefined ? "" : `?group=${group}`;
return renderPage(`/configuration/protection${suffix}`, "Protection");
}
function writes(method: string): Call[] {
return calls.filter((call) => call.method === method);
}
test("the group list is the master, and the selected group is the detail", async () => {
await openProtection();
const list = within(screen.getByRole("navigation", { name: "Groups" }));
expect(list.getByRole("link", { name: "default" })).toBeTruthy();
expect(list.getByRole("link", { name: "kids" })).toBeTruthy();
await screen.findByRole("heading", { name: "default", level: 2 });
});
test("the default group cannot be renamed or deleted, and says why", async () => {
await openProtection(1);
await screen.findByRole("heading", { name: "default", level: 2 });
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Delete group" }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText("The default group cannot be renamed or deleted.")).toBeTruthy();
});
test("another group can be renamed and deleted, and carries its safe-search state", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete group "kids"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes("DELETE")).toEqual([]);
});
test("toggling safe search resends the whole group row", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" }));
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
url: "/api/groups/2",
body: { name: "kids", safe_search: false },
});
});
test("the source assignment saves the full set via PUT", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
const ads = (await screen.findByRole("checkbox", { name: "Ads" })) as HTMLInputElement;
expect(ads.checked).toBe(false);
const save = screen.getByRole("button", { name: "Save sources" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(ads);
expect(save.disabled).toBe(false);
fireEvent.click(save);
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
url: "/api/groups/2/sources",
body: { source_ids: [1] },
});
});
test("only the selected group's rules are listed", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
expect(await screen.findByText("*.social.example")).toBeTruthy();
expect(screen.queryByText("ads.example.com")).toBeNull();
});
test("a new rule is created in the selected group, with the pattern posted verbatim for a regex", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });
// An exact pattern is trimmed; a regex is stored and matched byte for byte.
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(writes("POST")).toHaveLength(1));
expect(writes("POST")[0]?.body).toEqual({
group_id: 2,
pattern: "ads.example.net",
kind: "exact",
action: "block",
});
});
test("a rate-limited rule create shows the countdown from Retry-After", async () => {
calls = stubApi(DATABASE, {
onWrite: (call) =>
call.url === "/api/rules"
? new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "12" },
})
: null,
});
await renderPage("/configuration/protection?group=2", "Protection");
await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 12s.");
});
test("cancelling the rule delete confirmation leaves the rule alone", async () => {
await openProtection(2);
await screen.findByText("*.social.example");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes("DELETE")).toEqual([]);
expect(screen.getByText("*.social.example")).toBeTruthy();
});
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
await openProtection(2);
const pattern = await screen.findByLabelText("Pattern");
expect(pattern.getAttribute("autocapitalize")).toBe("none");
expect(pattern.getAttribute("autocorrect")).toBe("off");
expect(pattern.getAttribute("spellcheck")).toBe("false");
});
test("the kind selector offers the three contract kinds and can pick regex", async () => {
await openProtection(2);
await screen.findByLabelText("Pattern");
fireEvent.click(screen.getByRole("button", { name: /Kind$/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["exact", "wildcard", "regex"]);
fireEvent.click(screen.getByRole("option", { name: "regex" }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ^ad[0-9]+- " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(writes("POST")).toHaveLength(1));
expect(writes("POST")[0]?.body).toMatchObject({ pattern: " ^ad[0-9]+- ", kind: "regex" });
});
test("deleting a rule asks first, then issues the DELETE", async () => {
await openProtection(2);
await screen.findByText("*.social.example");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "*.social.example"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes("DELETE")).toHaveLength(1));
expect(writes("DELETE")[0]?.url).toBe("/api/rules/2");
});
test("the client count links into Clients filtered by the group (D3)", async () => {
await openProtection(2);
const link = await screen.findByRole("link", { name: "1 client in this group" });
expect(link.getAttribute("href")).toBe("/clients?group=2");
});
test("a group can be created from the master column", async () => {
await openProtection();
fireEvent.change(await screen.findByLabelText("New group"), { target: { value: " guests " } });
fireEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() => expect(writes("POST")).toHaveLength(1));
expect(writes("POST")[0]).toMatchObject({ url: "/api/groups", body: { name: "guests" } });
});
test("the Sources tab lists the catalogue with both skipped columns and their note", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
expect(await screen.findByText("Ads")).toBeTruthy();
expect(screen.getByText("Trackers")).toBeTruthy();
expect(screen.getByText("Suggested")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});
test("Update now says it started, and says nothing once it succeeds", async () => {
let release: ((response: Response) => void) | null = null;
calls = stubApi(DATABASE, {
onWrite: (call) =>
// Held open so the started state is observable, not a frame that resolves
// before the assertion.
call.url === "/api/blocklists/update"
? new Promise<Response>((resolve) => {
release = resolve;
})
: null,
});
await renderPage("/configuration/protection?tab=sources", "Protection");
fireEvent.click(await screen.findByRole("button", { name: "Update now" }));
const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement;
expect(pending.disabled).toBe(true);
expect(screen.getByRole("status").textContent).toBe("Update started…");
release!(
new Response(JSON.stringify({ sources: [] }), {
status: 202,
headers: { "content-type": "application/json" },
}),
);
await waitFor(() => expect(screen.getByRole("button", { name: "Update now" })).toBeTruthy());
// Success leaves no standing claim behind: the refreshed counters are the
// signal, and a "counters refreshed" line would outlive a failed refetch.
expect(screen.queryByRole("status")).toBeNull();
});
test("a rate-limited Update now shows the countdown from Retry-After", async () => {
calls = stubApi(DATABASE, {
onWrite: (call) =>
call.url === "/api/blocklists/update"
? new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "7" },
})
: null,
});
await renderPage("/configuration/protection?tab=sources", "Protection");
fireEvent.click(await screen.findByRole("button", { name: "Update now" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
});
test("deleting a source asks first, then issues the DELETE for that source", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("Ads");
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "Trackers"? Its domains stop being blocked.');
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes("DELETE")).toHaveLength(1));
expect(writes("DELETE")[0]?.url).toBe("/api/blocklists/2");
});
test("cancelling the source delete confirmation leaves the source alone", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("Ads");
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
const dialog = await screen.findByRole("alertdialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes("DELETE")).toEqual([]);
expect(screen.getByText("Trackers")).toBeTruthy();
});
@@ -0,0 +1,38 @@
import { useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import Tabs from "@/ui/Tabs";
import ProtectionGroups from "./ProtectionGroups";
import ProtectionSources from "./ProtectionSources";
import { styles } from "./styles";
import type { ProtectionTab } from "./search";
/**
* Protection: what policy governs each group, and which rules and lists
* produce it. Group-centred, because a rule or a source only means something
* once you know whose queries it applies to.
*/
export default function ProtectionPage() {
const search = useSearch({ from: "/shell/configuration/protection" });
const tab = search.tab ?? "groups";
const navigate = useNavigate({ from: "/configuration/protection" });
return (
<section>
<h1 {...stylex.props(styles.heading)}>Protection</h1>
<p {...stylex.props(styles.intro)}>What each group of clients is allowed to resolve, and why.</p>
<Tabs
label="Protection"
selectedKey={tab}
// The group rides along, so switching tabs and coming back returns to
// the group the reader was looking at rather than the first one.
onSelectionChange={(key) =>
void navigate({ search: { tab: key as ProtectionTab, group: search.group } })
}
tabs={[
{ id: "groups", label: "Groups", content: <ProtectionGroups /> },
{ id: "sources", label: "Sources", content: <ProtectionSources /> },
]}
/>
</section>
);
}
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
@@ -11,48 +11,23 @@ import {
blocklistsUpdateNowMutation,
} from "@/lib/queries";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
import AuthorityGate from "./AuthorityGate";
import BlocklistForm from "./BlocklistForm";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
import { styles as config } from "./styles";
const SKIPPED_NOTE =
"Both “Skipped” columns count lines nxdns read and did not take. Skipped regex lines are patterns nxdns accepts " +
"only from you — adopt one you trust as a regex rule. Skipped unsupported lines are syntax nxdns cannot translate " +
"into a DNS decision: cosmetic element hiding, browser-only modifiers. A skipped unsupported count that dwarfs the " +
"domain count usually means the list is written for a browser extension, and its DNS or hosts variant will block " +
"more here.";
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
done: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
@@ -82,9 +57,129 @@ const styles = stylex.create({
},
});
export default function BlocklistsPage() {
/**
* A runtime action: it re-downloads the sources the running process already
* knows about, so it stays enabled under file authority and while
* `/api/config/status` is still answering.
*
* The feedback is the action's own, and it is transient: started while the
* request is in flight, the failure verbatim if it fails, and nothing on
* success the refreshed counters are the success signal, and a standing
* "counters refreshed" line would claim a refetch that may itself have failed.
* Durable per-source outcomes live in Diagnostics.
*/
function UpdateNowAction() {
const queryClient = useQueryClient();
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
return (
<div {...stylex.props(config.actionRow)}>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
{updateNow.isPending && (
<p role="status" {...stylex.props(config.note)}>
Update started
</p>
)}
<InlineError error={updateNow.error} />
</div>
);
}
export default function ProtectionSources() {
const blocklists = useQuery(blocklistsQuery());
return (
<div>
<p {...stylex.props(config.intro)}>
The shared catalogue every group draws from. A group subscribes to sources on the Groups tab.
</p>
<UpdateNowAction />
<AuthorityGate>
{(status) =>
status.authority === "managed_file" ? (
<>
<FileModeNote path={status.path} />
<QueryPanel query={blocklists}>
{(rows) => <SourcesReadOnly blocklists={rows} />}
</QueryPanel>
</>
) : (
<QueryPanel query={blocklists}>{(rows) => <SourcesEditor blocklists={rows} />}</QueryPanel>
)
}
</AuthorityGate>
</div>
);
}
/**
* File mode is a different rendering of the same facts, not a smaller set of
* them: the Suggested provenance and both skipped-line counters belong here
* too. A list whose lines nxdns could not take is a failure the reader must
* see under either authority.
*/
function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
return (
<section {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>
Blocklist sources
<code {...stylex.props(shared.mono, config.panelKey)}>blocklist_sources</code>
</h2>
{blocklists.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no blocklist sources.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
</td>
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</td>
<td {...stylex.props(shared.td)}>{String(b.enabled)}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{b.skipped_unsupported_count}
</td>
<td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
</tr>
))}
</tbody>
</table>
<p {...stylex.props(config.note)}>{SKIPPED_NOTE}</p>
</div>
)}
</section>
);
}
function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
@@ -92,13 +187,6 @@ export default function BlocklistsPage() {
const save = useMutation(blocklistUpdateMutation(queryClient));
const toggle = useMutation(blocklistUpdateMutation(queryClient));
const remove = useMutation(blocklistDeleteMutation(queryClient));
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
const sources = useRefreshStatus();
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
// The refresh below re-fetches the sources the config already declares, so
// it stays live in file mode; every other control here writes config.
const readOnly = useReadOnlyConfig();
async function submitForm(input: BlocklistInput) {
if (editing === null) {
@@ -126,30 +214,12 @@ export default function BlocklistsPage() {
const tableError = remove.error ?? toggle.error;
return (
<section>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
</div>
{updateNow.isSuccess && !updateNow.isPending && (
<p {...stylex.props(styles.done)} role="status">
Update completed; source status refreshed below.
</p>
)}
<InlineError error={updateNow.error} />
<div>
{blocklists.length === 0 ? (
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
<p {...stylex.props(config.empty)}>No blocklist sources yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
@@ -183,8 +253,7 @@ export default function BlocklistsPage() {
type="checkbox"
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
disabled={toggle.isPending}
onChange={() => toggleEnabled(b)}
{...stylex.props(shared.focusRing)}
/>
@@ -204,22 +273,19 @@ export default function BlocklistsPage() {
<button
type="button"
onClick={() => setEditing(b)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
{...stylex.props(shared.linkButton, shared.focusRing)}
>
Edit
</button>
<button
type="button"
onClick={() => setPendingDelete(b)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
disabled={remove.isPending}
{...stylex.props(
shared.dangerLinkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -229,13 +295,7 @@ export default function BlocklistsPage() {
))}
</tbody>
</table>
<p {...stylex.props(styles.note)}>
Both Skipped columns count lines nxdns read and did not take. Skipped regex lines are patterns
nxdns accepts only from you adopt one you trust as a regex rule. Skipped unsupported lines are
syntax nxdns cannot translate into a DNS decision: cosmetic element hiding, browser-only
modifiers. A skipped unsupported count that dwarfs the domain count usually means the list is
written for a browser extension, and its DNS or hosts variant will block more here.
</p>
<p {...stylex.props(config.note)}>{SKIPPED_NOTE}</p>
</div>
)}
<InlineError error={tableError} />
@@ -244,14 +304,11 @@ export default function BlocklistsPage() {
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
/>
<SourceStatusSection sources={sources} namesById={namesById} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete blocklist"
@@ -264,6 +321,6 @@ export default function BlocklistsPage() {
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
</div>
);
}
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { styles } from "./styles";
/**
* One panel's data, with the loading and error surfaces the fire-and-forget
* route loaders leave to the page. A configuration page holds several
* independent collections; each states its own condition instead of the whole
* page waiting on the slowest request.
*/
export default function QueryPanel<T>({
query,
children,
}: {
query: UseQueryResult<T, Error>;
children: (data: T) => ReactNode;
}) {
if (query.isPending) {
return (
<p role="status" {...stylex.props(styles.pending, shared.pulse)}>
Loading
</p>
);
}
if (query.isError) return <InlineError error={query.error} onRetry={() => void query.refetch()} />;
return <>{children(query.data)}</>;
}
@@ -1,5 +1,5 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
localRecordCreateMutation,
@@ -12,9 +12,9 @@ import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import QueryPanel from "./QueryPanel";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
@@ -93,14 +93,12 @@ const styles = stylex.create({
function RecordForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: LocalRecord;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: LocalRecordInput) => void;
onCancel: () => void;
@@ -174,12 +172,7 @@ function RecordForm({
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
<button type="submit" disabled={busy} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
@@ -192,7 +185,7 @@ function RecordForm({
}
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const query = useQuery(localRecordsQuery());
const {
create,
update,
@@ -211,7 +204,6 @@ export default function RecordsTab() {
remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
@@ -220,8 +212,6 @@ export default function RecordsTab() {
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add record
@@ -229,98 +219,92 @@ export default function RecordsTab() {
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
)}
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Type
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Value
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
TTL
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet.
</td>
</tr>
)}
{records.map((record) => (
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
initial={record}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
<QueryPanel query={query}>
{(records) => (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Type
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Value
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
TTL
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet.
</td>
</>
</tr>
)}
</tr>
))}
</tbody>
</table>
</div>
{records.map((record) => (
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
initial={record}
busy={update.isPending}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</QueryPanel>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
@@ -0,0 +1,47 @@
import { useMutation } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { reloadCerts } from "@/lib/api";
import type { CertReloadOutcome, CertsReload } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { styles } from "./styles";
function describe(name: string, outcome: CertReloadOutcome): string {
if (!outcome.enabled) return `${name}: not enabled`;
if (outcome.reloaded) return `${name}: reloaded`;
return `${name}: failed — ${outcome.error ?? "no reason given"}`;
}
/**
* A runtime action, not a configuration write: it re-reads the certificate
* files the running listeners already point at, so it stays enabled under
* every authority — including a file-managed process, where renewing a
* certificate is exactly the job that must not need a restart.
*
* The endpoint answers 200 even when a reload fails, per endpoint, because a
* failed reload leaves the previous certificate serving. So the outcome is
* rendered as a result, and only a transport or auth failure is an error.
*/
export default function ReloadCertsAction() {
const mutation = useMutation<CertsReload>({ mutationFn: reloadCerts });
const result = mutation.data;
return (
<div {...stylex.props(styles.actionRow)}>
<button
type="button"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
{...stylex.props(shared.button, shared.focusRing)}
>
{mutation.isPending ? "Reloading certificates…" : "Reload certificates"}
</button>
{result !== undefined && (
<p role="status" {...stylex.props(styles.success)}>
{describe("DoH", result.doh)}. {describe("DoT", result.dot)}.
</p>
)}
<InlineError error={mutation.error} />
</div>
);
}
@@ -0,0 +1,259 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
/**
* Resolution in database mode: the upstream pool, the local records and the
* forward zones, each rehomed from its own page onto a tab of one.
*/
let calls: Call[];
afterEach(() => {
vi.unstubAllGlobals();
});
function writes(): Call[] {
return calls;
}
async function openResolution(tab?: string, options: Parameters<typeof stubApi>[1] = {}) {
calls = stubApi(DATABASE, options);
const suffix = tab === undefined ? "" : `?tab=${tab}`;
return renderPage(`/configuration/resolution${suffix}`, "Resolution");
}
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog(index = 0) {
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[index]!);
return await screen.findByRole("alertdialog");
}
test("the upstream pool is the default tab and lists every field", async () => {
await openResolution();
expect(await screen.findByText("udp://1.1.1.1:53")).toBeTruthy();
expect(screen.getByText("tls://9.9.9.9:853")).toBeTruthy();
expect(screen.getByText("dns.quad9.net")).toBeTruthy();
expect((screen.getByLabelText("udp://1.1.1.1:53 enabled") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("tls://9.9.9.9:853 enabled") as HTMLInputElement).checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
expect(screen.getByText(/takes effect at the next restart/)).toBeTruthy();
});
test("adding an upstream posts every field", async () => {
await openResolution();
await screen.findByRole("heading", { name: "Add upstream" });
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "150" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toEqual({
url: "/api/upstreams",
method: "POST",
body: { url: "udp://8.8.8.8:53", priority: 150, enabled: true, tls_name: "" },
});
});
test("an upstream write re-reads the config status, and the shell states the pending restart", async () => {
// The client never decides a restart is owed: the server sets the flag, and
// the mutation's invalidation is only what makes the page ask again.
let restartPending = false;
await openResolution(undefined, {
responses: { "GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }) },
onWrite: () => {
restartPending = true;
return null;
},
});
await screen.findByRole("heading", { name: "Add upstream" });
expect(screen.queryByText(/Restart nxdns to apply them/)).toBeNull();
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
await screen.findByText(/Saved changes are not running yet\. Restart nxdns to apply them\./);
});
test("toggling enabled resends the whole row", async () => {
await openResolution();
await screen.findByLabelText("tls://9.9.9.9:853 enabled");
fireEvent.click(screen.getByLabelText("tls://9.9.9.9:853 enabled"));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toEqual({
url: "/api/upstreams/2",
method: "PUT",
body: { url: "tls://9.9.9.9:853", priority: 200, enabled: true, tls_name: "dns.quad9.net" },
});
});
test("upstream delete asks for confirmation and skips the request when cancelled", async () => {
await openResolution();
await screen.findByText("udp://1.1.1.1:53");
const dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes()).toHaveLength(0);
});
test("confirming the upstream delete dialog issues the DELETE", async () => {
await openResolution();
await screen.findByText("udp://1.1.1.1:53");
const dialog = await openDeleteDialog();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/upstreams/1" });
});
test("a 409 on create renders the conflict text inline", async () => {
await openResolution(undefined, {
onWrite: () =>
new Response(JSON.stringify({ error: "an upstream with that url already exists" }), {
status: 409,
headers: { "content-type": "application/json" },
}),
});
await screen.findByRole("heading", { name: "Add upstream" });
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://1.1.1.1:53" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("an upstream with that url already exists");
});
test("a 409 on toggle renders the last-enabled conflict", async () => {
await openResolution(undefined, {
onWrite: () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be disabled" }), {
status: 409,
headers: { "content-type": "application/json" },
}),
});
await screen.findByLabelText("udp://1.1.1.1:53 enabled");
fireEvent.click(screen.getByLabelText("udp://1.1.1.1:53 enabled"));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be disabled");
});
test("a 409 on delete renders the last-enabled conflict", async () => {
await openResolution(undefined, {
onWrite: () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409,
headers: { "content-type": "application/json" },
}),
});
await screen.findByText("udp://1.1.1.1:53");
const dialog = await openDeleteDialog();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
});
test("editing a row seeds the form and PUTs the replaced row", async () => {
await openResolution();
await screen.findByText("tls://9.9.9.9:853");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[1]!);
await screen.findByRole("heading", { name: "Edit tls://9.9.9.9:853" });
expect((screen.getByLabelText("URL") as HTMLInputElement).value).toBe("tls://9.9.9.9:853");
expect((screen.getByLabelText("Priority") as HTMLInputElement).value).toBe("200");
expect((screen.getByLabelText("TLS name") as HTMLInputElement).value).toBe("dns.quad9.net");
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "10" } });
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toEqual({
url: "/api/upstreams/2",
method: "PUT",
body: { url: "tls://9.9.9.9:853", priority: 10, enabled: false, tls_name: "dns.quad9.net" },
});
await screen.findByRole("heading", { name: "Add upstream" });
});
test("the arrow keys move between tabs, and the panel follows", async () => {
await openResolution();
await screen.findByText("udp://1.1.1.1:53");
const tablist = screen.getByRole("tablist", { name: "Resolution" });
expect(screen.getByRole("tab", { name: "Upstreams" }).getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
await waitFor(() =>
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true"),
);
await screen.findByText("nas.lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
await waitFor(() =>
expect(screen.getByRole("tab", { name: "Upstreams" }).getAttribute("aria-selected")).toBe("true"),
);
await screen.findByText("udp://1.1.1.1:53");
});
test("creating a local record posts exactly the LocalRecordInput", async () => {
await openResolution("records");
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
// The record type is a RAC Select: open the listbox, then pick.
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toMatchObject({
url: "/api/local-records",
method: "POST",
body: { name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" },
});
});
test("the record delete dialog names the record and only deletes on confirm", async () => {
await openResolution("records");
await screen.findByText("nas.lan.home");
let dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes()).toHaveLength(0);
dialog = await openDeleteDialog();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/local-records/1" });
});
test("the forward zone delete dialog names the zone and only deletes on confirm", async () => {
await openResolution("zones");
await screen.findByText("lan.home");
let dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes()).toHaveLength(0);
dialog = await openDeleteDialog();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/forward-zones/1" });
});
@@ -0,0 +1,77 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { forwardZonesQuery, localRecordsQuery } from "@/lib/queries";
import Tabs from "@/ui/Tabs";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import { RecordsReadOnly, ZonesReadOnly } from "./LocalReadOnly";
import QueryPanel from "./QueryPanel";
import RecordsTab from "./RecordsTab";
import UpstreamsTab from "./UpstreamsTab";
import ZonesTab from "./ZonesTab";
import { styles } from "./styles";
import type { ResolutionTab } from "./search";
function RecordsPanel() {
const records = useQuery(localRecordsQuery());
return (
<AuthorityGate>
{(status) =>
status.authority === "managed_file" ? (
<>
<FileModeNote path={status.path} />
<QueryPanel query={records}>{(rows) => <RecordsReadOnly records={rows} />}</QueryPanel>
</>
) : (
<RecordsTab />
)
}
</AuthorityGate>
);
}
function ZonesPanel() {
const zones = useQuery(forwardZonesQuery());
return (
<AuthorityGate>
{(status) =>
status.authority === "managed_file" ? (
<>
<FileModeNote path={status.path} />
<QueryPanel query={zones}>{(rows) => <ZonesReadOnly zones={rows} />}</QueryPanel>
</>
) : (
<ZonesTab />
)
}
</AuthorityGate>
);
}
/**
* Resolution: where a permitted name gets its answer. Three tabs in the order
* a query meets them — the pool that answers most of them, the records nxdns
* answers itself, and the zones it hands to another resolver.
*/
export default function ResolutionPage() {
const tab = useSearch({ from: "/shell/configuration/resolution" }).tab ?? "upstreams";
const navigate = useNavigate({ from: "/configuration/resolution" });
return (
<section>
<h1 {...stylex.props(styles.heading)}>Resolution</h1>
<p {...stylex.props(styles.intro)}>Where nxdns answers or forwards the names it permits.</p>
<Tabs
label="Resolution"
selectedKey={tab}
onSelectionChange={(key) => void navigate({ search: { tab: key as ResolutionTab } })}
tabs={[
{ id: "upstreams", label: "Upstreams", content: <UpstreamsTab /> },
{ id: "records", label: "Records", content: <RecordsPanel /> },
{ id: "zones", label: "Forward zones", content: <ZonesPanel /> },
]}
/>
</section>
);
}
@@ -0,0 +1,67 @@
import * as stylex from "@stylexjs/stylex";
import type { Settings } from "@/lib/types";
import DefinitionList, { type Definition } from "@/ui/DefinitionList";
import { colors } from "@/ui/tokens.stylex";
import { SECTIONS, sectionValues, type AnyFieldDef } from "./settingsSections";
import { styles as config } from "./styles";
const styles = stylex.create({
absent: {
color: colors.textMuted,
},
});
/** The field key as a sentence. The exact key travels beside it, so this is free to read well. */
export function humanize(key: string): string {
const spaced = key.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/**
* Values are shown as the file spells them: `true`, not "Yes". The reader's
* next step is editing that file, and a value they cannot type back is a
* translation they have to undo.
*/
function renderValue(value: unknown) {
if (typeof value === "boolean") return String(value);
if (typeof value === "number") return String(value);
if (value === "") return <span {...stylex.props(styles.absent)}>empty</span>;
return String(value);
}
/**
* The settings under file authority: definition lists, one per section, each
* scalar carrying its exact ZON key. No inputs, no Save — the file is the
* form.
*/
export default function SettingsDefinitions({ settings }: { settings: Settings }) {
return (
<>
{SECTIONS.map(({ section, title, fields }) => {
const values = sectionValues(settings, section);
const items: Definition[] = (fields as readonly AnyFieldDef[]).map((def) => ({
label: humanize(def.key),
zonKey: `${section}.${def.key}`,
value: renderValue(values[def.key]),
}));
if (section === "web") {
// Derived from whether a password hash is stored, so it has no key of
// its own (D13). Naming one would send the reader to a line that is
// not in the file.
items.push({
label: "Authentication",
value: settings.web.auth_enabled ? "required" : "not configured",
});
}
return (
<section key={section} {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>{title}</h2>
<div {...stylex.props(config.note)}>
<DefinitionList items={items} />
</div>
</section>
);
})}
</>
);
}
@@ -0,0 +1,331 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { settingsPutMutation } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsEnvelope } from "@/lib/types";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { SECTIONS, sectionValues, type AnyFieldDef } from "./settingsSections";
import { styles as config } from "./styles";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
form: {
marginTop: "1rem",
maxWidth: "48rem",
},
/** A `fieldset` has a browser default border and padding; the layout wants neither. */
sections: {
display: "flex",
flexDirection: "column",
gap: "1.5rem",
borderStyle: "none",
margin: 0,
padding: 0,
},
section: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
legend: {
paddingInline: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
/** One column on a phone, two from `sm`. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(37% 0.013 285.805)",
[DARK]: "oklch(87.1% 0.006 286.286)",
},
},
checkboxRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
fieldInput: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
derived: {
color: colors.textMuted,
},
/** Both notices span the whole grid so the wrapped sentence stays readable. */
spanRow: {
gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" },
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
passwordNotice: {
color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" },
},
mismatchNotice: {
color: colors.danger,
},
submitRow: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
save: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
backgroundColor: {
default: colors.primary,
":disabled": "oklch(87.1% 0.006 286.286)",
[DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" },
},
color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" },
},
});
function FieldLabel({ id, text, restart }: { id: string; text: string; restart: boolean }) {
return (
<label htmlFor={id} {...stylex.props(styles.label)}>
{text}
{restart && <span {...stylex.props(config.restartTag)}>needs restart</span>}
</label>
);
}
function FieldRow({
section,
def,
value,
restart,
onChange,
}: {
section: string;
def: AnyFieldDef;
value: unknown;
restart: boolean;
onChange: (value: unknown) => void;
}) {
const id = `${section}.${def.key}`;
if (def.kind === "boolean") {
return (
<div {...stylex.props(styles.checkboxRow)}>
<input
id={id}
type="checkbox"
checked={value as boolean}
onChange={(e) => onChange(e.target.checked)}
{...stylex.props(shared.focusRing)}
/>
<FieldLabel id={id} text={def.key} restart={restart} />
</div>
);
}
if (Array.isArray(def.kind)) {
// `Select` renders its own `<label>` from a string, so the marker cannot be
// placed inside that label the way `FieldLabel` does it; it goes through the
// description slot instead, which puts it under the control and on the
// trigger's `aria-describedby`. An enum key that owes a restart owes it just
// as much as a number one, so it must be marked either way.
return (
<div {...stylex.props(styles.field)}>
<Select
variant="inline"
label={def.key}
value={value as string}
onChange={onChange}
options={def.kind.map((option) => ({ value: option, label: option }))}
description={restart ? "needs restart" : undefined}
/>
</div>
);
}
if (def.kind === "number") {
const numeric = value as number;
return (
<div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} />
<input
id={id}
type="number"
value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
);
}
return (
<div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} />
<input
id={id}
type="text"
value={value as string}
onChange={(e) => onChange(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
);
}
/**
* The editable settings, in database mode only. Nothing here is ever disabled
* for authority: under file authority the page renders definitions instead, so
* this form is never drawn as a shell the reader cannot use.
*
* Which keys owe a restart is the server's answer, carried on the envelope's
* `restart_required` list; whether one is owed *now* is `restart_pending` on
* `/api/config/status`, which the shell notice reads.
*/
export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope }) {
const queryClient = useQueryClient();
const mutation = useMutation(settingsPutMutation(queryClient));
// Frozen at mount and re-frozen on save: diffing against live query data would
// turn a background refetch's out-of-band changes into phantom user edits.
const [baseline, setBaseline] = useState<Settings>(() => structuredClone(envelope.settings));
const [edited, setEdited] = useState<Settings>(() => structuredClone(envelope.settings));
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const restartKeys = new Set(envelope.restart_required);
const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
const hasInvalidNumber = SECTIONS.some(({ section, fields }) => {
const values = sectionValues(edited, section);
return (fields as readonly AnyFieldDef[]).some(
(field) => field.kind === "number" && Number.isNaN(values[field.key]),
);
});
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
function setField(section: keyof Settings, key: string, value: unknown): void {
setEdited((prev) => ({
...prev,
[section]: { ...sectionValues(prev, section), [key]: value },
}));
}
function handleSubmit(event: FormEvent): void {
event.preventDefault();
if (patch === null || passwordsMismatch || hasInvalidNumber) return;
mutation.mutate(patch, {
onSuccess: (saved) => {
setBaseline(structuredClone(saved.settings));
setEdited(structuredClone(saved.settings));
setPassword("");
setConfirm("");
},
});
}
return (
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending} {...stylex.props(styles.sections)}>
{SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} {...stylex.props(styles.section)}>
<legend {...stylex.props(styles.legend)}>{title}</legend>
<div {...stylex.props(styles.fieldGrid)}>
{(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow
key={def.key}
section={section}
def={def}
value={sectionValues(edited, section)[def.key]}
restart={restartKeys.has(`${section}.${def.key}`)}
onChange={(value) => setField(section, def.key, value)}
/>
))}
{section === "web" && (
<>
<p {...stylex.props(styles.label)}>
Authentication:{" "}
{envelope.settings.web.auth_enabled ? "required" : "not configured"}{" "}
<span {...stylex.props(styles.derived)}>
(derived from whether a password is stored)
</span>
</p>
<div {...stylex.props(styles.field)}>
<label htmlFor="web.password" {...stylex.props(styles.label)}>
password
</label>
<input
id="web.password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.field)}>
<label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
confirm password
</label>
<input
id="web.password_confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
{password !== "" && (
<p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
Changing the password signs out every session; you will be asked to log in
again.
</p>
)}
{passwordsMismatch && (
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match.
</p>
)}
</>
)}
</div>
</fieldset>
))}
<div {...stylex.props(styles.submitRow)}>
<button type="submit" disabled={saveDisabled} {...stylex.props(styles.save, shared.focusRing)}>
{mutation.isPending ? "Saving…" : "Save"}
</button>
{mutation.isError && <InlineError error={mutation.error} />}
</div>
</fieldset>
</form>
);
}
@@ -0,0 +1,327 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { queryKeys } from "@/lib/queries";
import type { Settings, SettingsPatch } from "@/lib/types";
import { DATABASE, MANAGED_FILE, baseSettings, renderPage, stubApi } from "./testFixtures";
/**
* System in database mode: the settings form, its diff contract, and the
* certificate reload that is a runtime action under both authorities.
*/
// `logging.level` is enum-backed, so the list covers both field renderings: an
// input whose label carries the mark, and a `Select` that cannot.
const RESTART_KEYS = ["dns.port", "web.port", "logging.level"];
let stored: Settings;
let putBodies: SettingsPatch[];
let putResponse: (() => Response | Promise<Response>) | null;
let restartPending: boolean;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
/** The server's echo: the patch merged into the stored settings, password excepted. */
function applyPatch(patch: SettingsPatch): void {
const target = stored as unknown as Record<string, Record<string, unknown>>;
for (const [section, fields] of Object.entries(patch)) {
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
if (section === "web" && key === "password") continue;
target[section]![key] = value;
}
}
}
/** Mirrors settings.zig: a patch touching only `web.password` applies live. */
function needsRestart(patch: SettingsPatch): boolean {
return Object.entries(patch).some(([section, fields]) =>
Object.keys(fields as Record<string, unknown>).some((key) => !(section === "web" && key === "password")),
);
}
beforeEach(() => {
stored = baseSettings();
putBodies = [];
putResponse = null;
restartPending = false;
});
afterEach(() => {
vi.unstubAllGlobals();
});
async function openSystem() {
stubApi(DATABASE, {
responses: {
"GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }),
"GET /api/settings": () => ({ settings: stored, restart_required: RESTART_KEYS }),
},
onWrite: (call) => {
if (call.url !== "/api/settings") return null;
const patch = call.body as SettingsPatch;
putBodies.push(patch);
if (putResponse !== null) return putResponse();
applyPatch(patch);
if (needsRestart(patch)) restartPending = true;
return json({ settings: stored, restart_required: RESTART_KEYS });
},
});
const router = await renderPage("/configuration/system", "System");
await screen.findByRole("button", { name: "Save" });
return router;
}
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
}
function restartNotice(): HTMLElement | null {
return screen.queryByText(/Saved changes are not running yet/);
}
test("no changes means Save is disabled, and authentication reads as derived", async () => {
await openSystem();
expect(saveButton().disabled).toBe(true);
const auth = screen.getByText(/^Authentication: required/);
expect(auth.textContent).toContain("derived from whether a password is stored");
});
test("a changed field enables Save and the PUT body is exactly the diff", async () => {
await openSystem();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
await waitFor(() => expect(saveButton().disabled).toBe(true));
});
test("a restart-required key is marked as one, from the envelope's list", async () => {
await openSystem();
expect(within(screen.getByRole("group", { name: "DNS" })).getByText("needs restart")).toBeTruthy();
// `rate_limit` is not on the list, so it carries no mark.
const cache = screen.getByRole("group", { name: "Cache" });
expect(within(cache).queryByText("needs restart")).toBeNull();
});
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
await openSystem();
const logging = screen.getByRole("group", { name: "Logging" });
// `logging.level` is a Select and `logging.output` is not on the list, so
// exactly one mark belongs to this section.
expect(within(logging).getAllByText("needs restart")).toHaveLength(1);
const marker = within(logging).getByText("needs restart");
const marked = marker.parentElement!;
const trigger = within(marked).getByRole("button", { name: /level$/ });
// Visible next to the control is not enough: the marker sits outside the
// label, so only `aria-describedby` carries it to a screen reader.
expect(marker.id).not.toBe("");
expect(trigger.getAttribute("aria-describedby")?.split(" ")).toContain(marker.id);
});
test("enum and boolean fields diff as their own types", async () => {
await openSystem();
const logging = screen.getByRole("group", { name: "Logging" });
// A RAC Select names its trigger with the current value and then the label,
// and carries the options only while the listbox is open.
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
fireEvent.click(within(logging).getByLabelText("hide_domains"));
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
});
test("clearing a number field disables Save instead of sending NaN", async () => {
await openSystem();
const cache = screen.getByRole("group", { name: "Cache" });
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
expect(saveButton().disabled).toBe(true);
});
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
const passwordInput = within(web).getByLabelText("password") as HTMLInputElement;
const confirmInput = within(web).getByLabelText("confirm password") as HTMLInputElement;
expect(passwordInput.value).toBe("");
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
expect(screen.getByText(/signs out every session/)).toBeTruthy();
expect(screen.getByText("Passwords do not match.")).toBeTruthy();
expect(saveButton().disabled).toBe(true);
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
expect(screen.queryByText("Passwords do not match.")).toBeNull();
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ web: { password: "hunter2" } });
await waitFor(() => expect(passwordInput.value).toBe(""));
expect(confirmInput.value).toBe("");
// The password applies live, so the server never raises the flag.
expect(restartNotice()).toBeNull();
});
test("a mixed patch makes the server owe a restart, and the shell says so", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
fireEvent.change(within(web).getByLabelText("session_ttl_hours"), { target: { value: "48" } });
fireEvent.change(within(web).getByLabelText("password"), { target: { value: "hunter2" } });
fireEvent.change(within(web).getByLabelText("confirm password"), { target: { value: "hunter2" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
await screen.findByText(/Saved changes are not running yet/);
});
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
await openSystem();
let resolvePut!: (response: Response) => void;
putResponse = () => new Promise<Response>((resolve) => (resolvePut = resolve));
const dns = screen.getByRole("group", { name: "DNS" });
const port = within(dns).getByLabelText(/^port/) as HTMLInputElement;
fireEvent.change(port, { target: { value: "5353" } });
fireEvent.click(saveButton());
await screen.findByRole("button", { name: "Saving…" });
expect(port.matches(":disabled")).toBe(true);
expect(screen.getByRole("group", { name: "Web" }).querySelector("#web\\.password")?.matches(":disabled")).toBe(
true,
);
applyPatch(putBodies[putBodies.length - 1]!);
resolvePut(json({ settings: stored, restart_required: RESTART_KEYS }));
await waitFor(() => expect(port.matches(":disabled")).toBe(false));
expect(saveButton().textContent).toBe("Save");
});
test("a 429 shows the rate-limit countdown from Retry-After", async () => {
await openSystem();
putResponse = () =>
new Response(JSON.stringify({ error: "too many requests" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "30" },
});
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
fireEvent.click(saveButton());
expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 30s.");
expect(restartNotice()).toBeNull();
});
test("a 400 validation error surfaces inline and owes no restart", async () => {
await openSystem();
putResponse = () => json({ error: "dns.port out of range" }, 400);
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "70000" } });
fireEvent.click(saveButton());
expect((await screen.findByRole("alert")).textContent).toBe("dns.port out of range");
expect(restartNotice()).toBeNull();
expect(saveButton().disabled).toBe(false);
});
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
const router = await openSystem();
const queryClient = router.options.context.queryClient;
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
// Someone else changes cache.size; a background refetch brings it in. The
// derived authentication line is read straight from the query data, so it
// witnesses that the refetch reached the component.
stored.cache.size = 99999;
stored.web.auth_enabled = false;
await act(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
});
await waitFor(() => expect(screen.getByText(/^Authentication: not configured/)).toBeTruthy());
const cache = screen.getByRole("group", { name: "Cache" });
expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
});
test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
await openSystem();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
await waitFor(() => expect(saveButton().disabled).toBe(true));
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5454" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(2));
expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
});
test("Reload certificates reports each endpoint's outcome in database mode (D8)", async () => {
stubApi(DATABASE, {
responses: {
"POST /api/certs/reload": {
doh: { enabled: true, reloaded: true, error: null },
dot: { enabled: false, reloaded: false, error: null },
},
},
});
await renderPage("/configuration/system", "System");
fireEvent.click(screen.getByRole("button", { name: "Reload certificates" }));
const result = await screen.findByText(/DoH: reloaded/);
expect(result.textContent).toContain("DoT: not enabled");
});
test("Reload certificates works under file authority too, and states a failure (D8)", async () => {
stubApi(MANAGED_FILE, {
responses: {
"POST /api/certs/reload": {
doh: { enabled: true, reloaded: false, error: "cert.pem: no such file" },
dot: { enabled: true, reloaded: true, error: null },
},
},
});
await renderPage("/configuration/system", "System");
const button = screen.getByRole("button", { name: "Reload certificates" }) as HTMLButtonElement;
expect(button.disabled).toBe(false);
fireEvent.click(button);
const result = await screen.findByText(/DoH: failed/);
expect(result.textContent).toContain("cert.pem: no such file");
expect(result.textContent).toContain("DoT: reloaded");
});
test("a failed certificate reload request is an error, not an outcome", async () => {
stubApi(DATABASE, {
onWrite: (call) => (call.url === "/api/certs/reload" ? json({ error: "reload is busy" }, 409) : null),
});
await renderPage("/configuration/system", "System");
fireEvent.click(screen.getByRole("button", { name: "Reload certificates" }));
expect((await screen.findByRole("alert")).textContent).toBe("reload is busy");
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { settingsQuery } from "@/lib/queries";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
import ReloadCertsAction from "./ReloadCertsAction";
import SettingsDefinitions from "./SettingsDefinitions";
import SettingsForm from "./SettingsForm";
import { styles } from "./styles";
/**
* System: what this process is running with. No tabs — the settings registry
* is already sectioned, and splitting it further would hide the section the
* reader came for behind a guess about which tab holds it.
*
* "Reload certificates" sits above the gate: it is a runtime action on the
* running listeners, so it works whatever owns the configuration and whether
* or not `/api/config/status` has answered.
*/
export default function SystemPage() {
const settings = useQuery(settingsQuery());
return (
<section>
<h1 {...stylex.props(styles.heading)}>System</h1>
<p {...stylex.props(styles.intro)}>
The service, storage, logging, TLS and web settings this nxdns process is running with.
</p>
<ReloadCertsAction />
<AuthorityGate>
{(status) =>
status.authority === "managed_file" ? (
<>
<FileModeNote path={status.path} />
<QueryPanel query={settings}>
{(envelope) => <SettingsDefinitions settings={envelope.settings} />}
</QueryPanel>
</>
) : (
<QueryPanel query={settings}>{(envelope) => <SettingsForm envelope={envelope} />}</QueryPanel>
)
}
</AuthorityGate>
</section>
);
}
@@ -4,15 +4,12 @@ import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100";
interface UpstreamFormProps {
initial?: Upstream;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: UpstreamInput) => Promise<void>;
onCancel?: () => void;
@@ -61,7 +58,7 @@ const styles = stylex.create({
},
});
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -145,12 +142,7 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
Enabled
</label>
<div {...stylex.props(styles.actions)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
<button type="submit" disabled={busy} {...stylex.props(shared.primaryButton, shared.focusRing)}>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
@@ -1,40 +1,21 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
import UpstreamForm from "./UpstreamForm";
import { styles as config } from "./styles";
const INTRO = "The pool builds its clients at startup, so an edit here takes effect at the next restart.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
maxWidth: "42rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
url: {
display: "block",
maxWidth: "18rem",
@@ -50,11 +31,79 @@ const styles = stylex.create({
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
absent: {
color: colors.textMuted,
},
});
export default function UpstreamsPage() {
export default function UpstreamsTab() {
const upstreams = useQuery(upstreamsQuery());
return (
<AuthorityGate>
{(status) => (
<div>
<p {...stylex.props(config.intro)}>{INTRO}</p>
{status.authority === "managed_file" && <FileModeNote path={status.path} />}
<QueryPanel query={upstreams}>
{(rows) =>
status.authority === "managed_file" ? (
<UpstreamsReadOnly upstreams={rows} />
) : (
<UpstreamsEditor upstreams={rows} />
)
}
</QueryPanel>
</div>
)}
</AuthorityGate>
);
}
function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
return (
<section {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>
Upstream pool
<code {...stylex.props(shared.mono, config.panelKey)}>upstreams</code>
</h2>
{upstreams.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no upstreams.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
</tr>
</thead>
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td {...stylex.props(shared.td, shared.mono)}>{u.url}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>{String(u.enabled)}</td>
<td {...stylex.props(shared.td)}>
{u.tls_name === "" ? (
<span {...stylex.props(styles.absent)}>empty</span>
) : (
u.tls_name
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
const queryClient = useQueryClient();
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
const [editing, setEditing] = useState<Upstream | null>(null);
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
@@ -62,7 +111,6 @@ export default function UpstreamsPage() {
const save = useMutation(upstreamUpdateMutation(queryClient));
const toggle = useMutation(upstreamUpdateMutation(queryClient));
const remove = useMutation(upstreamDeleteMutation(queryClient));
const readOnly = useReadOnlyConfig();
async function submitForm(input: UpstreamInput) {
if (editing === null) {
@@ -71,22 +119,18 @@ export default function UpstreamsPage() {
await save.mutateAsync({ id: editing.id, input });
setEditing(null);
}
raiseRestartBanner();
}
function toggleEnabled(u: Upstream) {
toggle.mutate(
{
id: u.id,
input: { url: u.url, priority: u.priority, enabled: !u.enabled, tls_name: u.tls_name },
},
{ onSuccess: () => raiseRestartBanner() },
);
toggle.mutate({
id: u.id,
input: { url: u.url, priority: u.priority, enabled: !u.enabled, tls_name: u.tls_name },
});
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
@@ -94,18 +138,12 @@ export default function UpstreamsPage() {
const tableError = remove.error ?? toggle.error;
return (
<section>
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p {...stylex.props(styles.intro)}>
The pool builds its clients at startup, so an edit here takes effect at the next restart. The Upstreams
row on Overview counts the running pool, not this list.
</p>
<div>
{upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
<p {...stylex.props(config.empty)}>No upstreams yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
@@ -131,8 +169,7 @@ export default function UpstreamsPage() {
type="checkbox"
aria-label={`${u.url} enabled`}
checked={u.enabled}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
disabled={toggle.isPending}
onChange={() => toggleEnabled(u)}
{...stylex.props(shared.focusRing)}
/>
@@ -143,22 +180,19 @@ export default function UpstreamsPage() {
<button
type="button"
onClick={() => setEditing(u)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
{...stylex.props(shared.linkButton, shared.focusRing)}
>
Edit
</button>
<button
type="button"
onClick={() => setPendingDelete(u)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
disabled={remove.isPending}
{...stylex.props(
shared.dangerLinkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -176,7 +210,6 @@ export default function UpstreamsPage() {
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
@@ -194,6 +227,6 @@ export default function UpstreamsPage() {
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
</div>
);
}
@@ -1,5 +1,5 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
forwardZoneCreateMutation,
@@ -11,9 +11,9 @@ import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import QueryPanel from "./QueryPanel";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
formHeading: {
@@ -89,14 +89,12 @@ const styles = stylex.create({
function ZoneForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: ForwardZone;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void;
@@ -142,12 +140,7 @@ function ZoneForm({
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
<button type="submit" disabled={busy} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
@@ -160,7 +153,7 @@ function ZoneForm({
}
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const query = useQuery(forwardZonesQuery());
const {
create,
update,
@@ -179,7 +172,6 @@ export default function ZonesTab() {
remove: forwardZoneDeleteMutation,
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
@@ -190,8 +182,6 @@ export default function ZonesTab() {
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add zone
@@ -199,90 +189,84 @@ export default function ZonesTab() {
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
)}
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Zone
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Resolver
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
No forward zones yet.
</td>
</tr>
)}
{zones.map((zone) => (
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
initial={zone}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
<QueryPanel query={query}>
{(zones) => (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Zone
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Resolver
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
No forward zones yet.
</td>
</>
</tr>
)}
</tr>
))}
</tbody>
</table>
</div>
{zones.map((zone) => (
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
initial={zone}
busy={update.isPending}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</QueryPanel>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
@@ -0,0 +1,222 @@
import { screen, waitFor, within } from "@testing-library/react";
import { CONFIG_PATH, DATABASE, MANAGED_FILE, contentArea, renderPage, renderRoute, stubApi } from "./testFixtures";
/**
* Authority is three-state, and the difference between the states is the whole
* point: an unresolved status is never treated as database mode, and file mode
* is a different rendering rather than the same forms with their controls
* turned off.
*/
const NEVER = new Promise<Response>(() => {});
/**
* Every button the file-mode configuration content is allowed to contain: the
* runtime actions, and the Retry an error surface offers. Anything else is a
* mutation control that should not have been rendered at all.
*/
const RUNTIME_BUTTONS = ["Update now", "Reload certificates", "Retry"];
afterEach(() => {
vi.unstubAllGlobals();
});
function mutationControls(): Element[] {
return [
...contentArea().querySelectorAll(
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
),
];
}
function buttonLabels(): string[] {
return [...contentArea().querySelectorAll("button")].map((button) => (button.textContent ?? "").trim());
}
test("a pending status renders neither form nor definition list on Protection", async () => {
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
await renderPage("/configuration/protection", "Protection");
expect(await screen.findByText(/which configuration source this server obeys/i)).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
expect(screen.queryByRole("button", { name: "Create" })).toBeNull();
});
test("a pending status still leaves the runtime actions live (R3-4)", async () => {
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
await renderPage("/configuration/system", "System");
const reload = (await screen.findByRole("button", { name: "Reload certificates" })) as HTMLButtonElement;
expect(reload.disabled).toBe(false);
// The settings form is not drawn behind it.
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
});
test("a pending status renders neither form nor definition list on Resolution", async () => {
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
await renderPage("/configuration/resolution", "Resolution");
expect(await screen.findByText(/which configuration source this server obeys/i)).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
expect(screen.queryByRole("button", { name: "Add upstream" })).toBeNull();
});
test("a failed status renders the error and a Retry, never editable forms", async () => {
stubApi(DATABASE, {
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
});
await renderPage("/configuration/protection", "Protection");
await screen.findByText(/cannot say whether a file or the database owns/i);
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
});
test("a failed status renders the error and a Retry on Resolution too", async () => {
stubApi(DATABASE, {
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
});
await renderPage("/configuration/resolution", "Resolution");
await screen.findByText(/cannot say whether a file or the database owns/i);
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
expect(screen.queryByRole("button", { name: "Add upstream" })).toBeNull();
});
test("a failed status keeps Update now enabled on the Sources tab", async () => {
stubApi(DATABASE, {
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
});
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText(/cannot say whether a file or the database owns/i);
const update = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
expect(update.disabled).toBe(false);
});
test("a failed status is announced by the shell on a page that is not configuration", async () => {
stubApi(DATABASE, {
responses: {
"GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }),
"GET /api/stats?period=24h": {
period: "24h",
since: 0,
until: 86400,
queries: 0,
blocked: 0,
clients: 0,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
},
},
});
renderRoute("/overview");
await waitFor(() => expect(screen.getByText(/file authority and pending restarts cannot be shown/i)).toBeTruthy());
});
test("database authority renders the editable groups form", async () => {
stubApi(DATABASE);
await renderPage("/configuration/protection", "Protection");
expect(await screen.findByRole("button", { name: "Create" })).toBeTruthy();
expect(screen.getByLabelText("New group")).toBeTruthy();
expect(screen.queryByText(/loaded from/i)).toBeNull();
});
test("file authority renders definitions with their exact ZON keys, not a form", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/protection", "Protection");
const note = await screen.findByText(/are loaded from/i);
expect(note.textContent).toContain(CONFIG_PATH);
const detail = await screen.findByText("Safe search");
expect(within(detail).getByText("groups[].safe_search")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Create" })).toBeNull();
});
test("file mode: Protection renders zero mutation controls (D12)", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/protection", "Protection");
await screen.findByText("groups[].safe_search");
expect(mutationControls()).toHaveLength(0);
for (const label of buttonLabels()) expect(RUNTIME_BUTTONS).toContain(label);
});
test("file mode: the Sources tab shows the catalogue with its key and only Update now", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("blocklist_sources");
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual(["Update now"]);
});
test("file mode: the catalogue keeps provenance and both skipped counters", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("blocklist_sources");
// Trackers is the suggested source, and the one with skipped lines: hiding
// either fact under file authority would hide a parse failure.
const row = screen.getByText("Trackers").closest("tr")!;
expect(within(row).getByText("Suggested")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
const cells = [...row.querySelectorAll("td")].map((cell) => cell.textContent);
expect(cells).toContain("3");
expect(cells).toContain("4");
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
});
test("file mode: the Resolution upstream pool is a table with no controls", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/resolution", "Resolution");
await screen.findByText("upstreams");
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual([]);
});
test("file mode: the Records tab is a table with no controls", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/resolution?tab=records", "Resolution");
await screen.findByText("local_records");
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual([]);
});
test("file mode: the Forward zones tab is a table with no controls", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/resolution?tab=zones", "Resolution");
await screen.findByText("forward_zones");
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual([]);
});
test("file mode: System renders scalars as definitions and keeps Reload certificates", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/system", "System");
const retention = await screen.findByText("Retention days");
expect(within(retention).getByText("logging.retention_days")).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual(["Reload certificates"]);
});
test("file mode: authentication is a derived status with no invented key (D13)", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/system", "System");
const auth = await screen.findByText("Authentication");
expect(auth.textContent).toBe("Authentication");
expect(within(auth).queryByText(/web\.auth_enabled/)).toBeNull();
expect(screen.queryByText("web.auth_enabled")).toBeNull();
});
@@ -0,0 +1,49 @@
import { useQuery } from "@tanstack/react-query";
import { configStatusQuery } from "@/lib/queries";
import type { ConfigStatus } from "@/lib/types";
/**
* Configuration authority as the UI must treat it: three states, never two.
*
* `undefined` is not database mode. Until `/api/config/status` answers, the
* running process may be file-managed, and a form rendered on that guess
* invites edits the server will reject. So a page shows neither an editable
* form nor a definition list until the query resolves, and a failed query is
* an explicit error with a way to retry.
*/
export type Authority =
| { state: "pending" }
| { state: "failed"; error: unknown; retry: () => void }
| { state: "resolved"; status: ConfigStatus };
/**
* The running server's configuration status. Every page may call this — it is
* the shared `["configStatus"]` key, so one subscription serves them all from
* cache.
*/
export function useAuthority(): Authority {
const query = useQuery(configStatusQuery());
if (query.isPending) return { state: "pending" };
if (query.isError) {
return {
state: "failed",
error: query.error,
retry: () => void query.refetch(),
};
}
return { state: "resolved", status: query.data };
}
/**
* True unless the server has said the database owns the configuration.
*
* The lock is global and it fails closed: pending, failed and `managed_file`
* all read as locked, because only a resolved database authority proves a
* configuration mutation can succeed. Runtime actions — pause, update now,
* reload certificates, deleting an observed client, login and logout — do not
* consult it; they work under every authority.
*/
export function useReadOnlyConfig(): boolean {
const authority = useAuthority();
return !(authority.state === "resolved" && authority.status.authority === "database");
}
@@ -0,0 +1,98 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { createAppRouter } from "@/routes";
import { validateGroupId, validateProtectionSearch, validateResolutionSearch } from "@/features/configuration/search";
import { DATABASE, renderPage, stubApi } from "./testFixtures";
/**
* The configuration URL is the applied state: which page, which tab, which
* group. A view of a page is a link, and the back button walks what the reader
* chose rather than what the page corrected on their behalf.
*/
afterEach(() => {
vi.unstubAllGlobals();
});
test("the six resource routes are gone, with no alias left behind", () => {
const paths = new Set(Object.keys(createAppRouter().routesByPath));
for (const gone of ["/groups", "/blocklists", "/rules", "/local-dns", "/upstreams", "/settings"]) {
expect(paths.has(gone)).toBe(false);
}
for (const kept of ["/configuration/protection", "/configuration/resolution", "/configuration/system"]) {
expect(paths.has(kept)).toBe(true);
}
// No landing route either: `/configuration` is not a page.
expect(paths.has("/configuration")).toBe(false);
});
test("an unknown tab falls back to the page default rather than an empty panel", () => {
expect(validateProtectionSearch({ tab: "upstreams" }).tab).toBe("groups");
expect(validateProtectionSearch({}).tab).toBe("groups");
expect(validateResolutionSearch({ tab: "sources" }).tab).toBe("upstreams");
expect(validateResolutionSearch({ tab: "zones" }).tab).toBe("zones");
});
test("a group id is a positive integer or nothing", () => {
expect(validateGroupId(3)).toBe(3);
expect(validateGroupId(0)).toBeUndefined();
expect(validateGroupId(-1)).toBeUndefined();
expect(validateGroupId(1.5)).toBeUndefined();
expect(validateGroupId("2")).toBeUndefined();
});
test("an unknown group falls back to the first one and rewrites the URL in place", async () => {
stubApi(DATABASE);
const router = await renderPage("/configuration/protection?group=99", "Protection");
await waitFor(() => expect(router.state.location.search.group).toBe(1));
await screen.findByRole("heading", { name: "default", level: 2 });
// `replace`: the corrected address did not become a place to go back to.
expect(router.history.length).toBe(1);
});
test("selecting a group pushes a history entry the back button walks", async () => {
stubApi(DATABASE);
const router = await renderPage("/configuration/protection", "Protection");
await waitFor(() => expect(router.state.location.search.group).toBe(1));
fireEvent.click(screen.getByRole("link", { name: "kids" }));
await waitFor(() => expect(router.state.location.search.group).toBe(2));
await screen.findByRole("heading", { name: "kids", level: 2 });
act(() => router.history.back());
await waitFor(() => expect(router.state.location.search.group).toBe(1));
});
test("the selected group survives a tab change, and the change is a history entry", async () => {
stubApi(DATABASE);
const router = await renderPage("/configuration/protection?group=2", "Protection");
await screen.findByRole("heading", { name: "kids", level: 2 });
fireEvent.click(screen.getByRole("tab", { name: "Sources" }));
await screen.findByRole("button", { name: "Update now" });
expect(router.state.location.search).toEqual({ tab: "sources", group: 2 });
act(() => router.history.back());
await waitFor(() => expect(router.state.location.search.tab).toBe("groups"));
expect(router.state.location.search.group).toBe(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
});
test("the Resolution tab is URL state too", async () => {
stubApi(DATABASE);
const router = await renderPage("/configuration/resolution", "Resolution");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await waitFor(() => expect(router.state.location.search.tab).toBe("zones"));
await screen.findByRole("button", { name: "Add zone" });
});
test("the loaders are started, not awaited: the page renders while a collection is in flight", async () => {
// Groups never answers. If the loader were awaited the navigation would hang
// and neither the heading nor the tabs would ever paint.
stubApi(DATABASE, { responses: { "GET /api/groups": new Promise<Response>(() => {}) } });
await renderPage("/configuration/protection", "Protection");
expect(screen.getByRole("tab", { name: "Sources" })).toBeTruthy();
expect(await screen.findByText("Loading…")).toBeTruthy();
});
@@ -0,0 +1,59 @@
/**
* The configuration pages' URL state, validated as pure functions so each
* route's `validateSearch` stays a one-liner and every rejection is testable
* without a router.
*
* A tab that is not one of the page's tabs falls back to that page's default:
* a hand-typed `?tab=upstreams` on Protection opens Protection's first tab
* rather than an empty panel.
*/
export const PROTECTION_TABS = ["groups", "sources"] as const;
export type ProtectionTab = (typeof PROTECTION_TABS)[number];
export const RESOLUTION_TABS = ["upstreams", "records", "zones"] as const;
export type ResolutionTab = (typeof RESOLUTION_TABS)[number];
/**
* Both fields are optional so a link to the page need not spell out a tab it
* does not care about — the same shape Overview's `period` uses. Validation
* still resolves an unknown tab to the page default, so a *rendered* page
* always has one.
*/
export interface ProtectionSearch {
tab?: ProtectionTab;
/**
* The selected group. Absent until the page resolves one, which it then
* writes back so a view of the page is a link.
*/
group?: number;
}
export interface ResolutionSearch {
tab?: ResolutionTab;
}
function validateTab<T extends string>(options: readonly T[], value: unknown, fallback: T): T {
return options.includes(value as T) ? (value as T) : fallback;
}
/**
* A group row id. Ids are positive integers, so anything else — a fraction, a
* string, a zero — is not an id that could ever exist and becomes `undefined`.
* An id that is well-formed but unknown is a different case: the page has to
* load the groups before it can tell, so it falls back there, not here.
*/
export function validateGroupId(value: unknown): number | undefined {
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
}
export function validateProtectionSearch(search: Record<string, unknown>): ProtectionSearch {
return {
tab: validateTab(PROTECTION_TABS, search["tab"], "groups"),
group: validateGroupId(search["group"]),
};
}
export function validateResolutionSearch(search: Record<string, unknown>): ResolutionSearch {
return { tab: validateTab(RESOLUTION_TABS, search["tab"], "upstreams") };
}
@@ -0,0 +1,132 @@
import type { Settings } from "@/lib/types";
/**
* The settings registry. One list drives both renderings: the editable form
* builds a control per field, the file-mode page builds a definition per
* field, and neither can drift from the other or from `Settings`.
*/
export interface FieldDef<S extends keyof Settings> {
key: keyof Settings[S] & string;
kind: "number" | "text" | "boolean" | readonly string[];
}
export interface SectionDef<S extends keyof Settings> {
section: S;
title: string;
fields: readonly FieldDef<S>[];
}
/** Binds each section's field keys to that section's Settings type at definition. */
function defineSection<S extends keyof Settings>(def: SectionDef<S>): SectionDef<S> {
return def;
}
/** The registry read back as a heterogeneous list, once the per-section binding has been proven. */
export type AnyFieldDef = { [S in keyof Settings]: FieldDef<S> }[keyof Settings];
export type AnySectionDef = { [S in keyof Settings]: SectionDef<S> }[keyof Settings];
/**
* A section's values as a string-keyed view. The keys are proven against
* `Settings[S]` where each section is defined; iterating the heterogeneous
* registry loses that correlation, so consumption widens here in one place.
*/
export function sectionValues(settings: Settings, section: keyof Settings): Record<string, unknown> {
return settings[section] as Record<string, unknown>;
}
const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [
{ key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" },
{ key: "port", kind: "number" },
{ key: "cert_path", kind: "text" },
{ key: "key_path", kind: "text" },
];
export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "upstream",
title: "Upstream",
fields: [
{ key: "attempt_timeout_ms", kind: "number" },
{ key: "read_timeout_ms", kind: "number" },
{ key: "total_timeout_ms", kind: "number" },
],
}),
defineSection({
section: "dns",
title: "DNS",
fields: [
{ key: "bind_ipv4", kind: "text" },
{ key: "bind_ipv6", kind: "text" },
{ key: "port", kind: "number" },
{ key: "rate_limit", kind: "number" },
{ key: "rate_window_seconds", kind: "number" },
],
}),
defineSection({
section: "blocking",
title: "Blocking",
fields: [
{ key: "response", kind: ["zero", "nxdomain"] },
{ key: "ttl", kind: "number" },
],
}),
defineSection({
section: "cache",
title: "Cache",
fields: [
{ key: "size", kind: "number" },
{ key: "negative_ttl_max", kind: "number" },
],
}),
defineSection({
section: "web",
title: "Web",
fields: [
{ key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" },
{ key: "port", kind: "number" },
{ key: "session_ttl_hours", kind: "number" },
{ key: "api_rate_limit_per_min", kind: "number" },
{ key: "api_localhost_exempt", kind: "boolean" },
{ key: "sse_max_connections_per_ip", kind: "number" },
{ key: "trusted_proxies", kind: "text" },
],
}),
defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }),
defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }),
defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }),
defineSection({
section: "logging",
title: "Logging",
fields: [
{ key: "level", kind: ["error", "warn", "info", "debug"] },
{ key: "retention_days", kind: "number" },
{ key: "query_log_buffer_max", kind: "number" },
{ key: "query_log_flush_interval_s", kind: "number" },
{ key: "hide_domains", kind: "boolean" },
{ key: "hide_client_ips", kind: "boolean" },
{ key: "output", kind: ["stderr", "syslog", "file"] },
{ key: "file_path", kind: "text" },
{ key: "max_size_mb", kind: "number" },
{ key: "max_files", kind: "number" },
],
}),
defineSection({
section: "disk",
title: "Disk",
fields: [
{ key: "min_free_mb", kind: "number" },
{ key: "warn_free_mb", kind: "number" },
],
}),
defineSection({
section: "blocklist_update",
title: "Blocklist Update",
fields: [
{ key: "enabled", kind: "boolean" },
{ key: "interval_hours", kind: "number" },
],
}),
];
+142
View File
@@ -0,0 +1,142 @@
/**
* The vocabulary the three configuration pages share. Both renderings — the
* editable forms and the file-mode definition lists — sit on the same panels
* and headings, so the page keeps its shape when authority changes.
*/
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
export const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
maxWidth: "48rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
panel: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
panelHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
/** The collection's own key in the configuration file, beside its heading. */
panelKey: {
marginLeft: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
note: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** The file-mode page note: where edits happen, and what applies them. */
fileNote: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceHover,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
empty: {
marginTop: "0.5rem",
color: colors.textMuted,
},
/** Master/detail: the group list beside the selected group. */
split: {
marginTop: "1rem",
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 900px)": "16rem 1fr",
},
alignItems: "start",
},
masterList: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
masterLink: {
display: "block",
borderRadius: "0.25rem",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
textDecorationLine: "none",
color: colors.textSecondary,
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
},
masterLinkActive: {
backgroundColor: colors.surfaceHover,
color: colors.text,
fontWeight: 500,
},
actionRow: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
success: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
/** The authority error state: no forms, no definition list, one way forward. */
blocked: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pending: {
marginTop: "1rem",
color: colors.textMuted,
},
restartTag: {
marginLeft: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
@@ -0,0 +1,264 @@
/**
* The fixtures and the stubbed API the configuration tests share.
*
* All three pages read the same handful of collections plus
* `/api/config/status`, so a per-file copy of the stub would be five copies of
* one contract drifting apart. Test-only: nothing in `src` imports it, so it
* never reaches the bundle.
*/
import { render, screen } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { health } from "@/lib/healthFixture";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { ConfigStatus, Settings } from "@/lib/types";
export const CONFIG_PATH = "/etc/nxdns/config.zon";
export const RECONCILED_AT = 1754899200;
export const DATABASE: ConfigStatus = {
authority: "database",
path: null,
reconciled_at: null,
restart_pending: false,
};
export const MANAGED_FILE: ConfigStatus = {
authority: "managed_file",
path: CONFIG_PATH,
reconciled_at: RECONCILED_AT,
restart_pending: false,
};
export function baseSettings(): Settings {
return {
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 },
web: {
enabled: true,
bind: "127.0.0.1",
port: 8080,
session_ttl_hours: 24,
api_rate_limit_per_min: 60,
api_localhost_exempt: true,
sse_max_connections_per_ip: 2,
trusted_proxies: "",
auth_enabled: true,
},
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
edns: { ecs_mode: "strip" },
logging: {
level: "info",
retention_days: 30,
query_log_buffer_max: 10000,
query_log_flush_interval_s: 60,
hide_domains: false,
hide_client_ips: false,
output: "stderr",
file_path: "",
max_size_mb: 50,
max_files: 3,
},
disk: { min_free_mb: 100, warn_free_mb: 500 },
blocklist_update: { enabled: true, interval_hours: 24 },
};
}
export const GROUPS = [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
];
export const BLOCKLISTS = [
{
id: 1,
url: "https://example.com/ads.txt",
name: "Ads",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 100,
wildcard_count: 2,
exception_count: 1,
skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null,
},
{
id: 2,
url: "https://example.com/trackers.txt",
name: "Trackers",
enabled: false,
is_suggested: true,
last_updated: 1700000000,
domain_count: 50,
wildcard_count: 0,
exception_count: 0,
skipped_regex_count: 3,
skipped_unsupported_count: 4,
checksum: null,
},
];
export const RULES = [
{
id: 1,
group_id: 1,
group: "default",
pattern: "ads.example.com",
kind: "exact",
action: "block",
created_at: 1700000000,
},
{
id: 2,
group_id: 2,
group: "kids",
pattern: "*.social.example",
kind: "wildcard",
action: "block",
created_at: 1700000100,
},
];
export const CLIENTS = [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
];
export const UPSTREAMS = [
{ id: 1, url: "udp://1.1.1.1:53", priority: 100, enabled: true, tls_name: "" },
{ id: 2, url: "tls://9.9.9.9:853", priority: 200, enabled: false, tls_name: "dns.quad9.net" },
];
export const LOCAL_RECORDS = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.5", ttl: 300 }];
export const FORWARD_ZONES = [{ id: 1, zone: "lan.home", resolver: "udp://192.168.1.1:53" }];
export interface Call {
url: string;
method: string;
body: unknown;
}
export interface StubOptions {
/**
* Overrides and additions, keyed `"GET /api/x"`. A `Response` is used
* verbatim; a function is called per request, so a test can change what the
* server says between two reads of the same endpoint.
*/
responses?: Record<string, unknown>;
/**
* First refusal on every non-GET; return null to fall through to the default
* echo. An unsettled promise holds the mutation in flight, which is how a
* test observes a pending control.
*/
onWrite?: (call: Call) => Response | Promise<Response> | null;
}
function defaultResponses(status: ConfigStatus): Record<string, unknown> {
return {
"GET /api/config/status": status,
"GET /api/health": health(),
"GET /api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
"GET /api/groups": { groups: GROUPS },
"GET /api/groups/1/sources": { source_ids: [1] },
"GET /api/groups/2/sources": { source_ids: [] },
"GET /api/blocklists": { blocklists: BLOCKLISTS },
"GET /api/rules": { rules: RULES },
"GET /api/clients": { clients: CLIENTS },
"GET /api/client-prefixes": { client_prefixes: [] },
"GET /api/upstreams": { upstreams: UPSTREAMS },
"GET /api/local-records": { local_records: LOCAL_RECORDS },
"GET /api/forward-zones": { forward_zones: FORWARD_ZONES },
"GET /api/settings": { settings: baseSettings(), restart_required: ["dns.port", "web.port"] },
};
}
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
/** Installs the stub and returns the list every non-GET request is appended to. */
export function stubApi(status: ConfigStatus = DATABASE, options: StubOptions = {}): Call[] {
const map = { ...defaultResponses(status), ...options.responses };
const calls: Call[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
const key = `${method} ${url}`;
const configured = map[key];
// A promise is handed back unsettled on purpose: it is how a test holds a
// query in its pending state for as long as it needs to.
if (configured instanceof Promise) return configured;
if (configured instanceof Response) return configured.clone();
if (typeof configured === "function") return json((configured as () => unknown)());
if (method !== "GET") {
calls.push({ url, method, body });
const override = options.onWrite?.({ url, method, body });
if (override !== null && override !== undefined) return override;
if (configured !== undefined) return json(configured);
if (method === "DELETE") return new Response(null, { status: 204 });
return json(body ?? {});
}
if (configured === undefined) return json({ error: `not stubbed: ${key}` }, 404);
return json(configured);
}),
);
return calls;
}
export function renderRoute(route: string) {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
export async function renderPage(route: string, heading: string) {
const router = renderRoute(route);
await screen.findByRole("heading", { name: heading, level: 1 });
return router;
}
/** The configuration page's own content, excluding the shell chrome around it. */
export function contentArea(): HTMLElement {
const main = document.querySelector("main");
if (main === null) throw new Error("no main element");
return main;
}
@@ -100,7 +100,9 @@ test("an open episode shows its facts, its copy and the error the server sent",
expect(screen.getByText(EVENT_COPY["blocklist.refresh"].impact)).toBeTruthy();
expect(screen.getByText(EVENT_COPY["blocklist.refresh"].remediation)).toBeTruthy();
expect(screen.getByText("download failed: ConnectionTimedOut")).toBeTruthy();
expect(screen.getByRole("link", { name: "Go to Blocklists" }).getAttribute("href")).toBe("/blocklists");
expect(screen.getByRole("link", { name: "Go to Blocklist sources" }).getAttribute("href")).toBe(
"/configuration/protection?tab=sources",
);
});
test("a resolved episode states how long it lasted, not how long it has run", async () => {
@@ -207,9 +207,27 @@ export default function DiagnosticDetailPage() {
{copy.link !== undefined && (
<p {...stylex.props(styles.links)}>
<Link to={copy.link.to} {...stylex.props(styles.link, shared.focusRing)}>
Go to {copy.link.label}
</Link>
{copy.link.to === "/configuration/protection" ? (
<Link
to="/configuration/protection"
search={{ tab: "sources" }}
{...stylex.props(styles.link, shared.focusRing)}
>
Go to {copy.link.label}
</Link>
) : copy.link.to === "/configuration/resolution" ? (
<Link
to="/configuration/resolution"
search={{ tab: "upstreams" }}
{...stylex.props(styles.link, shared.focusRing)}
>
Go to {copy.link.label}
</Link>
) : (
<Link to={copy.link.to} {...stylex.props(styles.link, shared.focusRing)}>
Go to {copy.link.label}
</Link>
)}
</p>
)}
@@ -99,7 +99,7 @@ test("the five conditions are stated in words, healthy ones without a way out",
expect(within(strip()).queryByRole("link")).toBeNull();
});
test("protection unavailable sends the reader to Blocklists, upstreams to Upstreams", async () => {
test("protection unavailable sends the reader to the sources, upstreams to the pool", async () => {
healthBody = health({
protection: { state: "unavailable", until: null },
upstreams: { state: "unavailable", available: 0, total: 2 },
@@ -107,10 +107,12 @@ test("protection unavailable sends the reader to Blocklists, upstreams to Upstre
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Protection")).getByRole("link", { name: "Blocklists" }).getAttribute("href")).toBe(
"/blocklists",
expect(within(fact("Protection")).getByRole("link", { name: "Blocklist sources" }).getAttribute("href")).toBe(
"/configuration/protection?tab=sources",
);
expect(within(fact("Upstreams")).getByRole("link", { name: "Upstreams" }).getAttribute("href")).toBe(
"/configuration/resolution?tab=upstreams",
);
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 () => {
@@ -159,7 +161,8 @@ test("the strip says it is loading before the first reading, never empty conditi
renderDiagnostics();
expect(await screen.findByText("Loading status…")).toBeTruthy();
expect(screen.queryByText("Protection")).toBeNull();
// Scoped to the page: "Protection" is also a nav destination now.
expect(within(document.querySelector("main") as HTMLElement).queryByText("Protection")).toBeNull();
release();
await waitFor(() => expect(strip()).toBeTruthy());
+18 -1
View File
@@ -124,8 +124,25 @@ function FactLinkAnchor({ link }: { link: FactLink }) {
</Link>
);
}
// The tab belongs to the destination, not to the fact: a source that stopped
// loading is read on Protection's Sources tab, an upstream on Resolution's.
if (link.to === "/configuration/protection") {
return (
<Link
to="/configuration/protection"
search={{ tab: "sources" }}
{...stylex.props(styles.link, shared.focusRing)}
>
{link.label}
</Link>
);
}
return (
<Link to={link.to} {...stylex.props(styles.link, shared.focusRing)}>
<Link
to="/configuration/resolution"
search={{ tab: "upstreams" }}
{...stylex.props(styles.link, shared.focusRing)}
>
{link.label}
</Link>
);
+8 -6
View File
@@ -8,14 +8,16 @@
* `DIAGNOSTIC_CODES` to prove it at runtime too. A sixteenth code added to the
* enum fails `tsc` here before it can reach the page as a bare dotted string.
*
* `link` points at the configuration surface that governs the failure. Those
* are today's routes; the navigation restructure re-points them.
* `link` points at the configuration surface that governs the failure. The
* path alone decides which tab of that surface the detail page opens, so the
* copy never carries a tab of its own to drift from it.
*/
import { DIAGNOSTIC_CODES, type DiagnosticCode } from "@/lib/types";
/** The literal paths keep `link.to` assignable to a typed router `Link`. */
export type CopyLinkPath = "/settings" | "/blocklists" | "/upstreams" | "/clients";
export type CopyLinkPath =
"/configuration/system" | "/configuration/protection" | "/configuration/resolution" | "/clients";
export interface EventCopy {
title: string;
@@ -39,9 +41,9 @@ export function copyFor(code: DiagnosticCode): EventCopy {
);
}
const SETTINGS = { to: "/settings", label: "Settings" } as const;
const BLOCKLISTS = { to: "/blocklists", label: "Blocklists" } as const;
const UPSTREAMS = { to: "/upstreams", label: "Upstreams" } as const;
const SETTINGS = { to: "/configuration/system", label: "System" } as const;
const BLOCKLISTS = { to: "/configuration/protection", label: "Blocklist sources" } as const;
const UPSTREAMS = { to: "/configuration/resolution", label: "Upstreams" } as const;
const CLIENTS = { to: "/clients", label: "Clients" } as const;
/**
@@ -28,7 +28,7 @@ test("protection: active, paused indefinitely, paused until a time, unavailable"
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" });
expect(gone.link).toEqual({ kind: "route", to: "/configuration/protection", label: "Blocklist sources" });
});
test("a pause never reads as a fault, and never carries a way out", () => {
@@ -44,7 +44,7 @@ test("upstreams count the enabled pool, and only an empty one links out", () =>
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" });
expect(none.link).toEqual({ kind: "route", to: "/configuration/resolution", label: "Upstreams" });
});
test("query history: losing blames the disk gate, a failed writer blames the query log", () => {
@@ -20,7 +20,7 @@ export type FactTone = "ok" | "notice" | "warn" | "danger";
* that can fix the condition.
*/
export type FactLink =
| { kind: "route"; to: "/blocklists" | "/upstreams"; label: string }
| { kind: "route"; to: "/configuration/protection" | "/configuration/resolution"; label: string }
| { kind: "filter"; component: string; label: string };
export interface HealthFact {
@@ -50,7 +50,7 @@ function protectionFact(protection: Health["protection"], locale?: string, timeZ
tone: "danger",
value: "Unavailable",
detail: "No filter snapshot is published, so queries are not being filtered.",
link: { kind: "route", to: "/blocklists", label: "Blocklists" },
link: { kind: "route", to: "/configuration/protection", label: "Blocklist sources" },
};
}
if (protection.state === "paused") {
@@ -105,7 +105,7 @@ export function healthFacts(health: Health, locale?: string, timeZone?: string):
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 }
? { link: { kind: "route", to: "/configuration/resolution", label: "Upstreams" } as FactLink }
: {}),
},
queryHistoryFact(health.query_history, locale, timeZone),
@@ -1,135 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import type { Mock } from "vitest";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const BLOCKLISTS = {
blocklists: [
{
id: 1,
url: "https://example.com/ads.txt",
name: "Ads",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 100,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
{
id: 2,
url: "https://example.com/malware.txt",
name: "Malware",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 50,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
const BASE = {
"GET /api/groups": GROUPS,
"GET /api/blocklists": BLOCKLISTS,
"GET /api/version": VERSION,
"GET /api/groups/2/sources": { source_ids: [1] },
"PUT /api/groups/2/sources": { source_ids: [1, 2] },
};
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
}
async function renderGroupsPage(map: Record<string, unknown>) {
stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/groups"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Groups" });
}
function groupRow(name: string): HTMLElement {
const row = screen.getByText(name).closest("li");
if (row === null) throw new Error(`no row for group ${name}`);
return row;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("lists groups; the default group blocks rename and delete client-side", async () => {
await renderGroupsPage(BASE);
const defaultRow = groupRow("default");
expect((within(defaultRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(defaultRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect(within(defaultRow).getByText("The default group cannot be renamed or deleted.")).toBeTruthy();
const kidsRow = groupRow("kids");
expect((within(kidsRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
});
test("expanding sources loads the set, toggling saves the full set via PUT", async () => {
await renderGroupsPage(BASE);
const kidsRow = groupRow("kids");
fireEvent.click(within(kidsRow).getByRole("button", { name: "Sources" }));
const ads = (await within(kidsRow).findByRole("checkbox", { name: "Ads" })) as HTMLInputElement;
const malware = within(kidsRow).getByRole("checkbox", { name: "Malware" }) as HTMLInputElement;
expect(ads.checked).toBe(true);
expect(malware.checked).toBe(false);
const save = within(kidsRow).getByRole("button", { name: "Save sources" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(malware);
expect(save.disabled).toBe(false);
fireEvent.click(save);
await waitFor(() => expect(save.disabled).toBe(true));
const calls = (fetch as unknown as Mock).mock.calls as [RequestInfo | URL, RequestInit | undefined][];
const put = calls.find(([, init]) => init?.method === "PUT");
expect(put).toBeTruthy();
expect(String(put![0])).toBe("/api/groups/2/sources");
expect(JSON.parse(String(put![1]?.body))).toEqual({ source_ids: [1, 2] });
expect(malware.checked).toBe(true);
});
-289
View File
@@ -1,289 +0,0 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
blocklistsQuery,
groupCreateMutation,
groupDeleteMutation,
groupsQuery,
groupUpdateMutation,
} from "@/lib/queries";
import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
createForm: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
fieldLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
list: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
row: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
rowControls: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
renameForm: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
name: {
fontWeight: 500,
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
actions: {
marginLeft: "auto",
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
groupButton: {
opacity: { default: 1, ":disabled": 0.5 },
},
destructive: {
color: colors.danger,
},
lockNote: {
marginTop: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery());
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const queryClient = useQueryClient();
const createMutation = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
const readOnly = useReadOnlyConfig();
return (
<section>
<h1 {...stylex.props(styles.heading)}>Groups</h1>
<form
{...stylex.props(styles.createForm)}
onSubmit={(event) => {
event.preventDefault();
const name = newName.trim();
if (name === "") return;
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group
</label>
<input
id="new-group-name"
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
disabled={readOnly}
{...stylex.props(shared.smallInput, shared.focusRing)}
/>
<button
type="submit"
disabled={createMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Create
</button>
</form>
<InlineError error={createMutation.error} />
<ul {...stylex.props(styles.list)}>
{groups.map((group) => (
<GroupRow key={group.id} group={group} blocklists={blocklists} />
))}
</ul>
</section>
);
}
function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[] }) {
const queryClient = useQueryClient();
const updateMutation = useMutation(groupUpdateMutation(queryClient));
const deleteMutation = useMutation(groupDeleteMutation(queryClient));
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(group.name);
const [confirming, setConfirming] = useState(false);
const [expanded, setExpanded] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
const readOnly = useReadOnlyConfig();
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return (
<li {...stylex.props(styles.row)}>
<div {...stylex.props(styles.rowControls)}>
{renaming ? (
<form
{...stylex.props(styles.renameForm)}
onSubmit={(event) => {
event.preventDefault();
const trimmed = name.trim();
if (trimmed === "") return;
updateMutation.mutate(
{ id: group.id, input: { name: trimmed, safe_search: group.safe_search } },
{ onSuccess: () => setRenaming(false) },
);
}}
>
<input
type="text"
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus
/>
<button
type="submit"
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Save
</button>
<button
type="button"
onClick={() => {
setName(group.name);
setRenaming(false);
}}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel
</button>
</form>
) : (
<span {...stylex.props(styles.name)}>{group.name}</span>
)}
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.focusRing)}
onChange={(event) =>
updateMutation.mutate({
id: group.id,
input: { name: group.name, safe_search: event.target.checked },
})
}
/>
Safe search
</label>
<span {...stylex.props(styles.actions)}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Sources
</button>
{!renaming && (
<button
type="button"
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => {
setName(group.name);
setRenaming(true);
}}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Rename
</button>
)}
{confirming ? (
<>
<button
type="button"
onClick={() => {
setConfirming(false);
deleteMutation.mutate(group.id);
}}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirming(false)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel
</button>
</>
) : (
<button
type="button"
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => setConfirming(true)}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
>
Delete
</button>
)}
</span>
</div>
{isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={updateMutation.error ?? deleteMutation.error} />
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
</li>
);
}
@@ -1,170 +0,0 @@
import { fireEvent, render, screen, waitFor } 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 type { LocalRecord, LocalRecordInput } from "@/lib/types";
let records: LocalRecord[];
let fetchMock: ReturnType<typeof createFetchMock>;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
function createFetchMock() {
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
if (url === "/api/local-records" && method === "GET") return json({ local_records: records });
if (url === "/api/local-records" && method === "POST") {
const body = JSON.parse(String(init?.body)) as LocalRecordInput;
const created: LocalRecord = { id: 99, ttl: body.ttl ?? 300, ...body };
records = [...records, created];
return json(created, 201);
}
if (url.startsWith("/api/local-records/") && method === "DELETE") {
const id = Number(url.slice("/api/local-records/".length));
records = records.filter((record) => record.id !== id);
return new Response(null, { status: 204 });
}
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
}
return json({ error: "not stubbed" }, 404);
});
}
beforeEach(() => {
records = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.10", ttl: 300 }];
fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderPage() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/local-dns"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
test("renders the records table and switches to the forward zones tab", async () => {
renderPage();
await screen.findByRole("heading", { name: "Local DNS" });
await screen.findByText("nas.lan.home");
expect(screen.getByText("192.168.1.10")).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
});
test("the arrow keys move between tabs", async () => {
renderPage();
await screen.findByText("nas.lan.home");
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
const records = screen.getByRole("tab", { name: "Records" });
expect(records.getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
const zones = screen.getByRole("tab", { name: "Forward zones" });
expect(zones.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
await screen.findByText("lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
await screen.findByText("nas.lan.home");
});
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
// The record type is a RAC Select now: open the listbox, then pick.
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("printer.lan.home");
const post = fetchMock.mock.calls.find(
([input, init]) => init?.method === "POST" && String(input) === "/api/local-records",
);
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
});
test("cancelling the record delete dialog sends no request", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
expect(screen.getByText("nas.lan.home")).toBeTruthy();
});
test("confirming the record delete dialog issues the DELETE", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
),
).toBe(true),
);
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
});
test("the forward zone delete dialog names the zone and confirms", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
),
).toBe(true),
);
});
-27
View File
@@ -1,27 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab";
import Tabs from "@/ui/Tabs";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
});
export default function LocalDnsPage() {
return (
<section>
<h1 {...stylex.props(styles.heading)}>Local DNS</h1>
<Tabs
label="Local DNS"
tabs={[
{ id: "records", label: "Records", content: <RecordsTab /> },
{ id: "zones", label: "Forward zones", content: <ZonesTab /> },
]}
/>
</section>
);
}
-233
View File
@@ -1,233 +0,0 @@
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";
const RESPONSES: Record<string, unknown> = {
"/api/rules": {
rules: [
{
id: 1,
group_id: 1,
group: "Default",
pattern: "ads.example.com",
kind: "exact",
action: "block",
created_at: 1700000000,
},
{
id: 2,
group_id: 2,
group: "Kids",
pattern: "*.cdn.example.com",
kind: "wildcard",
action: "allow",
created_at: 1700000100,
},
],
},
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
// The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[];
let posted: { pattern: string; kind: string }[];
function deleteCalls(): string[] {
return deleted;
}
beforeEach(() => {
groups = [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
];
deleted = [];
posted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/rules" && init?.method === "POST") {
posted.push(JSON.parse(String(init.body)) as { pattern: string; kind: string });
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "5" },
});
}
const payload = url === "/api/groups" ? { groups } : RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderRulesRoute() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/rules"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
/**
* A RAC Select names its trigger with the current value and then the label, so
* the label alone is a suffix match. Opening it is the only way to read the
* options: there is no `<select>` carrying them any more.
*/
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function optionsOf(label: string): Promise<(string | null)[]> {
fireEvent.click(trigger(label));
const options = await screen.findAllByRole("option");
const labels = options.map((option) => option.textContent);
// Re-picking the current value closes the listbox and changes nothing.
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
return labels;
}
test("renders the rule table and the create form with contract enums", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const table = within(screen.getByRole("table"));
expect(table.getByText("ads.example.com")).toBeTruthy();
expect(table.getByText("*.cdn.example.com")).toBeTruthy();
expect(table.getByText("block")).toBeTruthy();
expect(table.getByText("allow")).toBeTruthy();
expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard", "regex"]);
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
});
test("the kind selector can select the regex option, not only list it", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(trigger("Kind"));
const options = await screen.findAllByRole("option");
const regex = options.find((option) => option.textContent === "regex");
expect(regex).toBeTruthy();
fireEvent.click(regex!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
expect(trigger("Kind").textContent).toContain("regex");
});
async function selectKind(label: string): Promise<void> {
fireEvent.click(trigger("Kind"));
const options = await screen.findAllByRole("option");
fireEvent.click(options.find((option) => option.textContent === label)!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
}
// A regex is stored and matched byte for byte, so whitespace inside it is data,
// not slop the UI may drop. Exact and wildcard are normalized server-side.
test("a regex pattern is posted untrimmed, an exact pattern is trimmed", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
await selectKind("regex");
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " foo|bar " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(posted).toHaveLength(1));
expect(posted[0]).toMatchObject({ pattern: " foo|bar ", kind: "regex" });
await selectKind("exact");
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(posted).toHaveLength(2));
expect(posted[1]).toMatchObject({ pattern: "ads.example.net", kind: "exact" });
});
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const input = screen.getByLabelText("Pattern");
expect(input.getAttribute("autocapitalize")).toBe("none");
expect(input.getAttribute("autocorrect")).toBe("off");
expect(input.getAttribute("spellcheck")).toBe("false");
});
test("rule create shows a countdown when rate limited with Retry-After", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 5s.");
});
test("the group select preselects the id-1 default, not the alphabetically first group", async () => {
groups = [
{ id: 5, name: "Attic", safe_search: false },
{ id: 1, name: "Default", safe_search: false },
];
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
expect(trigger("Group").textContent).toContain("Default");
});
test("the group select falls back to the first group when the default is absent", async () => {
groups = [
{ id: 5, name: "Attic", safe_search: false },
{ id: 7, name: "Basement", safe_search: false },
];
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect(trigger("Group").textContent).toContain("Attic");
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleteCalls()).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that rule", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
});
-236
View File
@@ -1,236 +0,0 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
{ value: "regex", label: "regex" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pattern: {
fontWeight: 500,
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1.5rem",
maxWidth: "36rem",
},
formHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** One column on a phone, three from the `sm` breakpoint, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
},
},
submitRow: {
display: "flex",
},
});
export default function RulesPage() {
const queryClient = useQueryClient();
const { data: rules } = useSuspenseQuery(rulesQuery());
const { data: groups } = useSuspenseQuery(groupsQuery());
const create = useMutation(ruleCreateMutation(queryClient));
const remove = useMutation(ruleDeleteMutation(queryClient));
const [pattern, setPattern] = useState("");
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// A regex pattern is stored and matched byte for byte, so the UI must not
// edit it: trimming here would make a UI-created rule differ from the same
// bytes posted to /api/rules. Name-shaped kinds are normalized server-side,
// so trimming them only spares a pasted space a 400.
const sent = kind === "regex" ? pattern : pattern.trim();
create.mutate({ group_id: groupId, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
return (
<section>
<h1 {...stylex.props(styles.heading)}>Rules</h1>
{rules.length === 0 ? (
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Group</th>
<th {...stylex.props(shared.th)}>Created</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
</span>
</td>
<td {...stylex.props(shared.td)}>{rule.group}</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td {...stylex.props(shared.td)}>
<button
type="button"
onClick={() => setPendingDelete(rule)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<InlineError error={remove.error} />
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
<div>
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern
</label>
<input
id="rule-pattern"
type="text"
required
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
// A phone keyboard capitalizing the first letter is silent for
// exact and wildcard (normalized server-side) but fatal for a
// regex, which matches the lowercase query name byte for byte.
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.fieldGrid)}>
<Select
label="Kind"
value={kind}
onChange={(value) => setKind(value as RuleKind)}
options={KIND_OPTIONS}
/>
<Select
label="Action"
value={action}
onChange={(value) => setAction(value as RuleAction)}
options={ACTION_OPTIONS}
/>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<div {...stylex.props(styles.submitRow)}>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
</div>
<InlineError error={create.error} />
</form>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
@@ -1,33 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority";
const styles = stylex.create({
banner: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
/**
* File authority is a standing condition, not an event, so this banner has no
* dismiss button: it stays up for as long as the process runs from a file.
*/
export default function ReadOnlyConfigBanner() {
const authority = useAuthority();
if (authority?.mode !== "managed_file") return null;
return (
<div role="status" {...stylex.props(styles.banner)}>
Configuration is managed by <code {...stylex.props(shared.mono)}>{authority.path}</code>. Edit the file and
restart nxdns to change it; the server rejects edits made here.
</div>
);
}
@@ -1,28 +0,0 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import RestartBanner from "@/features/settings/RestartBanner";
import { dismissRestartBanner, raiseRestartBanner } from "@/features/settings/restartBanner";
beforeEach(() => {
act(() => dismissRestartBanner());
});
test("hidden until raised, dismissible, and a new raise shows it again", () => {
render(<RestartBanner />);
expect(screen.queryByRole("status")).toBeNull();
act(() => raiseRestartBanner());
expect(screen.getByRole("status").textContent).toContain("Restart nxdns to apply");
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByRole("status")).toBeNull();
act(() => raiseRestartBanner());
expect(screen.getByRole("status")).toBeTruthy();
});
test("raising while already raised keeps the banner up", () => {
render(<RestartBanner />);
act(() => raiseRestartBanner());
act(() => raiseRestartBanner());
expect(screen.getByRole("status")).toBeTruthy();
});
@@ -1,49 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
const styles = stylex.create({
banner: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
message: {
flex: 1,
},
dismiss: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.warnBorderStrong,
backgroundColor: "transparent",
color: "inherit",
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
},
});
export default function RestartBanner() {
const raised = useRestartBanner();
if (!raised) return null;
return (
<div role="status" {...stylex.props(styles.banner)}>
<span {...stylex.props(styles.message)}>Changes saved. Restart nxdns to apply.</span>
<button type="button" onClick={dismissRestartBanner} {...stylex.props(styles.dismiss, shared.focusRing)}>
Dismiss
</button>
</div>
);
}
@@ -1,278 +0,0 @@
import { Suspense } from "react";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act } from "react";
import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage";
import RestartBanner from "@/features/settings/RestartBanner";
import { dismissRestartBanner } from "@/features/settings/restartBanner";
import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import type { Settings, SettingsPatch } from "@/lib/types";
function baseSettings(): Settings {
return {
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 },
web: {
enabled: true,
bind: "127.0.0.1",
port: 8080,
session_ttl_hours: 24,
api_rate_limit_per_min: 60,
api_localhost_exempt: true,
sse_max_connections_per_ip: 2,
trusted_proxies: "",
auth_enabled: true,
},
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
edns: { ecs_mode: "strip" },
logging: {
level: "info",
retention_days: 30,
query_log_buffer_max: 10000,
query_log_flush_interval_s: 60,
hide_domains: false,
hide_client_ips: false,
output: "stderr",
file_path: "",
max_size_mb: 50,
max_files: 3,
},
disk: { min_free_mb: 100, warn_free_mb: 500 },
blocklist_update: { enabled: true, interval_hours: 24 },
};
}
let putBodies: SettingsPatch[];
let putResponse: () => Response | Promise<Response>;
let storedSettings: Settings;
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
function applyPatch(patch: SettingsPatch): void {
const settings = storedSettings as unknown as Record<string, Record<string, unknown>>;
for (const [section, fields] of Object.entries(patch)) {
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
if (section === "web" && key === "password") continue;
settings[section]![key] = value;
}
}
}
beforeEach(() => {
act(() => dismissRestartBanner());
putBodies = [];
storedSettings = baseSettings();
putResponse = () => {
applyPatch(putBodies[putBodies.length - 1]!);
return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] });
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url !== "/api/settings") return jsonResponse({ error: "not stubbed" }, 404);
if (init?.method === "PUT") {
putBodies.push(JSON.parse(String(init.body)) as SettingsPatch);
return putResponse();
}
return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] });
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
async function renderPage(): Promise<QueryClient> {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<RestartBanner />
<Suspense fallback={<p>loading</p>}>
<SettingsPage />
</Suspense>
</QueryClientProvider>,
);
await screen.findByRole("heading", { name: "Settings" });
return queryClient;
}
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
}
test("no changes means Save is disabled and auth_enabled shows read-only", async () => {
await renderPage();
expect(saveButton().disabled).toBe(true);
expect(screen.getByText(/auth_enabled: true/).textContent).toContain("read-only");
});
test("a changed field enables Save and the PUT body is exactly the diff", async () => {
await renderPage();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
expect((await screen.findByRole("status")).textContent).toContain("Restart nxdns to apply");
await waitFor(() => expect(saveButton().disabled).toBe(true));
});
test("enum and boolean fields diff as their own types", async () => {
await renderPage();
const logging = screen.getByRole("group", { name: "Logging" });
// A RAC Select names its trigger with the current value and then the label, and
// carries the options only while the listbox is open.
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
fireEvent.click(within(logging).getByLabelText("hide_domains"));
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
});
test("clearing a number field disables Save instead of sending NaN", async () => {
await renderPage();
const cache = screen.getByRole("group", { name: "Cache" });
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
expect(saveButton().disabled).toBe(true);
});
test("password flow: note shown, confirm required, PUT sends web.password, no banner", async () => {
await renderPage();
const web = screen.getByRole("group", { name: "Web" });
const passwordInput = within(web).getByLabelText("password") as HTMLInputElement;
const confirmInput = within(web).getByLabelText("confirm password") as HTMLInputElement;
expect(passwordInput.value).toBe("");
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
expect(screen.getByText(/signs out every session/)).toBeTruthy();
expect(screen.getByText("Passwords do not match.")).toBeTruthy();
expect(saveButton().disabled).toBe(true);
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
expect(screen.queryByText("Passwords do not match.")).toBeNull();
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ web: { password: "hunter2" } });
await waitFor(() => expect(passwordInput.value).toBe(""));
expect(confirmInput.value).toBe("");
expect(screen.queryByRole("status")).toBeNull();
});
test("a mixed patch with a password still raises the banner", async () => {
await renderPage();
const web = screen.getByRole("group", { name: "Web" });
fireEvent.change(within(web).getByLabelText("session_ttl_hours"), { target: { value: "48" } });
fireEvent.change(within(web).getByLabelText("password"), { target: { value: "hunter2" } });
fireEvent.change(within(web).getByLabelText("confirm password"), { target: { value: "hunter2" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
expect(await screen.findByRole("status")).toBeTruthy();
});
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
await renderPage();
let resolvePut!: (response: Response) => void;
putResponse = () => new Promise<Response>((resolve) => (resolvePut = resolve));
const dns = screen.getByRole("group", { name: "DNS" });
const port = within(dns).getByLabelText("port") as HTMLInputElement;
fireEvent.change(port, { target: { value: "5353" } });
fireEvent.click(saveButton());
await screen.findByRole("button", { name: "Saving…" });
expect(port.matches(":disabled")).toBe(true);
const web = screen.getByRole("group", { name: "Web" });
expect(within(web).getByLabelText("password").matches(":disabled")).toBe(true);
applyPatch(putBodies[putBodies.length - 1]!);
resolvePut(jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] }));
await waitFor(() => expect(port.matches(":disabled")).toBe(false));
expect(saveButton().textContent).toBe("Save");
});
test("a 429 shows the rate-limit countdown from Retry-After", async () => {
await renderPage();
putResponse = () =>
new Response(JSON.stringify({ error: "too many requests" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "30" },
});
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
fireEvent.click(saveButton());
expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 30s.");
expect(screen.queryByRole("status")).toBeNull();
});
test("a 400 validation error surfaces inline and raises no banner", async () => {
await renderPage();
putResponse = () => jsonResponse({ error: "dns.port out of range" }, 400);
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "70000" } });
fireEvent.click(saveButton());
expect((await screen.findByRole("alert")).textContent).toBe("dns.port out of range");
expect(screen.queryByRole("status")).toBeNull();
expect(saveButton().disabled).toBe(false);
});
test("patchRequiresRestart ignores only a bare web.password", () => {
expect(patchRequiresRestart({ web: { password: "x" } })).toBe(false);
expect(patchRequiresRestart({ web: { password: "x", port: 9090 } })).toBe(true);
expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true);
expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true);
});
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
const queryClient = await renderPage();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
// Someone else changes cache.size; a background refetch brings it in. The
// derived auth_enabled line is read straight from the query data, so it
// witnesses that the refetch reached the component.
storedSettings.cache.size = 99999;
storedSettings.web.auth_enabled = false;
await act(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
});
await waitFor(() => expect(screen.getByText(/auth_enabled: false/)).toBeTruthy());
const cache = screen.getByRole("group", { name: "Cache" });
expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
});
test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
await renderPage();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
await waitFor(() => expect(saveButton().disabled).toBe(true));
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5454" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(2));
expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
});
@@ -1,462 +0,0 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner";
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
export function patchRequiresRestart(patch: SettingsPatch): boolean {
return Object.entries(patch).some(([section, fields]) =>
Object.keys(fields ?? {}).some((key) => !(section === "web" && key === "password")),
);
}
interface FieldDef<S extends keyof Settings> {
key: keyof Settings[S] & string;
kind: "number" | "text" | "boolean" | readonly string[];
}
interface SectionDef<S extends keyof Settings> {
section: S;
title: string;
fields: readonly FieldDef<S>[];
}
/** Binds each section's field keys to that section's Settings type at definition. */
function defineSection<S extends keyof Settings>(def: SectionDef<S>): SectionDef<S> {
return def;
}
/** The registry read back as a heterogeneous list, once the per-section binding has been proven. */
type AnyFieldDef = { [S in keyof Settings]: FieldDef<S> }[keyof Settings];
type AnySectionDef = { [S in keyof Settings]: SectionDef<S> }[keyof Settings];
/**
* A section's values as a string-keyed view. The keys are proven against
* `Settings[S]` where each section is defined; iterating the heterogeneous
* registry loses that correlation, so consumption widens here in one place.
*/
function sectionValues(settings: Settings, section: keyof Settings): Record<string, unknown> {
return settings[section] as Record<string, unknown>;
}
const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [
{ key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" },
{ key: "port", kind: "number" },
{ key: "cert_path", kind: "text" },
{ key: "key_path", kind: "text" },
];
const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "upstream",
title: "Upstream",
fields: [
{ key: "attempt_timeout_ms", kind: "number" },
{ key: "read_timeout_ms", kind: "number" },
{ key: "total_timeout_ms", kind: "number" },
],
}),
defineSection({
section: "dns",
title: "DNS",
fields: [
{ key: "bind_ipv4", kind: "text" },
{ key: "bind_ipv6", kind: "text" },
{ key: "port", kind: "number" },
{ key: "rate_limit", kind: "number" },
{ key: "rate_window_seconds", kind: "number" },
],
}),
defineSection({
section: "blocking",
title: "Blocking",
fields: [
{ key: "response", kind: ["zero", "nxdomain"] },
{ key: "ttl", kind: "number" },
],
}),
defineSection({
section: "cache",
title: "Cache",
fields: [
{ key: "size", kind: "number" },
{ key: "negative_ttl_max", kind: "number" },
],
}),
defineSection({
section: "web",
title: "Web",
fields: [
{ key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" },
{ key: "port", kind: "number" },
{ key: "session_ttl_hours", kind: "number" },
{ key: "api_rate_limit_per_min", kind: "number" },
{ key: "api_localhost_exempt", kind: "boolean" },
{ key: "sse_max_connections_per_ip", kind: "number" },
{ key: "trusted_proxies", kind: "text" },
],
}),
defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }),
defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }),
defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }),
defineSection({
section: "logging",
title: "Logging",
fields: [
{ key: "level", kind: ["error", "warn", "info", "debug"] },
{ key: "retention_days", kind: "number" },
{ key: "query_log_buffer_max", kind: "number" },
{ key: "query_log_flush_interval_s", kind: "number" },
{ key: "hide_domains", kind: "boolean" },
{ key: "hide_client_ips", kind: "boolean" },
{ key: "output", kind: ["stderr", "syslog", "file"] },
{ key: "file_path", kind: "text" },
{ key: "max_size_mb", kind: "number" },
{ key: "max_files", kind: "number" },
],
}),
defineSection({
section: "disk",
title: "Disk",
fields: [
{ key: "min_free_mb", kind: "number" },
{ key: "warn_free_mb", kind: "number" },
],
}),
defineSection({
section: "blocklist_update",
title: "Blocklist Update",
fields: [
{ key: "enabled", kind: "boolean" },
{ key: "interval_hours", kind: "number" },
],
}),
];
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
form: {
marginTop: "1rem",
maxWidth: "48rem",
},
/** A `fieldset` has a browser default border and padding; the layout wants neither. */
sections: {
display: "flex",
flexDirection: "column",
gap: "1.5rem",
borderStyle: "none",
margin: 0,
padding: 0,
},
section: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
legend: {
paddingInline: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
/** One column on a phone, two from `sm`, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(37% 0.013 285.805)",
[DARK]: "oklch(87.1% 0.006 286.286)",
},
},
checkboxRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
fieldInput: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
derived: {
color: colors.textMuted,
},
/** Both notices span the whole grid so the wrapped sentence stays readable. */
spanRow: {
gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" },
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
passwordNotice: {
color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" },
},
mismatchNotice: {
color: colors.danger,
},
submitRow: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
save: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
backgroundColor: {
default: colors.primary,
":disabled": "oklch(87.1% 0.006 286.286)",
[DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" },
},
color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" },
},
});
function FieldRow({
section,
def,
value,
onChange,
}: {
section: string;
def: AnyFieldDef;
value: unknown;
onChange: (value: unknown) => void;
}) {
const id = `${section}.${def.key}`;
if (def.kind === "boolean") {
return (
<div {...stylex.props(styles.checkboxRow)}>
<input
id={id}
type="checkbox"
checked={value as boolean}
onChange={(e) => onChange(e.target.checked)}
{...stylex.props(shared.focusRing)}
/>
<label htmlFor={id} {...stylex.props(styles.label)}>
{def.key}
</label>
</div>
);
}
if (Array.isArray(def.kind)) {
return (
<Select
variant="inline"
label={def.key}
value={value as string}
onChange={onChange}
options={def.kind.map((option) => ({ value: option, label: option }))}
/>
);
}
if (def.kind === "number") {
const numeric = value as number;
return (
<div {...stylex.props(styles.field)}>
<label htmlFor={id} {...stylex.props(styles.label)}>
{def.key}
</label>
<input
id={id}
type="number"
value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
);
}
return (
<div {...stylex.props(styles.field)}>
<label htmlFor={id} {...stylex.props(styles.label)}>
{def.key}
</label>
<input
id={id}
type="text"
value={value as string}
onChange={(e) => onChange(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
);
}
export default function SettingsPage() {
const { data } = useSuspenseQuery(settingsQuery());
const queryClient = useQueryClient();
const mutation = useMutation(settingsPutMutation(queryClient));
// Frozen at mount and re-frozen on save: diffing against live query data would
// turn a background refetch's out-of-band changes into phantom user edits.
const [baseline, setBaseline] = useState<Settings>(() => structuredClone(data.settings));
const [edited, setEdited] = useState<Settings>(() => structuredClone(data.settings));
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
const hasInvalidNumber = SECTIONS.some(({ section, fields }) => {
const values = sectionValues(edited, section);
return (fields as readonly AnyFieldDef[]).some(
(field) => field.kind === "number" && Number.isNaN(values[field.key]),
);
});
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
const readOnly = useReadOnlyConfig();
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly;
function setField(section: keyof Settings, key: string, value: unknown): void {
setEdited((prev) => ({
...prev,
[section]: { ...sectionValues(prev, section), [key]: value },
}));
}
function handleSubmit(event: FormEvent): void {
event.preventDefault();
if (patch === null || passwordsMismatch || hasInvalidNumber) return;
const restartNeeded = patchRequiresRestart(patch);
mutation.mutate(patch, {
onSuccess: (envelope) => {
setBaseline(structuredClone(envelope.settings));
setEdited(structuredClone(envelope.settings));
setPassword("");
setConfirm("");
if (restartNeeded) raiseRestartBanner();
},
});
}
return (
<section>
<h1 {...stylex.props(styles.heading)}>Settings</h1>
<p {...stylex.props(styles.intro)}>
Changes are validated as a whole; every setting requires a restart to take effect.
</p>
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending || readOnly} {...stylex.props(styles.sections)}>
{SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} {...stylex.props(styles.section)}>
<legend {...stylex.props(styles.legend)}>{title}</legend>
<div {...stylex.props(styles.fieldGrid)}>
{(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow
key={def.key}
section={section}
def={def}
value={sectionValues(edited, section)[def.key]}
onChange={(value) => setField(section, def.key, value)}
/>
))}
{section === "web" && (
<>
<p {...stylex.props(styles.label)}>
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
<span {...stylex.props(styles.derived)}>(derived, read-only)</span>
</p>
<div {...stylex.props(styles.field)}>
<label htmlFor="web.password" {...stylex.props(styles.label)}>
password
</label>
<input
id="web.password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.field)}>
<label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
confirm password
</label>
<input
id="web.password_confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
{password !== "" && (
<p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
Changing the password signs out every session; you will be asked to log
in again.
</p>
)}
{passwordsMismatch && (
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match.
</p>
)}
</>
)}
</div>
</fieldset>
))}
<div {...stylex.props(styles.submitRow)}>
<button
type="submit"
disabled={saveDisabled}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(styles.save, shared.focusRing)}
>
{mutation.isPending ? "Saving…" : "Save"}
</button>
{mutation.isError && <InlineError error={mutation.error} />}
</div>
</fieldset>
</form>
</section>
);
}
@@ -1,255 +0,0 @@
import { render, screen, 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 type { Authority, Settings, SettingsEnvelope } from "@/lib/types";
// One file for the whole file-mode sweep: the settings envelope is the only
// discovery mechanism, so every page test needs the same stubbed envelope.
const CONFIG_PATH = "/etc/nxdns/config.zon";
const DATABASE: Authority = { mode: "database", path: null, reconciled_at: null };
const MANAGED_FILE: Authority = { mode: "managed_file", path: CONFIG_PATH, reconciled_at: 1754899200 };
function baseSettings(): Settings {
return {
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 },
web: {
enabled: true,
bind: "127.0.0.1",
port: 8080,
session_ttl_hours: 24,
api_rate_limit_per_min: 60,
api_localhost_exempt: true,
sse_max_connections_per_ip: 2,
trusted_proxies: "",
auth_enabled: true,
},
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
edns: { ecs_mode: "strip" },
logging: {
level: "info",
retention_days: 30,
query_log_buffer_max: 10000,
query_log_flush_interval_s: 60,
hide_domains: false,
hide_client_ips: false,
output: "stderr",
file_path: "",
max_size_mb: 50,
max_files: 3,
},
disk: { min_free_mb: 100, warn_free_mb: 500 },
blocklist_update: { enabled: true, interval_hours: 24 },
};
}
function envelope(authority: Authority): SettingsEnvelope {
return { settings: baseSettings(), restart_required: [], authority };
}
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const BLOCKLISTS = {
blocklists: [
{
id: 1,
url: "https://example.com/ads.txt",
name: "Ads",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 100,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
],
};
const RULES = {
rules: [
{
id: 1,
group_id: 1,
group: "default",
pattern: "ads.example.com",
kind: "exact",
action: "block",
created_at: 1700000000,
},
],
};
const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
const BASE: Record<string, unknown> = {
"GET /api/version": VERSION,
"GET /api/groups": GROUPS,
"GET /api/blocklists": BLOCKLISTS,
"GET /api/rules": RULES,
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
};
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
}
async function renderAt(route: string, heading: string, authority: Authority) {
stubFetch({ ...BASE, "GET /api/settings": envelope(authority) });
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: heading });
}
function button(name: string): HTMLButtonElement {
return screen.getByRole("button", { name }) as HTMLButtonElement;
}
function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr");
if (row === null) throw new Error(`no client row for ${ip}`);
return row;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("the banner names the managed file in file mode", async () => {
await renderAt("/rules", "Rules", MANAGED_FILE);
const banner = await screen.findByText(/configuration is managed by/i);
expect(banner.textContent).toContain(CONFIG_PATH);
expect(banner.textContent).toMatch(/restart/i);
expect(banner.closest('[role="status"]')).toBeTruthy();
});
test("the banner is absent in database mode", async () => {
await renderAt("/rules", "Rules", DATABASE);
await screen.findByRole("button", { name: "Create rule" });
expect(screen.queryByText(/configuration is managed by/i)).toBeNull();
});
function kidsRow(): HTMLElement {
const row = screen.getByText("kids").closest("li");
if (row === null) throw new Error("no row for group kids");
return row;
}
test("file mode disables the Groups create and delete controls", async () => {
await renderAt("/groups", "Groups", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Create").disabled).toBe(true);
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(true);
});
test("database mode leaves the Groups create and delete controls enabled", async () => {
await renderAt("/groups", "Groups", DATABASE);
await screen.findByRole("button", { name: "Create" });
expect(button("Create").disabled).toBe(false);
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(false);
});
test("file mode disables the Rules create and delete controls", async () => {
await renderAt("/rules", "Rules", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Create rule").disabled).toBe(true);
expect(button("Delete").disabled).toBe(true);
});
test("database mode leaves the Rules create and delete controls enabled", async () => {
await renderAt("/rules", "Rules", DATABASE);
await screen.findByRole("button", { name: "Create rule" });
expect(button("Create rule").disabled).toBe(false);
expect(button("Delete").disabled).toBe(false);
});
test("file mode keeps delete live for an observed client and blocks it for a declared one", async () => {
await renderAt("/clients", "Clients", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
const declared = clientRow("192.168.1.10");
const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(observed).getByRole("button", { name: "Edit" }) as HTMLButtonElement).disabled).toBe(true);
});
test("file mode leaves the blocklist refresh button enabled", async () => {
await renderAt("/blocklists", "Blocklists", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Update now").disabled).toBe(false);
expect(button("Add source").disabled).toBe(true);
expect((screen.getByRole("checkbox", { name: "Ads enabled" }) as HTMLInputElement).disabled).toBe(true);
});
-25
View File
@@ -1,25 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { settingsQuery } from "@/lib/queries";
import type { Authority } from "@/lib/types";
/** The one-line explanation on every control file authority takes away. */
export const READ_ONLY_HINT = "Configuration is managed by a file; edit the file and restart nxdns.";
/**
* The running server's configuration authority, read from the settings
* envelope — the only route that carries it. `undefined` until that query
* resolves. Every page may call this: it is the shared `["settings"]` key, so
* the shell's own subscription serves them all from cache.
*/
export function useAuthority(): Authority | undefined {
return useQuery(settingsQuery()).data?.authority;
}
/**
* True only once the server has said a file owns the configuration. While the
* mode is unknown nothing is disabled — the 403 is the enforcement, this is
* the courtesy.
*/
export function useReadOnlyConfig(): boolean {
return useAuthority()?.mode === "managed_file";
}
@@ -1,27 +0,0 @@
import { useSyncExternalStore } from "react";
let raised = false;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): boolean {
return raised;
}
export function raiseRestartBanner(): void {
raised = true;
for (const listener of listeners) listener();
}
export function dismissRestartBanner(): void {
raised = false;
for (const listener of listeners) listener();
}
export function useRestartBanner(): boolean {
return useSyncExternalStore(subscribe, getSnapshot);
}
@@ -1,236 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { dismissRestartBanner } from "@/features/settings/restartBanner";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
const UPSTREAMS = {
upstreams: [
{ id: 1, url: "udp://1.1.1.1:53", priority: 100, enabled: true, tls_name: "" },
{ id: 2, url: "tls://9.9.9.9:853", priority: 200, enabled: false, tls_name: "dns.quad9.net" },
],
};
const RESPONSES: Record<string, unknown> = {
"/api/upstreams": UPSTREAMS,
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
interface Call {
url: string;
method: string;
body: unknown;
}
let calls: Call[];
let writeResponse: (() => Response) | null;
beforeEach(() => {
calls = [];
writeResponse = null;
dismissRestartBanner();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
if (method !== "GET") {
calls.push({
url,
method,
body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined,
});
if (writeResponse !== null) return writeResponse();
if (method === "DELETE") return new Response(null, { status: 204 });
return new Response(
JSON.stringify({
id: 3,
url: "udp://8.8.8.8:53",
priority: 100,
enabled: true,
tls_name: "",
restart_required: true,
}),
{ status: 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), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function renderUpstreamsRoute() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/upstreams"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Upstreams" });
}
test("renders the upstream table and the add form", async () => {
await renderUpstreamsRoute();
expect(screen.getByText("udp://1.1.1.1:53")).toBeTruthy();
expect(screen.getByText("tls://9.9.9.9:853")).toBeTruthy();
expect(screen.getByText("100")).toBeTruthy();
expect(screen.getByText("200")).toBeTruthy();
expect(screen.getByText("dns.quad9.net")).toBeTruthy();
const enabledToggle = screen.getByLabelText("udp://1.1.1.1:53 enabled") as HTMLInputElement;
expect(enabledToggle.checked).toBe(true);
const disabledToggle = screen.getByLabelText("tls://9.9.9.9:853 enabled") as HTMLInputElement;
expect(disabledToggle.checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
expect(screen.getByText(/counts the running pool/)).toBeTruthy();
});
test("adding an upstream posts every field and raises the restart banner", async () => {
await renderUpstreamsRoute();
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "150" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]).toEqual({
url: "/api/upstreams",
method: "POST",
body: { url: "udp://8.8.8.8:53", priority: 150, enabled: true, tls_name: "" },
});
const banner = await screen.findByRole("status");
expect(banner.textContent).toContain("Changes saved. Restart nxdns to apply.");
});
test("toggling enabled resends the whole row", async () => {
await renderUpstreamsRoute();
fireEvent.click(screen.getByLabelText("tls://9.9.9.9:853 enabled"));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]).toEqual({
url: "/api/upstreams/2",
method: "PUT",
body: { url: "tls://9.9.9.9:853", priority: 200, enabled: true, tls_name: "dns.quad9.net" },
});
await screen.findByRole("status");
});
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog() {
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
return await screen.findByRole("alertdialog");
}
test("delete asks for confirmation and skips the request when cancelled", async () => {
await renderUpstreamsRoute();
const dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(calls).toHaveLength(0);
});
test("confirming the delete dialog issues the DELETE and raises the restart banner", async () => {
await renderUpstreamsRoute();
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]!.method).toBe("DELETE");
expect(calls[0]!.url).toBe("/api/upstreams/1");
await screen.findByRole("status");
});
test("a 409 on create renders the conflict text inline", async () => {
await renderUpstreamsRoute();
writeResponse = () =>
new Response(JSON.stringify({ error: "an upstream with that url already exists" }), {
status: 409,
headers: { "content-type": "application/json" },
});
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://1.1.1.1:53" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("an upstream with that url already exists");
expect(screen.queryByRole("status")).toBeNull();
});
test("a 409 on toggle renders the last-enabled conflict above the form", async () => {
await renderUpstreamsRoute();
writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be disabled" }), {
status: 409,
headers: { "content-type": "application/json" },
});
fireEvent.click(screen.getByLabelText("udp://1.1.1.1:53 enabled"));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be disabled");
expect(screen.queryByRole("status")).toBeNull();
});
test("a 409 on delete renders the last-enabled conflict", async () => {
await renderUpstreamsRoute();
writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409,
headers: { "content-type": "application/json" },
});
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
expect(screen.queryByRole("status")).toBeNull();
});
test("editing a row seeds the form and PUTs the replaced row", async () => {
await renderUpstreamsRoute();
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[1]!);
await screen.findByRole("heading", { name: "Edit tls://9.9.9.9:853" });
expect((screen.getByLabelText("URL") as HTMLInputElement).value).toBe("tls://9.9.9.9:853");
expect((screen.getByLabelText("Priority") as HTMLInputElement).value).toBe("200");
expect((screen.getByLabelText("TLS name") as HTMLInputElement).value).toBe("dns.quad9.net");
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "10" } });
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]).toEqual({
url: "/api/upstreams/2",
method: "PUT",
body: { url: "tls://9.9.9.9:853", priority: 10, enabled: false, tls_name: "dns.quad9.net" },
});
await screen.findByRole("heading", { name: "Add upstream" });
});
+14
View File
@@ -2,10 +2,12 @@ import type {
Blocklist,
BlocklistEcho,
BlocklistInput,
CertsReload,
Client,
ClientEdit,
ClientPrefix,
ClientPrefixInput,
ConfigStatus,
DiagnosticEvent,
DiagnosticsFilter,
DiagnosticsPage,
@@ -249,3 +251,15 @@ export const postPause = (body: PausePost): Promise<PauseState> => request("/api
export const getSettings = (): Promise<SettingsEnvelope> => request("/api/settings");
export const putSettings = (patch: SettingsPatch): Promise<SettingsEnvelope> =>
request("/api/settings", { method: "PUT", body: patch });
// Configuration authority and restart state
/**
* The one route that answers which source governs the running configuration
* and whether a restart is owed. Both are per-process facts: no other endpoint
* carries them, and neither survives a restart.
*/
export const getConfigStatus = (): Promise<ConfigStatus> => request("/api/config/status");
/** Reloads the DoH/DoT certificates from disk. A runtime action: served in both authority modes. */
export const reloadCerts = (): Promise<CertsReload> => request("/api/certs/reload", { method: "POST" });
+22 -10
View File
@@ -14,8 +14,10 @@
import type {
Blocklist,
BlocklistEcho,
CertsReload,
Client,
ClientPrefix,
ConfigStatus,
DiagnosticEvent,
DiagnosticsPage,
DiagnosticsPurge,
@@ -565,11 +567,6 @@ export const sample_post_pause: PauseState = {
};
export const sample_get_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
@@ -690,11 +687,6 @@ export const sample_get_settings: SettingsEnvelope = {
};
export const sample_put_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
@@ -814,6 +806,26 @@ export const sample_put_settings: SettingsEnvelope = {
},
};
export const sample_get_config_status: ConfigStatus = {
authority: "database",
path: null,
reconciled_at: null,
restart_pending: true,
};
export const sample_reload_certs: CertsReload = {
doh: {
enabled: false,
error: null,
reloaded: false,
},
dot: {
enabled: false,
error: null,
reloaded: false,
},
};
export const sample_error_bad_request: ErrorEnvelope = {
error: "logging.level: not one of the values this setting accepts",
};
+36 -9
View File
@@ -1,6 +1,5 @@
import { infiniteQueryOptions, keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
import * as api from "@/lib/api";
import { setRefreshStatus } from "@/features/blocklists/refreshStore";
import type {
BlocklistInput,
ClientEdit,
@@ -46,6 +45,7 @@ export const queryKeys = {
clientPrefixes: ["client-prefixes"] as const,
upstreams: ["upstreams"] as const,
settings: ["settings"] as const,
configStatus: ["configStatus"] as const,
};
export const healthQuery = () =>
@@ -150,6 +150,19 @@ export const upstreamsQuery = () => queryOptions({ queryKey: queryKeys.upstreams
export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings });
// Restart truth must not depend on the tab that caused it: a notice raised by
// another tab, another API client or a mutation elsewhere in this one has to
// surface here too, and a process restart has to clear it. Hence never stale,
// always refetched when the window regains focus, and polled once a minute.
export const configStatusQuery = () =>
queryOptions({
queryKey: queryKeys.configStatus,
queryFn: api.getConfigStatus,
staleTime: 0,
refetchOnWindowFocus: "always",
refetchInterval: 60_000,
});
// Mutation option factories. Usage: useMutation(groupCreateMutation(useQueryClient())).
// Group membership and names feed lookup verdicts and the group columns on
// clients, prefixes and rules, hence the wide invalidation on group mutations.
@@ -223,16 +236,18 @@ export const blocklistDeleteMutation = (qc: QueryClient) => ({
onSuccess: () => invalidateBlocklistWorld(qc),
});
/** Ruling 12: the 202 snapshot REPLACES the refresh store; counters refresh. */
// A runtime action, not a configuration write: it re-fetches the sources the
// running process already declares, so it works under file authority too. The
// 202 body's per-source snapshot is deliberately dropped — refresh outcomes are
// durable counters on the source rows and episodes in Diagnostics, not an
// ephemeral readout that survives one navigation.
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
mutationFn: () => api.updateBlocklistsNow(),
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
setRefreshStatus(sources);
return Promise.all([
onSuccess: () =>
Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]);
},
]),
});
function invalidateRules(qc: QueryClient): Promise<unknown> {
@@ -313,8 +328,14 @@ export const clientPrefixesPutMutation = (qc: QueryClient) => ({
},
});
// The pool builds its clients at startup, so every upstream write leaves the
// server owing a restart. The flag it sets lives on /api/config/status, and the
// shell notice reads it there — hence the second invalidation.
function invalidateUpstreams(qc: QueryClient): Promise<unknown> {
return qc.invalidateQueries({ queryKey: queryKeys.upstreams });
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.upstreams }),
qc.invalidateQueries({ queryKey: queryKeys.configStatus }),
]);
}
export const upstreamCreateMutation = (qc: QueryClient) => ({
@@ -341,10 +362,16 @@ export const pauseMutation = (qc: QueryClient) => ({
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.health }),
});
// Whether the patch needs a restart is the server's decision (it owns the
// restart-required key set), so the client re-reads the status rather than
// deciding for itself.
export const settingsPutMutation = (qc: QueryClient) => ({
mutationFn: (patch: SettingsPatch) => api.putSettings(patch),
onSuccess: (envelope: Awaited<ReturnType<typeof api.putSettings>>) => {
qc.setQueryData(queryKeys.settings, envelope);
return qc.invalidateQueries({ queryKey: queryKeys.settings });
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.settings }),
qc.invalidateQueries({ queryKey: queryKeys.configStatus }),
]);
},
});
+36 -14
View File
@@ -636,23 +636,28 @@ export interface Settings {
};
}
/**
* Which configuration source the running process obeys. `path` and
* `reconciled_at` are non-null only under `managed_file`: the file the process
* loaded, and the epoch second at which it loaded it. Authority lives in the
* invocation, never in the database, so this is the only place the UI can read
* it and it rides an authenticated route, never the open ones.
*/
export interface Authority {
mode: "database" | "managed_file";
path: string | null;
reconciled_at: number | null;
}
export interface SettingsEnvelope {
settings: Settings;
restart_required: string[];
authority: Authority;
}
/**
* Which configuration source the running process obeys, and whether it owes a
* restart. `path` and `reconciled_at` are non-null only under `managed_file`:
* the file the process loaded, and the epoch second at which it loaded it.
* Both facts live in the invocation and the process, never in the database, so
* `/api/config/status` is the only place the UI can read them and it is an
* authenticated route, never one of the open ones.
*
* `restart_pending` is true once the server has committed a change only a
* restart applies (an upstream write, a settings key). Nothing but process
* exit clears it, so a browser reload cannot dismiss it.
*/
export interface ConfigStatus {
authority: "database" | "managed_file";
path: string | null;
reconciled_at: number | null;
restart_pending: boolean;
}
export interface TlsListenerPatch {
@@ -677,3 +682,20 @@ export interface SettingsPatch {
disk?: Partial<Settings["disk"]>;
blocklist_update?: Partial<Settings["blocklist_update"]>;
}
/** One endpoint's outcome from `POST /api/certs/reload`. */
export interface CertReloadOutcome {
enabled: boolean;
reloaded: boolean;
/** Why the reload failed; null on success and while the endpoint is disabled. */
error: string | null;
}
/**
* The reload runs per endpoint and always answers 200: a failed reload is an
* outcome, not an error, and the previous certificate keeps serving.
*/
export interface CertsReload {
doh: CertReloadOutcome;
dot: CertReloadOutcome;
}
+79 -54
View File
@@ -25,6 +25,7 @@ import {
blocklistsQuery,
clientPrefixesQuery,
clientsQuery,
configStatusQuery,
diagnosticQuery,
diagnosticsInfiniteQuery,
forwardZonesQuery,
@@ -43,6 +44,13 @@ import {
upstreamsQuery,
} from "@/lib/queries";
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
import {
validateGroupId,
validateProtectionSearch,
validateResolutionSearch,
type ProtectionSearch,
type ResolutionSearch,
} from "@/features/configuration/search";
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -253,6 +261,11 @@ const activityTestRoute = createRoute({
const clientsRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/clients",
// `group` is the filter Protection's client count links into. Validated here
// so a hand-typed id cannot reach the page as anything but a row id.
validateSearch: (search: Record<string, unknown>): { group?: number } => ({
group: validateGroupId(search["group"]),
}),
loader: ({ context }) =>
Promise.all([
context.queryClient.ensureQueryData(clientsQuery()),
@@ -262,51 +275,22 @@ const clientsRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/clients/ClientsPage")),
});
const groupsRoute = createRoute({
/**
* One client, keyed by the row id the client API already identifies it with.
*
* No endpoint answers for a single client, so the list is the source. It is
* awaited, as the other detail routes await theirs: a cold deep link has
* nothing to render until it lands, and holding the navigation for one request
* beats a page that flashes "no such client" before the rows arrive. The
* rejection is swallowed for the same reason theirs are the page states a
* missing row and a failed fetch itself, where the whole-page error component
* would call both a request failure.
*/
const clientDetailRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/groups",
loader: ({ context }) =>
Promise.all([
context.queryClient.ensureQueryData(groupsQuery()),
context.queryClient.ensureQueryData(blocklistsQuery()),
]),
component: lazyRouteComponent(() => import("@/features/groups/GroupsPage")),
});
const blocklistsRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/blocklists",
loader: ({ context }) => context.queryClient.ensureQueryData(blocklistsQuery()),
component: lazyRouteComponent(() => import("@/features/blocklists/BlocklistsPage")),
});
const rulesRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/rules",
loader: ({ context }) =>
Promise.all([
context.queryClient.ensureQueryData(rulesQuery()),
context.queryClient.ensureQueryData(groupsQuery()),
]),
component: lazyRouteComponent(() => import("@/features/rules/RulesPage")),
});
const localDnsRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/local-dns",
loader: ({ context }) =>
Promise.all([
context.queryClient.ensureQueryData(localRecordsQuery()),
context.queryClient.ensureQueryData(forwardZonesQuery()),
]),
component: lazyRouteComponent(() => import("@/features/local/LocalDnsPage")),
});
const upstreamsRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/upstreams",
loader: ({ context }) => context.queryClient.ensureQueryData(upstreamsQuery()),
component: lazyRouteComponent(() => import("@/features/upstreams/UpstreamsPage")),
path: "/clients/$id",
loader: ({ context }) => context.queryClient.ensureQueryData(clientsQuery()).catch(() => undefined),
component: lazyRouteComponent(() => import("@/features/clients/ClientDetailPage")),
});
/**
@@ -366,11 +350,54 @@ const diagnosticDetailRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticDetailPage")),
});
const settingsRoute = createRoute({
/**
* The three task-shaped configuration pages. There is no `/configuration`
* landing route: a bare `/configuration` is not a question anyone has, and a
* route that only redirects is a second name for a page.
*
* Every loader here is started and awaited nowhere, the pattern the rest of the
* app uses: each panel owns its loading and error surface, so awaiting would
* trade that for one blocking navigation on the slowest request. The rejections
* are caught only to keep them from going unhandled.
*
* `configStatusQuery` starts with all of them. Every configuration page renders
* nothing neither form nor definition list until it answers, so it is on the
* critical path of all three.
*/
function startConfiguration(queryClient: QueryClient, queries: readonly unknown[]): void {
const start = (promise: Promise<unknown>) => void promise.catch(() => {});
start(queryClient.ensureQueryData(configStatusQuery()));
for (const options of queries) {
start(queryClient.ensureQueryData(options as Parameters<QueryClient["ensureQueryData"]>[0]));
}
}
const protectionRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/settings",
loader: ({ context }) => context.queryClient.ensureQueryData(settingsQuery()),
component: lazyRouteComponent(() => import("@/features/settings/SettingsPage")),
path: "/configuration/protection",
validateSearch: validateProtectionSearch,
loaderDeps: ({ search }): ProtectionSearch => ({ tab: search.tab, group: search.group }),
// Clients rides along because the selected group's detail counts them.
loader: ({ context }) =>
startConfiguration(context.queryClient, [groupsQuery(), blocklistsQuery(), rulesQuery(), clientsQuery()]),
component: lazyRouteComponent(() => import("@/features/configuration/ProtectionPage")),
});
const resolutionRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/configuration/resolution",
validateSearch: validateResolutionSearch,
loaderDeps: ({ search }): ResolutionSearch => ({ tab: search.tab }),
loader: ({ context }) =>
startConfiguration(context.queryClient, [upstreamsQuery(), localRecordsQuery(), forwardZonesQuery()]),
component: lazyRouteComponent(() => import("@/features/configuration/ResolutionPage")),
});
const systemRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/configuration/system",
loader: ({ context }) => startConfiguration(context.queryClient, [settingsQuery()]),
component: lazyRouteComponent(() => import("@/features/configuration/SystemPage")),
});
const routeTree = rootRoute.addChildren([
@@ -382,14 +409,12 @@ const routeTree = rootRoute.addChildren([
activityDetailRoute,
activityTestRoute,
clientsRoute,
groupsRoute,
blocklistsRoute,
rulesRoute,
localDnsRoute,
upstreamsRoute,
clientDetailRoute,
diagnosticsRoute,
diagnosticDetailRoute,
settingsRoute,
protectionRoute,
resolutionRoute,
systemRoute,
]),
]);
+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>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "./styles";
import { colors } from "./tokens.stylex";
export interface Definition {
/** What the operator calls the value. */
label: string;
/**
* The exact key in the configuration file, shown secondarily so the reader
* can find the line to edit. Omitted for a derived value that has no key
* inventing one would send the reader looking for something not there.
*/
zonKey?: string;
value: ReactNode;
}
const styles = stylex.create({
list: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
margin: 0,
},
term: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
key: {
marginLeft: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
value: {
marginTop: "0.125rem",
marginLeft: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.text,
},
});
/**
* Scalars as a definition list the file-mode counterpart of a form section.
* Nothing here is interactive: a value the process loaded from a file is read,
* not edited.
*/
export default function DefinitionList({ items }: { items: readonly Definition[] }) {
return (
<dl {...stylex.props(styles.list)}>
{items.map((item) => (
<div key={item.label}>
<dt {...stylex.props(styles.term)}>
{item.label}
{item.zonKey !== undefined && (
<code {...stylex.props(shared.mono, styles.key)}>{item.zonKey}</code>
)}
</dt>
<dd {...stylex.props(styles.value)}>{item.value}</dd>
</div>
))}
</dl>
);
}
+28 -1
View File
@@ -11,7 +11,16 @@
*/
import * as stylex from "@stylexjs/stylex";
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
import {
Button,
Label,
ListBox,
ListBoxItem,
Popover,
Select as AriaSelect,
SelectValue,
Text,
} from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
@@ -27,6 +36,12 @@ interface Props {
/** The visible label. Omit it only when `aria-label` names the control. */
label?: string;
"aria-label"?: string;
/**
* Text under the control that qualifies it. RAC gives it an id and points the
* trigger's `aria-describedby` at it, which a sibling rendered by the caller
* cannot be: the trigger is this file's, so only this file can name it.
*/
description?: string;
/**
* `field` matches a full-width form input, `compactField` the smaller one a
* dialog uses, `inline` a control sitting in a row of other controls.
@@ -70,6 +85,12 @@ const styles = stylex.create({
chevron: {
color: colors.textMuted,
},
description: {
display: "block",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
popover: {
width: "var(--trigger-width)",
maxHeight: "16rem",
@@ -116,6 +137,7 @@ export default function Select({
onChange,
label,
"aria-label": ariaLabel,
description,
variant = "field",
isDisabled = false,
}: Props) {
@@ -136,6 +158,11 @@ export default function Select({
</span>
</Button>
{description !== undefined && (
<Text slot="description" {...stylex.props(styles.description)}>
{description}
</Text>
)}
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<ListBox {...stylex.props(styles.listBox)}>
{options.map((option) => (
+13 -2
View File
@@ -25,6 +25,13 @@ interface Props {
/** The tab list's accessible name. */
label: string;
tabs: readonly TabSpec[];
/**
* The open tab, when a caller owns the selection the configuration pages
* keep it in the URL. Omit both of these to let RAC hold the state, which
* is what a tab set with no linkable identity wants.
*/
selectedKey?: string;
onSelectionChange?: (key: string) => void;
}
const styles = stylex.create({
@@ -73,9 +80,13 @@ const styles = stylex.create({
},
});
export default function Tabs({ label, tabs }: Props) {
export default function Tabs({ label, tabs, selectedKey, onSelectionChange }: Props) {
return (
<AriaTabs className={() => stylex.props(styles.root).className ?? ""}>
<AriaTabs
selectedKey={selectedKey}
onSelectionChange={onSelectionChange === undefined ? undefined : (key) => onSelectionChange(String(key))}
className={() => stylex.props(styles.root).className ?? ""}
>
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
{tabs.map((tab) => (
<Tab