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
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:
@@ -26,6 +26,7 @@ Query provenance: every logged query becomes exactly explainable — what the po
|
||||
|
||||
### Changed
|
||||
|
||||
- **Configuration is three task-shaped pages, and a file-managed server says so once.** Groups, Blocklists, Rules, Local DNS, Upstreams and Settings — six navigation entries and six routes — become `/configuration/protection`, `/configuration/resolution` and `/configuration/system` under one labelled "Configuration" group. Protection is group-centred: pick a group and read its safe search, its blocklist sources, its rules and how many clients it filters, with the shared blocklist catalogue and "Update now" on a second tab. Resolution holds the upstream pool, local records and forward zones; System holds the settings sections, the restart-required annotations and "Reload certificates". The tab and the selected group are URL state, so the view you are reading is a link, and the six old paths stop resolving — no redirect, no alias. When a configuration file owns the server, these pages are no longer forms with everything greyed out: they render as definition lists and tables with the exact ZON key beside each value, a note saying which file to edit, and no input, checkbox, Add, Edit, Delete or Save anywhere — while runtime actions that do not touch the file (update blocklists now, reload certificates, pause and resume, deleting an observed client) stay ordinary working buttons. A new `GET /api/config/status` is the one place that answers who owns the configuration and whether a restart is pending; the admin never guesses from a 403, and until the endpoint answers nothing is editable. `restart_pending` is now the server's own state rather than a banner one browser tab remembered: it is set by the upstream and settings changes that need a restart, it survives a page reload, another tab sees it within a minute, and only restarting the process clears it. `GET /api/settings` loses its `authority` object, which said the same thing twice. Clients keeps its own top-level page, because naming an unknown device is operations, not configuration: the list is address, name, group and first/last seen, `?group=` filters it to one group, and each row opens `/clients/{id}` — the client's identity and where its name came from, the group whose policy it follows with a link to that group, and a link into Activity for its last 24 hours.
|
||||
- **The Dashboard is now Overview, and it takes Pi-hole's layout.** `/` redirects to `/overview`, and the page answers one question — what the resolver did over a period you choose — instead of laying out six widgets. The 1h/24h/7d/30d period is URL state (`/overview?period=1h`), so the view you are reading is a link you can send. Top to bottom: four neutral stat tiles — queries, blocked with its share, distinct clients, average response time — each linking into the rows behind its number; the query-volume timeline split blocked, cached and other; a new per-client chart on the same axis, the busiest clients named — by their registered or reverse-DNS name where they have one, exactly as the query tables name them — and the rest summed as "other"; and two donuts, query types and how queries were answered, with each upstream and forward zone named separately. Colours follow the identity of a client, a type or a route rather than its rank, so one client overtaking another between refreshes does not repaint the page. Each donut is drawn as decoration with a visible legend beside it and a table a screen reader reads instead of the graphic. All five panels describe one window — matched on the period, both bounds and the coverage watermark together — so a refresh that straddles a bucket boundary, or a retention pass mid-page, can never put a headline count above a chart of a different span. Panels load, fail and retry on their own: a failing donut leaves the charts standing. A period with nothing in it says "No queries in this period." rather than drawing an empty frame.
|
||||
- **The five health conditions moved to Diagnostics, and the nav item says when to look.** Protection, Upstreams, Query history, Diagnostics and Storage are now a compact strip at the top of `/diagnostics`, above the episodes that explain them, instead of a status list on the landing page. Each states its state in words and an icon as well as colour. A healthy condition is quiet; a degraded one is highlighted and links to what can fix it: protection to Blocklists, no reachable upstream to Upstreams, and a losing or failed query log or a low or critical disk to this same page filtered to the component that failed, with any time window cleared so the filter cannot hide the episodes it points at. Dropped rows are reported with the time of the newest drop, so a loss stays visible after the box recovers. When a health poll fails, the conditions on screen are labelled as the last reading that arrived rather than passing for the current state, and a Retry sits beside them. The Diagnostics navigation item carries a badge with the number of open episodes; it shows a plain "!" when the rollup is degraded with nothing open, and also when the last health poll failed, because an unknown must not look like good news. The badge is absent only when health answered and there was nothing to report.
|
||||
- **Pause moved to the sidebar, and the header indicator is gone.** The header carries nothing but the menu button and Log out. Pause and Resume sit at the foot of the navigation sidebar, above the version label, in both the desktop rail and the phone drawer — one global runtime action in the one place that belongs to the resolver rather than to whichever page you are on. The control still appears beside the detail of a query that was blocked, which is the other place the action answers what you are looking at. The control says what it is doing as well as what it offers: Pause while filtering is on, and while it is off, "Paused until 14:05" — or plain "Paused" when the pause has no end — above the Resume button, on every page. "Resume" on its own would name an action without naming the state it ends, and with the header indicator gone no other page could tell you filtering was off. Both controls read the same `protection` condition, so they cannot disagree, and a pause or resume is reflected immediately rather than at the next poll. Nothing offers to pause while protection is unavailable, since pausing a resolver with no filter snapshot changes nothing.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+87
-18
@@ -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.",
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-4
@@ -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("");
|
||||
+2
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
+148
-91
@@ -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)}</>;
|
||||
}
|
||||
+87
-103
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-10
@@ -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 && (
|
||||
+95
-62
@@ -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>
|
||||
);
|
||||
}
|
||||
+79
-95
@@ -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" },
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -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());
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+14
-8
@@ -4,7 +4,7 @@ nxdns serves its admin API itself, on `web.bind:web.port` (default port 8080), a
|
||||
|
||||
The machine-readable contract is `src/web/openapi.yaml`, which the running server hands out unauthenticated at `GET /api/openapi.yaml`. Request and response schemas for every operation live there. When this page and the YAML disagree, the YAML wins.
|
||||
|
||||
The route table is `src/web/routes.zig`; the [Operations](#operations) table below carries all 63 of its entries.
|
||||
The route table is `src/web/routes.zig`; the [Operations](#operations) table below carries all 64 of its entries.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -15,7 +15,7 @@ The route table is `src/web/routes.zig`; the [Operations](#operations) table bel
|
||||
413.
|
||||
- A request whose path matches but whose method does not answers 405 with an `Allow` header. An unknown `/api` path is a JSON 404; unknown non-`/api` paths fall through to the embedded SPA (`index.html`), so client-side routing works.
|
||||
- Item routes (`{id}`) match a positive integer id only.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones, clients and client prefixes take effect live. Upstreams and `/api/settings` are restart-required.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones, clients and client prefixes take effect live. Upstreams and `/api/settings` are restart-required, and once one of them is written `GET /api/config/status` reports `restart_pending: true`.
|
||||
- Every route has a policy class — `read`, `config_write` or `runtime_action` — and in file mode the `config_write` routes are refused. See [Configuration authority](#configuration-authority).
|
||||
|
||||
## Authentication
|
||||
@@ -62,7 +62,7 @@ A `config write` route in file mode answers **403** with the ordinary error enve
|
||||
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
|
||||
```
|
||||
|
||||
There is no `code` field and no richer body. 403 is used for nothing else in this API, so the status alone is the machine-readable part, and a client that wants to know the mode in advance reads it from `GET /api/settings` rather than probing for errors.
|
||||
There is no `code` field and no richer body. 403 is used for nothing else in this API, so the status alone is the machine-readable part, and a client that wants to know the mode in advance reads it from `GET /api/config/status` rather than probing for errors.
|
||||
|
||||
**401 comes first.** The router matches the path, spends a rate-limit token, checks the session, and only then checks the policy. So an unauthenticated request to a `config write` route in file mode is a 401, not a 403 — answering 403 first would tell an anonymous caller which routes exist.
|
||||
|
||||
@@ -72,23 +72,26 @@ There is no `code` field and no richer body. 403 is used for nothing else in thi
|
||||
|
||||
### Discovering the authority
|
||||
|
||||
`GET /api/settings` carries an `authority` object:
|
||||
`GET /api/config/status` answers with the authority and the process's restart state:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `mode` | `"database"` or `"managed_file"`. |
|
||||
| `authority` | `"database"` or `"managed_file"`. |
|
||||
| `path` | The managed file's path, or `null` in database mode. |
|
||||
| `reconciled_at` | Unix seconds when this process loaded the file, or `null` in database mode. |
|
||||
| `restart_pending` | True once this process has committed a configuration change that only a restart applies. |
|
||||
|
||||
All three keys are always present; the two nullable ones carry `null` rather than being omitted, so a client can read `authority.mode` without probing.
|
||||
All four keys are always present; the two nullable ones carry `null` rather than being omitted, so a client can read `authority` without probing.
|
||||
|
||||
```json
|
||||
{"mode": "database", "path": null, "reconciled_at": null}
|
||||
{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016}
|
||||
{"authority": "database", "path": null, "reconciled_at": null, "restart_pending": false}
|
||||
{"authority": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016, "restart_pending": false}
|
||||
```
|
||||
|
||||
The route requires a session, which is why the filesystem path is here rather than on the open `/api/version` and `/api/health`.
|
||||
|
||||
`restart_pending` is per-process state and nothing but process exit clears it. It rises when this server writes an upstream or a settings key — the changes the running process cannot apply — and it is never persisted, so a `false` read after a restart means the restart happened, not that the flag was cleared. In file mode it stays false: those writes are refused before any handler runs.
|
||||
|
||||
`reconciled_at` answers exactly one question: **when did this process last read the file?** Compare it against the file's mtime to spot a restart that has not happened yet. It is a hint and not a verdict, in both directions — a clock that stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make a newer file look older, and the database can change without either timestamp moving. It does not tell you whether the file and the running configuration agree; answering that would take content hashing, which nxdns deliberately does not do.
|
||||
|
||||
## Operations
|
||||
@@ -159,6 +162,7 @@ Auth `open` means no session is required; `session` means a valid session cookie
|
||||
| POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking |
|
||||
| GET | `/api/settings` | session | counted | read | Read the scalar settings |
|
||||
| PUT | `/api/settings` | session | counted | config write | Update settings |
|
||||
| GET | `/api/config/status` | session | counted | read | Read the configuration authority and restart state |
|
||||
| POST | `/api/certs/reload` | session | counted | runtime action | Reload the TLS certificates from disk |
|
||||
|
||||
There is no `POST /api/clients`: client rows come from DNS activity or import, never from the API.
|
||||
@@ -169,6 +173,8 @@ Static assets are not routes. The router sends unmatched non-`/api` paths to the
|
||||
|
||||
## Settings keys
|
||||
|
||||
The envelope both operations answer with is `{settings, restart_required}`: the stored values, and the list of keys a restart applies. It says nothing about the live authority or a restart already owed — those are per-process facts, and `GET /api/config/status` is their one home.
|
||||
|
||||
`GET /api/settings` and `PUT /api/settings` speak the `section.field` keys of [the configuration reference](configuration.md), with the values in their database spelling — notably `logging.level` is `"error"`, not `"err"`. Two keys behave differently over the API than in the file: `web.password` is write-only (accepted on a `PUT`, never returned, hashed before storage), and `web.password_hash` is neither readable nor directly writable, because a client that could install a hash could install one whose password it already knows.
|
||||
|
||||
In file mode `PUT /api/settings` is refused with the 403 above, password changes included. The password then lives where the rest of the configuration lives: set `web.password` in the file and restart. See [Password and hash](configuration.md#password-and-hash).
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Milestone 32: task-shaped configuration and file mode
|
||||
|
||||
Redesign step 5 of specs/ui-redesign.md (§Information architecture, §File mode, §API changes `/api/config/status`, §Deletions, build-sequence step 5). The six top-level configuration routes (Groups, Blocklists, Rules, Local DNS, Upstreams, Settings) become three task-shaped subpages — `/configuration/protection`, `/configuration/resolution`, `/configuration/system` — in both editable (database-mode) and read-only (file-mode) forms; the server gains `GET /api/config/status` with a server-owned `restart_pending`; the client-only restart banner and the standing read-only banner die; Clients gains its detail route. Old routes are replaced atomically, no aliases.
|
||||
|
||||
Codex design review round 1 folded in; corrections marked (D1..D14).
|
||||
|
||||
## Sessions
|
||||
|
||||
S1 (Zig endpoint + envelope change + the minimal admin data-layer migration that keeps tsc green), then S2 (admin: configuration restructure + file mode + shell), then S3 (admin: Clients redesign). Sequential — S2 and S3 both touch `routes.tsx` and the clients feature boundary.
|
||||
|
||||
---
|
||||
|
||||
## Session S1: `/api/config/status` and server-owned `restart_pending`
|
||||
|
||||
### S1.1 The endpoint
|
||||
|
||||
`GET /api/config/status`, new handler `src/web/handlers/config.zig`, route policy `.read`, auth `.session`. Route count 63 → 64 (bump the count test, routes.zig:159-161). Response:
|
||||
|
||||
```json
|
||||
{"authority": "database" | "managed_file", "path": null | "/etc/nxdns/config.zon", "reconciled_at": null | 1755600000, "restart_pending": false}
|
||||
```
|
||||
|
||||
- `authority`/`path`/`reconciled_at` read from the existing `WebState.authority` (server.zig:113-125) and `WebState.reconciled_at` (server.zig:126-130). `path` is null exactly when authority is `database`; `reconciled_at` is null in database mode (both already true of the state today).
|
||||
- `restart_pending`: new `restart_pending: std.atomic.Value(bool) = .init(false)` on `WebState` beside `reconciled_at`, same "per-process state, never persisted" doc comment. It is process state: set by database-mode mutations that need a restart, cleared by nothing but process exit.
|
||||
|
||||
### S1.2 Setters
|
||||
|
||||
Exactly the mutations whose changes do not take effect live (the sites already emitting per-response `restart_required`):
|
||||
|
||||
- `src/web/handlers/upstreams.zig` create, update and remove.
|
||||
- `src/web/handlers/settings.zig` `put` (settings.zig:521-529) — only when the patch touches a restart-required key. The server already owns that key set (`restart_required_keys`, settings.zig:68); a patch touching only `web.password` must not set the flag (password applies live, docs/reference/api.md:29). This replaces the admin's `patchRequiresRestart` (S2 deletes it) — the server becomes the one home of that decision.
|
||||
- **(D4)** The flag is stored only *after* the database mutation commits — a validation failure or SQL error must leave it untouched.
|
||||
|
||||
File-mode requests never reach these handlers (router 403, router.zig:173-176), so the flag can only rise in database mode.
|
||||
|
||||
### S1.3 Envelope change
|
||||
|
||||
`GET /api/settings` loses its `authority` object (settings.zig:485-501 serialization, openapi.yaml). `/api/config/status` is the one home; two copies of the same fact drift. `restart_required` (the key list) stays on the settings envelope — field-level metadata about the settings form, not process state. Per-response `restart_required: true` on upstream views stays (harmless, view-local).
|
||||
|
||||
### S1.4 Surfaces and tests
|
||||
|
||||
- `src/web/openapi.yaml`: new path + `ConfigStatus` schema; `SettingsEnvelope` loses `authority`. Drift tests stay green (openapi.zig).
|
||||
- `docs/reference/api.md`: new route row (docs_drift_test.zig enforces per-operation rows); settings envelope description updated.
|
||||
- **(D10)** `contract_sample_walk` is a static table — add a `get_config_status` sample explicitly, typed `ConfigStatus`, so the new shape is byte-checked against a real response like every other endpoint.
|
||||
- `src/web/web_integration_test.zig`: config-status view struct. **(D4, R2-1)** A transition matrix where **every setter is proved `false → true` in isolation** — a probe made while the flag is already `true` proves nothing. Isolation comes from a fresh server process per setter (the integration harness already boots servers per scenario; restarting between assertions is also the proof that restart clears the flag):
|
||||
- database mode, fresh boot: `{"database", null, null, false}`;
|
||||
- password-only settings patch → still `false` (re-authenticate after setting a non-empty password — that patch revokes sessions and turns authentication on);
|
||||
- a **mixed** patch (password + a restart-required key) → `true` (**R3-2**: re-authenticate before the status assertion — the password half revokes sessions, exactly as in the password-only case);
|
||||
- a non-password settings patch → `true`;
|
||||
- upstream create → `true`; upstream update → `true` (fresh process, upstream seeded first while the flag is then reset by the restart); upstream delete → `true` (fresh process, second upstream seeded directly before the probe);
|
||||
- a rejected mutation (invalid body → 4xx) → `false` preserved; a mutation failing at the SQL layer preserves `false` too — **(R3-3)** the settings write fault seam is reachable from in-file tests, so pin the committed-write ordering with it; no escape clause;
|
||||
- file mode (the existing `--config` fixture): `{"managed_file", path, reconciled_at != null, false}`, and a config write still 403s.
|
||||
- **(D1)** S1 owns the *minimal* admin migration that keeps the tree green, nothing styled: `admin/src/lib/types.ts` (`ConfigStatus` added, `SettingsEnvelope.authority` removed), `admin/src/lib/contractSamples.gen.ts` (regenerated via `zig build test -Dintegration -Dcontract-samples-out=...`), `admin/src/lib/api.ts` (`getConfigStatus`), `admin/src/lib/queries.ts` (`configStatusQuery` + `queryKeys.configStatus`; **(D5)** declared with `staleTime: 0`, `refetchOnWindowFocus: "always"`, and a 60 s `refetchInterval` — restart truth must not depend on the mutating tab), `admin/src/features/settings/authority.ts` (`useAuthority`/`useReadOnlyConfig` retarget to the config status query, same exported names and file path — S2 moves the file, S1 does not), and `authority.test.tsx` migrated to the new source. `cd admin && npx tsc --noEmit` and `npx vitest run` green at S1 exit.
|
||||
- **(R2-2)** S1 also delivers the admin side of "Reload certificates" that S2's System page consumes, since S1 owns the contract surfaces: the `api.ts` wrapper for the existing `/api/certs/reload` endpoint, its result type in `types.ts`, and — if the endpoint has no contract sample today — the `contract_sample_walk` entry for it, matching how the walk handles the other POST actions.
|
||||
|
||||
### S1.5 Acceptance (S1)
|
||||
|
||||
- [ ] `zig build test` and `-Dintegration` green; route count test at 64; openapi + docs drift green; the D4 matrix passes.
|
||||
- [ ] Admin tsc + vitest green after the S1-owned migration.
|
||||
|
||||
---
|
||||
|
||||
## Session S2: task-shaped configuration, file mode, shell
|
||||
|
||||
Everything below follows specs/ui-redesign.md §File mode verbatim where quoted; that section is binding.
|
||||
|
||||
### S2.1 Routes — created and deleted in one change
|
||||
|
||||
- `/configuration/protection`, `/configuration/resolution`, `/configuration/system` — three routes under the shell; **no `/configuration` landing route** (a bare `/configuration` falls to the router's not-found handling; build nothing).
|
||||
- Deleted in the same edit: `/groups`, `/blocklists`, `/rules`, `/local-dns`, `/upstreams`, `/settings`. No aliases, no redirects.
|
||||
- Loaders (fire-and-forget `ensureQueryData`, never awaited — the m30 pattern): protection → groups + blocklists + rules + clients (**D3**: the group detail's client count needs it); resolution → upstreams + localRecords + forwardZones; system → settings. Every configuration loader also starts `configStatusQuery`.
|
||||
- Tab state is URL search (`?tab=`), validated with a per-page default. **(D11)** `?group=` on Protection: positive integer; an unknown or deleted id falls back to the first group and normalizes the URL with a `replace` navigation; changing `tab` preserves `group`; user-initiated tab and group changes `push` history entries so back/forward walks them. `ui/Tabs.tsx` is uncontrolled — add a controlled prop, not a second component.
|
||||
|
||||
### S2.2 The three pages
|
||||
|
||||
**Protection** (`admin/src/features/configuration/ProtectionPage.tsx` + feature dir), tabs `groups` (default) | `sources`:
|
||||
|
||||
- Groups tab is group-centred per §Protection: group list (master) with selected-group detail (`?group=<id>`): effective safe-search setting, assigned blocklist sources (the existing `GroupSourcesEditor` capability), rules scoped to the selected group, and a client count from `clientsQuery` linking to `/clients?group=<id>`. **(D3)** The `?group=` *filter* on the Clients page lands in S3; within this milestone the link is complete at milestone acceptance, and S2's test asserts only the href.
|
||||
- Sources tab: the shared blocklist catalogue (list, add/edit/delete in database mode) and the "Update now" runtime action with immediate action feedback (started/failed). **(D7)** `SourceStatusSection` and `refreshStore` are deleted, per §Deletions — the ephemeral status readout is gone by ruling; refresh outcomes live in Diagnostics. Their tests are intentionally dead and listed as such in the session report.
|
||||
- The existing feature components are raw material, not the deliverable: capabilities move, old page shells die. One home per capability; no re-export shims.
|
||||
|
||||
**Resolution** (`ResolutionPage.tsx`), tabs `upstreams` (default) | `records` | `zones`: the upstream pool (create/edit/toggle/delete), local records, forward zones — the existing capabilities rehomed.
|
||||
|
||||
**System** (`SystemPage.tsx`), no tabs: the settings sections (the `defineSection` registry survives), restart-required key annotations from the envelope's `restart_required` list, and **(D8)** the "Reload certificates" runtime action (§File mode preserves it): the existing `/api/certs/reload` call with result/error rendering, enabled in both authority modes, with tests proving both.
|
||||
**(D13)** The derived `auth_enabled` value renders as derived authentication status sourced from `web.password` — it has no ZON key and must not be shown with an invented one.
|
||||
|
||||
### S2.3 File mode is a rendering, not a disabled form
|
||||
|
||||
Per §File mode, in file mode each page renders **definition lists for scalars and tables or cards for collections, with human labels and the exact ZON key shown secondarily** (e.g. `logging.retention_days`). Binding, verbatim: "No text inputs, no checkboxes, no Add/Edit/Delete, no disabled form shells, no simulated Save." A short page note says where edits happen (the config file path) and that a restart may be needed. Runtime actions (update blocklists now, reload certificates, pause/resume, login/logout, delete an observed undeclared client) stay ordinary enabled buttons.
|
||||
|
||||
Mechanically: each page selects a read-only rendering branch on resolved authority — not `<fieldset disabled>` (SettingsPage.tsx:385 dies), not per-control `disabled` props (the GroupsPage/RulesPage pattern dies). A small `DefinitionList` component in `ui/` (none exists).
|
||||
|
||||
**(D6)** Authority is three-state in the UI: `pending | resolved | failed`. A configuration page renders neither form nor definition list until the status query resolves (skeleton/pending presentation); a failed status query renders an explicit error state with Retry, never editable forms by default. `undefined` is not database mode — the current hook's fallback dies with it.
|
||||
|
||||
**(R2-3)** The lock is global, not page-local: `useReadOnlyConfig` (and every mutation affordance that consults it, including the interim Clients page) treats *anything other than resolved database authority* — pending, failed, or `managed_file` — as locked. And the failure must be visible outside configuration pages too: when the config status query is in a failed state, the shell renders a compact status-unavailable indicator (beside where the restart notice would sit), so a dead `/api/config/status` cannot silently hide file authority or a pending restart on Overview/Activity/Diagnostics/Clients. Pin both with tests. **(R3-4)** The gate covers configuration renderings and configuration mutations only: authority-independent runtime actions (update now, reload certificates, pause/resume, observed-client delete, login/logout) stay visible and enabled alongside a pending or failed status.
|
||||
|
||||
### S2.4 Shell: authority line, lock indicator, restart notice
|
||||
|
||||
- **Authority line** in the Configuration sub-navigation area, exactly the §File mode shape: `File-managed · /etc/nxdns/config.zon · loaded 19 Aug 2026, 08:42` — wording is "loaded", never a claim about current file contents; `reconciled_at`, browser-local. File mode only.
|
||||
- **Compact lock indicator** as a small shared component beside affected controls elsewhere (S3's Clients edit affordances consume it).
|
||||
- **`ReadOnlyConfigBanner` deleted** (AppShell.tsx:274). **`RestartBanner`, `restartBanner.ts` and `patchRequiresRestart` deleted** (AppShell.tsx:273, settings/restartBanner.ts, SettingsPage.tsx:15-19 and the raise sites) — replaced by a non-dismissable shell notice when `restart_pending` is true, driven by `configStatusQuery`. **(D5)** Coherence is the query options S1 set (staleTime 0, always-refetch-on-focus, 60 s interval) plus `configStatus` invalidation in the S2.5 mutations; a notice raised by another tab or API client appears within one interval, and a process restart clears it on the next fetch — the smoke verifies the restart half. Wording: pending changes need a restart; a browser refresh must not clear it (server state — pin with a remount test).
|
||||
- `NAV_ITEMS` (AppShell.tsx:19-30): Overview, Activity, Clients, Diagnostics, then a labelled "Configuration" group containing Protection, Resolution, System — a labelled group, not a collapsible tree. The six old items leave.
|
||||
|
||||
### S2.5 Data layer
|
||||
|
||||
- **(D2)** `authority.ts` moves to `admin/src/features/configuration/authority.ts`; S2 updates **every** consumer's import, and for the three modules under `features/clients/**` this is an explicitly granted exception to S3's ownership: import-path-only edits, no behavioral change.
|
||||
- Mutations that can set `restart_pending` (upstreams create/update/delete, settings put) invalidate `queryKeys.configStatus` in `onSuccess`.
|
||||
|
||||
### S2.6 Test migration and sweep
|
||||
|
||||
No-shrink rule: every behavior pinned by the old page tests is re-pinned in the new homes or listed as intentionally dead with its page (D7's status section is the known dead set). Old test files: GroupsPage, BlocklistsPage, BlocklistForm, RulesPage, LocalDnsPage, UpstreamsPage, SettingsPage, RestartBanner, authority (migrated in S1), AppShell. New pinned behaviors at minimum:
|
||||
|
||||
- Tab and group selection are URL state; back/forward restores them; group survives tab change; unknown group falls back with a replace (D11).
|
||||
- **(D12)** File mode renders zero mutation controls: assert the configuration content area contains no `input`, `textarea`, `select`, `[role="combobox"]`, `[role="checkbox"]`, `[contenteditable]`, and no `button` outside an explicit allowlist (tab triggers, the named runtime actions, Retry). Definition lists show ZON keys.
|
||||
- Authority pending shows no form; authority failure shows the error state, not editable forms (D6).
|
||||
- The authority line renders in file mode only; the restart notice appears from config status and survives remount; deleted routes are gone.
|
||||
- Loaders fire-and-forget.
|
||||
|
||||
### S2.7 Acceptance (S2)
|
||||
|
||||
- [ ] tsc, vitest, oxlint, prettier clean; `npm run build` and `zig build -Dadmin-dist=admin/dist` green.
|
||||
- [ ] No import, route string or nav reference to `/groups`, `/blocklists`, `/rules`, `/local-dns`, `/upstreams`, `/settings` (API paths `/api/*` stay).
|
||||
- [ ] S2.6 behaviors pinned.
|
||||
|
||||
---
|
||||
|
||||
## Session S3: Clients redesign
|
||||
|
||||
### S3.1 `/clients` and `/clients/$id`
|
||||
|
||||
Per §Clients: Clients keeps primary navigation because naming unknown devices is operations, not configuration.
|
||||
|
||||
- `/clients` list: identity-first — address, name with the *learned* marker (only place the marker appears; query tables keep unmarked `ClientName`), group, first/last seen. **(D3)** Implements the `?group=<id>` filter Protection links to (validated positive integer; unknown id shows an empty-filter state with a clear-filter action, not a crash). Edit/delete follow file-mode rules (declared clients read-only in file mode with the lock indicator; observed-client delete stays a runtime action).
|
||||
- **(D9)** `/clients/$id` detail: `$id` is the **numeric row id** (the identifier `PUT /api/clients/{id}` already uses). Data comes from `clientsQuery` — the loader `ensureQueryData`s it, so a cold deep link fetches the list once; an id not in the loaded list renders an explicit missing-client state (deleted or never existed), no crash, no retry loop. Content: identity (name, learned/declared/edited provenance, first/last seen), effective policy (group, link to `/configuration/protection?group=`), and an Activity link `/activity?mode=history&client=<addr>&since=<now-24h>&until=<now>` with absolute bounds computed at click time. No new API.
|
||||
- **Network assignments**: the prefix-assignment section renames from "Prefixes" to "Network assignments" (§Clients), stays on the list page below the table, existing editor semantics in database mode, definition rendering in file mode.
|
||||
- **(D9, D14)** S3 owns `admin/src/features/clients/**`, `admin/src/routes.tsx` (the clients route edits), `CHANGELOG.md`, and — if a list-to-detail selector needs a home — a minimal additive touch of `admin/src/lib/queries.ts`. S3 does not touch `AppShell.tsx` (the nav item already exists and covers the child route).
|
||||
|
||||
### S3.2 Acceptance (S3)
|
||||
|
||||
- [ ] tsc, vitest, oxlint, prettier clean; builds green.
|
||||
- [ ] Old ClientsPage tests migrated under the no-shrink rule; new: cold deep link to `/clients/$id` fetches and renders (test with an empty cache); missing id → missing-client state; activity link carries absolute bounds; `?group=` filters and the unknown-group state renders; learned marker appears on Clients pages only.
|
||||
- [ ] **(R2-4)** File-mode Clients coverage, explicitly: no edit affordance for observed or declared clients; observed-client delete stays enabled (runtime action); declared-client delete is locked; Network assignments render with zero mutation controls; pending or failed authority exposes no *configuration* mutation control (the R2-3 lock) — observed-client delete, an authority-independent runtime action, stays enabled throughout (R3-4). The client-authority assertions currently living in the settings `authority.test.tsx` are re-pinned here. **(R3-1)** Placement: when S2 moves `authority.test.tsx` to `features/configuration/`, it moves the *clients-specific* assertions into `features/clients/ClientsPage.test.tsx` instead — a test-only exception to S3's ownership, adjusting mechanics only — so S3 owns every clients test it must rewrite and never edits `features/configuration/`.
|
||||
|
||||
---
|
||||
|
||||
## Smoke (orchestrator, after S3)
|
||||
|
||||
Real binary + built bundle, both modes:
|
||||
|
||||
1. Database mode (no `--config`): walk Protection/Resolution/System editable forms; create an upstream → restart notice appears and survives reload; restart the process → notice gone (D5). Screenshots: the three pages, the nav group, the restart notice, desktop + 390 px.
|
||||
2. File mode (smoke config): the three pages render definition lists (no mutation controls), authority line shows path + loaded time, a config API write 403s, "Update now" and "Reload certificates" still work (D8). Screenshots: Protection and System in file mode.
|
||||
3. Clients: list with learned marker, `?group=` filter from Protection, detail page via cold deep link, Clients→Activity bounds link. Screenshots.
|
||||
4. Screenshots shown to the owner before any commit (standing rule).
|
||||
|
||||
## File ownership
|
||||
|
||||
S1: `src/web/**`, `docs/reference/api.md`, `admin/src/lib/types.ts`, `admin/src/lib/contractSamples.gen.ts`, `admin/src/lib/api.ts`, `admin/src/lib/queries.ts`, `admin/src/features/settings/authority.ts`, `admin/src/features/settings/authority.test.tsx` (D1). S2: `admin/src/**` except `features/clients/**` — with two narrow exceptions inside `features/clients/**`: import-path-only edits (D2), and the R3-1 test-only move of the clients-specific authority assertions into `features/clients/ClientsPage.test.tsx`. S3: `admin/src/features/clients/**`, `admin/src/routes.tsx`, minimal additive `admin/src/lib/queries.ts`, `CHANGELOG.md` (D9, D14). Sequential, so the shared files never see two writers at once.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No `/configuration` landing route; no route aliases or redirects for the six deleted routes.
|
||||
- No disabled form shells, no simulated Save, no full-width authority banner (§File mode contract).
|
||||
- No new server mutation endpoints; no restart endpoint; no persistence for `restart_pending`.
|
||||
- No client-side authority guessing from 403s — authority comes from `/api/config/status` only, and unknown is never editable (D6).
|
||||
- No Overview/Activity/Diagnostics changes; step 6 (contract closure) stays out.
|
||||
- No new dependencies.
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] All session gates green; `zig build test -Dintegration` green end-to-end (contract samples byte-compare included).
|
||||
- [ ] Smoke walked in both modes with screenshots shown to the owner.
|
||||
- [ ] CHANGELOG updated.
|
||||
@@ -119,6 +119,7 @@ comptime {
|
||||
_ = @import("web/handlers/upstreams.zig");
|
||||
_ = @import("web/handlers/pause.zig");
|
||||
_ = @import("web/handlers/settings.zig");
|
||||
_ = @import("web/handlers/config.zig");
|
||||
_ = @import("web/handlers/auth.zig");
|
||||
_ = @import("web/static.zig");
|
||||
_ = @import("web/openapi.zig");
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! `GET /api/config/status` — which source governs this process's
|
||||
//! configuration, and whether a change already written needs a restart.
|
||||
//!
|
||||
//! One home for both facts. `authority`, `path` and `reconciled_at` are the
|
||||
//! invocation's answer (milestone-20 ruling 1): the database carries no record
|
||||
//! of who wrote it, so only the running process can say. `restart_pending` is
|
||||
//! the running process's own memory of a mutation whose effect waits for the
|
||||
//! next start — the upstream pool and the scalar settings are both built at
|
||||
//! startup. Nothing clears it but process exit, so a client that sees it false
|
||||
//! after a restart is seeing the truth rather than a cleared flag.
|
||||
//!
|
||||
//! The route needs a session: `path` is a filesystem path and must never reach
|
||||
//! the open `/api/version` or `/api/health`.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const http_util = @import("../http_util.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
pub const View = struct {
|
||||
authority: []const u8,
|
||||
/// The managed file, or null in database mode.
|
||||
path: ?[]const u8,
|
||||
/// When this process loaded the managed file, in epoch seconds; null in
|
||||
/// database mode, which never reconciles. It means exactly that: a file
|
||||
/// whose mtime is newer has not been loaded by the running process.
|
||||
reconciled_at: ?i64,
|
||||
restart_pending: bool,
|
||||
};
|
||||
|
||||
pub fn view(state: *const server.WebState) View {
|
||||
return switch (state.authority) {
|
||||
.database => .{
|
||||
.authority = "database",
|
||||
.path = null,
|
||||
.reconciled_at = state.reconciled_at,
|
||||
.restart_pending = state.restart_pending.load(.monotonic),
|
||||
},
|
||||
.managed_file => |path| .{
|
||||
.authority = "managed_file",
|
||||
.path = path,
|
||||
.reconciled_at = state.reconciled_at,
|
||||
.restart_pending = state.restart_pending.load(.monotonic),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
return http_util.respondJson(request, .ok, view(state), &.{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "database authority reports no file and no reconcile time" {
|
||||
var state: server.WebState = .{ .gpa = testing.allocator };
|
||||
const current = view(&state);
|
||||
try testing.expectEqualStrings("database", current.authority);
|
||||
try testing.expectEqual(@as(?[]const u8, null), current.path);
|
||||
try testing.expectEqual(@as(?i64, null), current.reconciled_at);
|
||||
try testing.expect(!current.restart_pending);
|
||||
}
|
||||
|
||||
test "file authority reports the path and the load time" {
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.authority = .{ .managed_file = "/etc/nxdns/config.zon" },
|
||||
.reconciled_at = 1_700_000_042,
|
||||
};
|
||||
const current = view(&state);
|
||||
try testing.expectEqualStrings("managed_file", current.authority);
|
||||
try testing.expectEqualStrings("/etc/nxdns/config.zon", current.path.?);
|
||||
try testing.expectEqual(@as(?i64, 1_700_000_042), current.reconciled_at);
|
||||
}
|
||||
|
||||
test "the flag the mutations raise is what the view reports" {
|
||||
var state: server.WebState = .{ .gpa = testing.allocator };
|
||||
try testing.expect(!view(&state).restart_pending);
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
try testing.expect(view(&state).restart_pending);
|
||||
}
|
||||
+107
-39
@@ -184,6 +184,32 @@ fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether the patch names a key whose change waits for a restart. The key
|
||||
/// table above is the authority, so a key that stops being restart-required
|
||||
/// stops raising the flag without anyone remembering this function exists.
|
||||
/// A patch carrying `web.password` alone touches no such key: the new hash is
|
||||
/// installed live (ruling 17 of milestone 16).
|
||||
fn touchesRestartRequiredKey(patch: Patch) bool {
|
||||
inline for (@typeInfo(Patch).@"struct".fields) |section_field| {
|
||||
if (@field(patch, section_field.name)) |section| {
|
||||
inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| {
|
||||
if (@field(section, field.name) != null) {
|
||||
if (comptime isRestartRequired(section_field.name ++ "." ++ field.name)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isRestartRequired(comptime key: []const u8) bool {
|
||||
@setEvalBranchQuota(10_000);
|
||||
for (keys) |listed| {
|
||||
if (std.mem.eql(u8, listed, key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether the patch carries a new password.
|
||||
fn newPassword(patch: Patch) ?[]const u8 {
|
||||
const web = patch.web orelse return null;
|
||||
@@ -346,6 +372,10 @@ pub fn applyPut(
|
||||
return .{ .fail = .{ .internal = err } };
|
||||
};
|
||||
|
||||
// After the commit, never before: a validation failure or a write that
|
||||
// rolled back changed nothing, so it owes nobody a restart.
|
||||
if (touchesRestartRequiredKey(patch)) state.restart_pending.store(true, .monotonic);
|
||||
|
||||
if (replacement) |hash| {
|
||||
// Ruling 17, both halves: the running server must verify against the
|
||||
// new hash at once — a restart-free credential change — and the
|
||||
@@ -470,36 +500,6 @@ pub const hash_stall_control = if (builtin.is_test) struct {
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which source governs this process's configuration, and when it last read
|
||||
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
|
||||
/// read-only — declaratively, rather than by probing a route for a 403.
|
||||
///
|
||||
/// It rides `GET /api/settings` because that route needs a session: the
|
||||
/// managed path is a filesystem path and must never reach the open
|
||||
/// `/api/version` or `/api/health`.
|
||||
///
|
||||
/// `reconciled_at` means exactly "this process loaded the file at T". A file
|
||||
/// whose mtime is newer has not been loaded by the running process. It cannot
|
||||
/// answer "is the file what the server uses" — a stepped clock or a preserved
|
||||
/// mtime defeats the comparison in either direction, and the database can move
|
||||
/// under `nxdns import` without either timestamp moving.
|
||||
const AuthorityView = struct {
|
||||
mode: []const u8,
|
||||
path: ?[]const u8,
|
||||
reconciled_at: ?i64,
|
||||
};
|
||||
|
||||
fn authorityView(state: *const server.WebState) AuthorityView {
|
||||
return switch (state.authority) {
|
||||
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
|
||||
.managed_file => |path| .{
|
||||
.mode = "managed_file",
|
||||
.path = path,
|
||||
.reconciled_at = state.reconciled_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
|
||||
@@ -515,7 +515,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
|
||||
const cfg = loaded catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
|
||||
|
||||
return respondSettings(request, state, .ok, cfg);
|
||||
return respondSettings(request, .ok, cfg);
|
||||
}
|
||||
|
||||
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
@@ -524,20 +524,18 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
|
||||
|
||||
return switch (try applyPut(state, io, request.arena, parsed.value)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
|
||||
.config => |cfg| respondSettings(request, state, .ok, cfg),
|
||||
.config => |cfg| respondSettings(request, .ok, cfg),
|
||||
};
|
||||
}
|
||||
|
||||
fn respondSettings(
|
||||
request: *Request,
|
||||
state: *const server.WebState,
|
||||
status: std.http.Status,
|
||||
cfg: model.Config,
|
||||
) HandlerError!void {
|
||||
/// `restart_required` is field-level metadata about this form — which keys need
|
||||
/// a restart to take effect. Whether one is *owed* right now is process state
|
||||
/// and lives on `GET /api/config/status`, which is also the one home of the
|
||||
/// configuration authority: two copies of the same fact drift.
|
||||
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
|
||||
return http_util.respondJson(request, status, .{
|
||||
.settings = view(cfg),
|
||||
.restart_required = restart_required_keys,
|
||||
.authority = authorityView(state),
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -769,6 +767,76 @@ test "an empty password is not a password change" {
|
||||
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
|
||||
}
|
||||
|
||||
test "a write that fails at the SQL layer owes no restart" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.dns = .{ .port = 5353 };
|
||||
|
||||
// The seam fails `writeSettings` after its transaction has begun, which is
|
||||
// the ordering this pins: the flag is stored only once the commit lands, so
|
||||
// a rolled-back write leaves the operator nothing to restart for.
|
||||
write_fault.armed = true;
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expect(outcome.fail == .internal);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
// The same patch, written for real, does raise it.
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), patch) == .config);
|
||||
try testing.expect(bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a rejected patch raises no restart flag" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var invalid: Patch = .{};
|
||||
invalid.dns = .{ .port = 0 };
|
||||
try testing.expect((try applyPut(&bench.state, bench.io(), bench.arena(), invalid)).fail == .invalid);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
var unknown_enum: Patch = .{};
|
||||
unknown_enum.logging = .{ .level = "verbose" };
|
||||
try testing.expect((try applyPut(&bench.state, bench.io(), bench.arena(), unknown_enum)).fail == .invalid);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "only a patch naming a restart-required key raises the flag" {
|
||||
var password_only: Patch = .{};
|
||||
password_only.web = .{ .password = "correct horse battery staple" };
|
||||
try testing.expect(!touchesRestartRequiredKey(password_only));
|
||||
|
||||
var mixed: Patch = .{};
|
||||
mixed.web = .{ .password = "correct horse battery staple", .port = 9090 };
|
||||
try testing.expect(touchesRestartRequiredKey(mixed));
|
||||
|
||||
var elsewhere: Patch = .{};
|
||||
elsewhere.dns = .{ .port = 5353 };
|
||||
try testing.expect(touchesRestartRequiredKey(elsewhere));
|
||||
|
||||
try testing.expect(!touchesRestartRequiredKey(.{}));
|
||||
}
|
||||
|
||||
test "a password-only put leaves the restart flag alone" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
bench.state.sessions = &sessions;
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.web = .{ .password = "correct horse battery staple" };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), patch) == .config);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "reading the settings with no database is unavailable" {
|
||||
var state: server.WebState = .{ .gpa = testing.allocator };
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
|
||||
@@ -52,6 +52,10 @@ pub fn applyCreate(
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
|
||||
// Ruling 12: the pool is built at startup, so the row now stored governs
|
||||
// nothing until the next one. Stored after the insert, never before — a
|
||||
// rejected url or a conflict owes no restart.
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
@@ -83,6 +87,7 @@ pub fn applyUpdate(
|
||||
|
||||
upstreams_repo.updateUpstream(database, id, item) catch |err|
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -106,6 +111,7 @@ pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i6
|
||||
|
||||
upstreams_repo.deleteUpstream(database, id) catch |err|
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -245,6 +251,26 @@ test "a url the validator refuses never reaches the database" {
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
|
||||
}
|
||||
|
||||
test "a refused upstream write owes no restart, and an accepted one does" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expect((try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.url = "udp://1.1.1.1:53",
|
||||
})).fail == .invalid);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
try testing.expect((try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).? == .not_found);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
try testing.expect(applyDelete(&bench.state, bench.io(), bench.arena(), 999).? == .not_found);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
try testing.expect(bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a duplicate url is a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
|
||||
+41
-14
@@ -35,8 +35,8 @@ info:
|
||||
envelope, naming the file. Operations that change runtime state —
|
||||
`/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
|
||||
login and the logout — stay live, as does `DELETE /api/clients/{id}`
|
||||
for a client the file does not declare. `GET /api/settings` reports
|
||||
the live authority, so a client reads the mode rather than
|
||||
for a client the file does not declare. `GET /api/config/status`
|
||||
reports the live authority, so a client reads the mode rather than
|
||||
discovering it from a rejection.
|
||||
|
||||
servers:
|
||||
@@ -1753,6 +1753,26 @@ paths:
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/config/status:
|
||||
get:
|
||||
summary: Read the configuration authority and restart state
|
||||
description: |
|
||||
Which source governs this process's configuration, and whether a
|
||||
change already written waits for a restart. Both are per-process
|
||||
facts the database cannot answer, and this is their one home: a
|
||||
client reads the mode here rather than discovering it from a 403.
|
||||
responses:
|
||||
"200":
|
||||
description: The live authority and the pending-restart flag.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigStatus"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
|
||||
/api/certs/reload:
|
||||
post:
|
||||
summary: Reload the TLS certificates from disk
|
||||
@@ -2910,7 +2930,7 @@ components:
|
||||
|
||||
SettingsEnvelope:
|
||||
type: object
|
||||
required: [settings, restart_required, authority]
|
||||
required: [settings, restart_required]
|
||||
properties:
|
||||
settings:
|
||||
$ref: "#/components/schemas/Settings"
|
||||
@@ -2919,21 +2939,21 @@ components:
|
||||
items: { type: string }
|
||||
description: |
|
||||
Every `section.field` key that needs a restart to take effect —
|
||||
currently all of them.
|
||||
authority:
|
||||
$ref: "#/components/schemas/Authority"
|
||||
currently all of them. Whether a restart is *owed* right now is
|
||||
process state, and lives on `/api/config/status`.
|
||||
|
||||
Authority:
|
||||
ConfigStatus:
|
||||
type: object
|
||||
description: |
|
||||
Which source governs this process's configuration. This is how a
|
||||
client learns that configuration is read-only; it never has to probe
|
||||
a write route for a 403. The block rides this authenticated endpoint
|
||||
because `path` is a filesystem path, and never appears on the open
|
||||
`/api/version` or `/api/health`.
|
||||
required: [mode, path, reconciled_at]
|
||||
Which source governs this process's configuration, and whether a
|
||||
committed change waits for a restart. This is how a client learns
|
||||
that configuration is read-only; it never has to probe a write route
|
||||
for a 403. It rides an authenticated endpoint because `path` is a
|
||||
filesystem path, and never appears on the open `/api/version` or
|
||||
`/api/health`.
|
||||
required: [authority, path, reconciled_at, restart_pending]
|
||||
properties:
|
||||
mode:
|
||||
authority:
|
||||
type: string
|
||||
enum: [database, managed_file]
|
||||
description: |
|
||||
@@ -2955,6 +2975,13 @@ components:
|
||||
a stepped clock or a preserved mtime defeats the comparison
|
||||
either way, and `nxdns import` can move the database without
|
||||
moving either timestamp.
|
||||
restart_pending:
|
||||
type: boolean
|
||||
description: |
|
||||
True once this process has committed a configuration change that
|
||||
takes effect only at the next start — an upstream write or a
|
||||
settings key. Nothing clears it but process exit, and it is
|
||||
never persisted, so a false after a restart is the truth.
|
||||
|
||||
SettingsPatch:
|
||||
type: object
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ const auth = @import("handlers/auth.zig");
|
||||
const blocklists = @import("handlers/blocklists.zig");
|
||||
const certs = @import("handlers/certs.zig");
|
||||
const clients = @import("handlers/clients.zig");
|
||||
const config = @import("handlers/config.zig");
|
||||
const diagnostics = @import("handlers/diagnostics.zig");
|
||||
const groups = @import("handlers/groups.zig");
|
||||
const health = @import("handlers/health.zig");
|
||||
@@ -145,6 +146,9 @@ pub const table: []const router.RouteInfo = &.{
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
|
||||
|
||||
// Configuration status: the authority and whether a restart is pending.
|
||||
.{ .method = .GET, .pattern = "/api/config/status", .auth = .session, .policy = .read, .handler = config.get },
|
||||
|
||||
// Certificates (milestone-10 ruling 8).
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post },
|
||||
};
|
||||
@@ -157,7 +161,7 @@ const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
test "the table carries every endpoint of the milestone" {
|
||||
try testing.expectEqual(@as(usize, 63), table.len);
|
||||
try testing.expectEqual(@as(usize, 64), table.len);
|
||||
}
|
||||
|
||||
test "no two entries claim the same method and pattern" {
|
||||
|
||||
@@ -128,6 +128,12 @@ pub const WebState = struct {
|
||||
/// loaded the file at T" and nothing more: a file whose mtime is newer has
|
||||
/// not been loaded by the running process.
|
||||
reconciled_at: ?i64 = null,
|
||||
/// Whether a configuration change this process already committed waits for
|
||||
/// a restart to take effect: the upstream pool and the scalar settings are
|
||||
/// both built at startup. Per-process state, never persisted — nothing
|
||||
/// clears it but process exit, which is exactly what applies the change.
|
||||
/// Only database-mode mutations raise it; file mode never reaches them.
|
||||
restart_pending: std.atomic.Value(bool) = .init(false),
|
||||
|
||||
handler: ?*dns_handler.Handler = null,
|
||||
pause: ?*pause_mod.Pause = null,
|
||||
|
||||
@@ -65,6 +65,7 @@ const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
|
||||
|
||||
const handlers_blocklists = @import("handlers/blocklists.zig");
|
||||
const handlers_certs = @import("handlers/certs.zig");
|
||||
const handlers_config = @import("handlers/config.zig");
|
||||
const handlers_diagnostics = @import("handlers/diagnostics.zig");
|
||||
const handlers_health = @import("handlers/health.zig");
|
||||
const handlers_live = @import("handlers/live.zig");
|
||||
@@ -783,7 +784,6 @@ const SettingsView = struct {
|
||||
blocklist_update: struct { enabled: bool, interval_hours: u16 },
|
||||
},
|
||||
restart_required: []const []const u8,
|
||||
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
|
||||
};
|
||||
|
||||
const Contract = struct {
|
||||
@@ -909,6 +909,7 @@ const contract = [_]Contract{
|
||||
// Certificates. The walk's environment wires no cert store, so both
|
||||
// endpoints report disabled — and the reload still answers 200 (m10
|
||||
// ruling 8: the outcome is the payload).
|
||||
.{ .method = .GET, .pattern = "/api/config/status", .auth = .session, .policy = .read, .target = "/api/config/status", .status = 200, .check = jsonShape(handlers_config.View) },
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
|
||||
|
||||
// The walk's last delete returns the groups table to its seeded shape.
|
||||
@@ -1486,33 +1487,249 @@ test "W10 milestone 20: an unauthenticated configuration write is 401, never 403
|
||||
try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
|
||||
// ---------------------------------------------------------------------------
|
||||
// `GET /api/config/status`: the authority and the pending restart (milestone 32)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One probe of the status route. `extra_header` carries a session cookie once
|
||||
/// a test has turned authentication on.
|
||||
fn configStatus(
|
||||
gpa: Allocator,
|
||||
conn: *Conn,
|
||||
body_buf: []u8,
|
||||
extra_header: ?[]const u8,
|
||||
) !std.json.Parsed(handlers_config.View) {
|
||||
try conn.request("GET", "/api/config/status", extra_header, null);
|
||||
const response = try conn.receive(body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
return std.json.parseFromSlice(handlers_config.View, gpa, response.body, .{});
|
||||
}
|
||||
|
||||
fn restartPending(gpa: Allocator, conn: *Conn, body_buf: []u8, extra_header: ?[]const u8) !bool {
|
||||
const parsed = try configStatus(gpa, conn, body_buf, extra_header);
|
||||
defer parsed.deinit();
|
||||
return parsed.value.restart_pending;
|
||||
}
|
||||
|
||||
/// Logs in with `test_password` and renders the session as a `cookie:` header
|
||||
/// line. Needed by the two password patches below: setting a password revokes
|
||||
/// every session and turns authentication on, so the probe that follows one
|
||||
/// has to carry a fresh cookie.
|
||||
fn loginHeader(conn: *Conn, header_buf: []u8, body_buf: []u8) ![]const u8 {
|
||||
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
|
||||
const response = try conn.receive(body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
|
||||
const pair_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
|
||||
return std.fmt.bufPrint(header_buf, "cookie: {s}", .{set_cookie[0..pair_end]});
|
||||
}
|
||||
|
||||
/// One mutation that must move the flag, with whatever the fresh database owes
|
||||
/// it beforehand.
|
||||
const Setter = struct {
|
||||
method: []const u8,
|
||||
target: []const u8,
|
||||
body: ?[]const u8 = null,
|
||||
status: u16,
|
||||
/// Rows the scenario needs, inserted into the fresh config database before
|
||||
/// the server is probed.
|
||||
seed: [:0]const u8 = "",
|
||||
};
|
||||
|
||||
fn setterRaisesFlag(io: std.Io, env: *Env, setter: Setter) anyerror!void {
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// The false→true transition is the claim, so the "before" reading is part
|
||||
// of the proof: a probe taken while the flag is already true says nothing.
|
||||
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
|
||||
|
||||
try conn.request(setter.method, setter.target, null, setter.body);
|
||||
const response = try conn.receive(&body_buf);
|
||||
errdefer std.debug.print("{s} {s}: {d} {s}\n", .{ setter.method, setter.target, response.status, response.body });
|
||||
try testing.expectEqual(setter.status, response.status);
|
||||
|
||||
try testing.expect(try restartPending(env.gpa, &conn, &body_buf, null));
|
||||
}
|
||||
|
||||
test "W10 milestone 32: every restart-required mutation raises the flag, each from its own boot" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// A server per setter. The flag is process state nothing clears, so proving
|
||||
// a second setter raises it needs a process that has never raised it — and
|
||||
// the fresh boot is also the proof that a restart clears it.
|
||||
const setters = [_]Setter{
|
||||
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200 },
|
||||
.{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
|
||||
.{ .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
|
||||
.{
|
||||
.method = "DELETE",
|
||||
.target = "/api/upstreams/2",
|
||||
.status = 204,
|
||||
// The seeded row 1 stays enabled, so removing this one is not the
|
||||
// last-enabled-upstream conflict.
|
||||
.seed =
|
||||
\\INSERT INTO upstreams (id, url, priority, enabled, tls_name)
|
||||
\\VALUES (2, 'https://dns2.example/dns-query', 100, 1, '')
|
||||
,
|
||||
},
|
||||
};
|
||||
|
||||
for (setters) |setter| {
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
if (setter.seed.len != 0) try env.config_db.exec(setter.seed);
|
||||
try bounded(env.io(), default_budget, setterRaisesFlag, .{ env.io(), env, setter });
|
||||
}
|
||||
}
|
||||
|
||||
fn passwordOnlyPatch(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
|
||||
|
||||
try conn.request("PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"}}");
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
// The patch revoked every session and turned authentication on, so the
|
||||
// probe needs a cookie of its own.
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const cookie = try loginHeader(&conn, &header_buf, &body_buf);
|
||||
|
||||
// A password applies live (ruling 17 of milestone 16). Nothing is owed.
|
||||
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, cookie));
|
||||
}
|
||||
|
||||
test "W10 milestone 32: a password-only patch owes no restart" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, passwordOnlyPatch, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn mixedPatch(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
|
||||
|
||||
try conn.request(
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
null,
|
||||
"{\"web\":{\"password\":\"" ++ test_password ++ "\"},\"dns\":{\"port\":5353}}",
|
||||
);
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const cookie = try loginHeader(&conn, &header_buf, &body_buf);
|
||||
|
||||
// The password half applies live; the `dns.port` half does not, and one
|
||||
// restart-required key in the patch is enough.
|
||||
try testing.expect(try restartPending(env.gpa, &conn, &body_buf, cookie));
|
||||
}
|
||||
|
||||
test "W10 milestone 32: a patch carrying a password and a restart-required key raises the flag" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, mixedPatch, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn rejectedMutations(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// Nothing was committed, so nothing is owed. The SQL-layer half of this —
|
||||
// a write that begins its transaction and then fails — is pinned in
|
||||
// handlers/settings.zig, where the write-fault seam lives.
|
||||
const rejected = [_]Setter{
|
||||
.{ .method = "PUT", .target = "/api/settings", .body = "{\"logging\":{\"level\":\"chatty\"}}", .status = 400 },
|
||||
.{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"udp://1.1.1.1:53\"}", .status = 400 },
|
||||
.{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns.example/dns-query\"}", .status = 409 },
|
||||
.{ .method = "DELETE", .target = "/api/upstreams/1", .status = 409 },
|
||||
.{ .method = "DELETE", .target = "/api/upstreams/999", .status = 404 },
|
||||
};
|
||||
for (rejected) |attempt| {
|
||||
try conn.request(attempt.method, attempt.target, null, attempt.body);
|
||||
const response = try conn.receive(&body_buf);
|
||||
errdefer std.debug.print("{s} {s}: {d} {s}\n", .{ attempt.method, attempt.target, response.status, response.body });
|
||||
try testing.expectEqual(attempt.status, response.status);
|
||||
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
|
||||
}
|
||||
}
|
||||
|
||||
test "W10 milestone 32: a refused mutation leaves the flag alone" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, rejectedMutations, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn fileModeStatus(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16384]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/settings", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
{
|
||||
const parsed = try configStatus(env.gpa, &conn, &body_buf, null);
|
||||
defer parsed.deinit();
|
||||
try testing.expectEqualStrings("managed_file", parsed.value.authority);
|
||||
try testing.expectEqualStrings(managed_path, parsed.value.path.?);
|
||||
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.reconciled_at);
|
||||
try testing.expect(!parsed.value.restart_pending);
|
||||
}
|
||||
|
||||
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
|
||||
defer parsed.deinit();
|
||||
try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
|
||||
try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
|
||||
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);
|
||||
// The write the flag would follow never reaches its handler, so the flag
|
||||
// cannot rise in file mode at all.
|
||||
try conn.request("PUT", "/api/settings", null, "{\"dns\":{\"port\":5353}}");
|
||||
const refused = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 403), refused.status);
|
||||
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
|
||||
|
||||
// The path is a filesystem path and must not reach the open routes.
|
||||
for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
|
||||
try conn.request("GET", target, null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
|
||||
}
|
||||
|
||||
// And the settings envelope no longer carries a second copy of it.
|
||||
try conn.request("GET", "/api/settings", null, null);
|
||||
const settings_response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), settings_response.status);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, settings_response.body, 1, "authority"));
|
||||
|
||||
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, settings_response.body, .{});
|
||||
defer parsed.deinit();
|
||||
}
|
||||
|
||||
test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
|
||||
test "W10 milestone 32: file mode reports the file, owes no restart, and keeps the path off the open routes" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
@@ -1522,39 +1739,39 @@ test "W10 milestone 20: the settings envelope reports the authority and the open
|
||||
});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
|
||||
try bounded(env.io(), default_budget, fileModeStatus, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
|
||||
fn databaseModeStatus(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16384]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/settings", null, null);
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
|
||||
const parsed = try configStatus(env.gpa, &conn, &body_buf, null);
|
||||
defer parsed.deinit();
|
||||
try testing.expectEqualStrings("database", parsed.value.authority.mode);
|
||||
try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
|
||||
try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);
|
||||
try testing.expectEqualStrings("database", parsed.value.authority);
|
||||
try testing.expectEqual(@as(?[]const u8, null), parsed.value.path);
|
||||
try testing.expectEqual(@as(?i64, null), parsed.value.reconciled_at);
|
||||
try testing.expect(!parsed.value.restart_pending);
|
||||
|
||||
// And nothing is rejected.
|
||||
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
||||
const created = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 201), created.status);
|
||||
|
||||
// A live-resource write is not a restart: groups take effect at once.
|
||||
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
|
||||
}
|
||||
|
||||
test "W10 milestone 20: database authority reports null and writes normally" {
|
||||
test "W10 milestone 32: a fresh database-mode boot reports database, nulls and no pending restart" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
|
||||
try bounded(env.io(), default_budget, databaseModeStatus, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3564,6 +3781,14 @@ const contract_sample_walk = [_]ContractSample{
|
||||
.{ .name = "get_settings", .ts_type = "SettingsEnvelope", .method = "GET", .target = "/api/settings", .status = 200 },
|
||||
.{ .name = "put_settings", .ts_type = "SettingsEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200 },
|
||||
|
||||
// Config status after the settings PUT above, so the sample witnesses a
|
||||
// raised `restart_pending` rather than only the boot value.
|
||||
.{ .name = "get_config_status", .ts_type = "ConfigStatus", .method = "GET", .target = "/api/config/status", .status = 200 },
|
||||
|
||||
// Certificate reload. Neither TLS endpoint is wired in this environment, so
|
||||
// the sample carries the disabled outcome and nothing is read from disk.
|
||||
.{ .name = "reload_certs", .ts_type = "CertsReload", .method = "POST", .target = "/api/certs/reload", .status = 200 },
|
||||
|
||||
// One sample per shared error class this environment can produce. 401 and
|
||||
// 429 need their own environments and follow below.
|
||||
.{ .name = "error_bad_request", .ts_type = "ErrorEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"logging\":{\"level\":\"chatty\"}}", .status = 400 },
|
||||
|
||||
Reference in New Issue
Block a user