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