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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import BlocklistForm, { swallowMutationError } from "./BlocklistForm";
|
||||
|
||||
test("swallowMutationError drops an ApiError and rethrows anything else", () => {
|
||||
expect(() => swallowMutationError(new ApiError(400, "bad url"))).not.toThrow();
|
||||
expect(() => swallowMutationError(new TypeError("cannot read x of undefined"))).toThrow(TypeError);
|
||||
expect(() => swallowMutationError("not an error at all")).toThrow();
|
||||
});
|
||||
|
||||
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} 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" } });
|
||||
fireEvent.change(name, { target: { value: "Example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
|
||||
|
||||
await waitFor(() => expect(rejecting).toHaveBeenCalledTimes(1));
|
||||
expect(url.value).toBe("https://example.com/list.txt");
|
||||
expect(name.value).toBe("Example");
|
||||
|
||||
const resolving = vi.fn(() => Promise.resolve());
|
||||
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("");
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
|
||||
const styles = stylex.create({
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1rem",
|
||||
maxWidth: "36rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
cancel: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drops the rejection the page already renders inline below the form. Anything
|
||||
* else is a bug in this component and must reach the console instead of dying
|
||||
* silently in the submit handler.
|
||||
*/
|
||||
export function swallowMutationError(error: unknown): void {
|
||||
if (error instanceof ApiError) return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
interface BlocklistFormProps {
|
||||
initial?: Blocklist;
|
||||
busy: boolean;
|
||||
error: Error | null;
|
||||
onSubmit: (input: BlocklistInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await onSubmit({ url: url.trim(), name: name.trim(), enabled });
|
||||
} catch (error) {
|
||||
swallowMutationError(error);
|
||||
return;
|
||||
}
|
||||
if (initial === undefined) {
|
||||
setUrl("");
|
||||
setName("");
|
||||
setEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
|
||||
<div>
|
||||
<label htmlFor="blocklist-url" {...stylex.props(styles.fieldLabel)}>
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
id="blocklist-url"
|
||||
type="url"
|
||||
required
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="blocklist-name" {...stylex.props(styles.fieldLabel)}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="blocklist-name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button type="submit" disabled={busy} {...stylex.props(shared.primaryButton, shared.focusRing)}>
|
||||
{initial === undefined ? "Add source" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
{...stylex.props(shared.button, styles.cancel, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<InlineError error={error} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||
import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
interface Props {
|
||||
groupId: number;
|
||||
blocklists: Blocklist[];
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
note: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
root: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
list: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
buttonRow: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const sources = useQuery(groupSourcesQuery(groupId));
|
||||
const mutation = useMutation(groupSourcesPutMutation(queryClient));
|
||||
const [selected, setSelected] = useState<number[] | null>(null);
|
||||
|
||||
if (sources.isPending) {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.note)}>
|
||||
Loading sources…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
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 Sources tab.</p>;
|
||||
}
|
||||
|
||||
const current = selected ?? sources.data;
|
||||
const dirty = !sameSet(current, sources.data);
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.root)}>
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{blocklists.map((blocklist) => (
|
||||
<li key={blocklist.id}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current.includes(blocklist.id)}
|
||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
{blocklist.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || mutation.isPending}
|
||||
onClick={() =>
|
||||
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
||||
}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Save sources
|
||||
</button>
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import {
|
||||
blocklistCreateMutation,
|
||||
blocklistDeleteMutation,
|
||||
blocklistUpdateMutation,
|
||||
blocklistsQuery,
|
||||
blocklistsUpdateNowMutation,
|
||||
} from "@/lib/queries";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
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({
|
||||
name: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
badge: {
|
||||
marginLeft: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: colors.border,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.text,
|
||||
},
|
||||
url: {
|
||||
display: "block",
|
||||
maxWidth: "18rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 [editing, setEditing] = useState<Blocklist | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
|
||||
|
||||
const create = useMutation(blocklistCreateMutation(queryClient));
|
||||
const save = useMutation(blocklistUpdateMutation(queryClient));
|
||||
const toggle = useMutation(blocklistUpdateMutation(queryClient));
|
||||
const remove = useMutation(blocklistDeleteMutation(queryClient));
|
||||
|
||||
async function submitForm(input: BlocklistInput) {
|
||||
if (editing === null) {
|
||||
await create.mutateAsync(input);
|
||||
} else {
|
||||
await save.mutateAsync({ id: editing.id, input: { ...input, is_suggested: editing.is_suggested } });
|
||||
setEditing(null);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEnabled(b: Blocklist) {
|
||||
toggle.mutate({
|
||||
id: b.id,
|
||||
input: { url: b.url, name: b.name, enabled: !b.enabled, is_suggested: b.is_suggested },
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (pendingDelete === null) return;
|
||||
remove.mutate(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
const formError = editing === null ? create.error : save.error;
|
||||
const tableError = remove.error ?? toggle.error;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{blocklists.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>No blocklist sources yet. Add one below.</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>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</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)}>
|
||||
<span {...stylex.props(styles.url)} title={b.url}>
|
||||
{b.url}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${b.name} enabled`}
|
||||
checked={b.enabled}
|
||||
disabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</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>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(b)}
|
||||
{...stylex.props(shared.linkButton, shared.focusRing)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(b)}
|
||||
disabled={remove.isPending}
|
||||
{...stylex.props(
|
||||
shared.dangerLinkButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p {...stylex.props(config.note)}>{SKIPPED_NOTE}</p>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={tableError} />
|
||||
|
||||
<BlocklistForm
|
||||
key={editing?.id ?? "add"}
|
||||
initial={editing ?? undefined}
|
||||
busy={editing === null ? create.isPending : save.isPending}
|
||||
error={formError}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete blocklist"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</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)}</>;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import {
|
||||
localRecordCreateMutation,
|
||||
localRecordDeleteMutation,
|
||||
localRecordUpdateMutation,
|
||||
localRecordsQuery,
|
||||
} from "@/lib/queries";
|
||||
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
|
||||
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";
|
||||
|
||||
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
|
||||
|
||||
const styles = stylex.create({
|
||||
formHeading: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
intro: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
headCell: {
|
||||
paddingBlock: "0.5rem",
|
||||
paddingRight: "1rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
headCellLast: {
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
bodyRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingBlock: "0.5rem",
|
||||
paddingRight: "1rem",
|
||||
},
|
||||
emptyCell: {
|
||||
paddingBlock: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
actionCell: {
|
||||
paddingBlock: "0.5rem",
|
||||
textAlign: "right",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
function RecordForm({
|
||||
initial,
|
||||
busy,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: LocalRecord;
|
||||
busy: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: LocalRecordInput) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [rtype, setRtype] = useState<LocalRecordType>(initial?.rtype ?? "A");
|
||||
const [value, setValue] = useState(initial?.value ?? "");
|
||||
const [ttl, setTtl] = useState(initial === undefined ? "" : String(initial.ttl));
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const input: LocalRecordInput = { name: name.trim(), rtype, value: value.trim() };
|
||||
if (ttl.trim() !== "") input.ttl = Number(ttl);
|
||||
onSubmit(input);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
|
||||
<h3 {...stylex.props(styles.formHeading)}>
|
||||
{initial === undefined ? "New record" : `Edit ${initial.name}`}
|
||||
</h3>
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-name`}
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="nas.lan.home"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
label="Type"
|
||||
value={rtype}
|
||||
onChange={(next) => setRtype(next as LocalRecordType)}
|
||||
options={RTYPE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-value`}
|
||||
required
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={
|
||||
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
|
||||
}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
|
||||
TTL (seconds)
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-ttl`}
|
||||
type="number"
|
||||
min={0}
|
||||
value={ttl}
|
||||
onChange={(event) => setTtl(event.target.value)}
|
||||
placeholder="300"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<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)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={error} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecordsTab() {
|
||||
const query = useQuery(localRecordsQuery());
|
||||
const {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
form,
|
||||
openForm,
|
||||
closeForm,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
pendingDelete,
|
||||
confirmPendingDelete,
|
||||
cancelPendingDelete,
|
||||
} = useCrudForm<LocalRecord, LocalRecordInput>({
|
||||
create: localRecordCreateMutation,
|
||||
update: localRecordUpdateMutation,
|
||||
remove: localRecordDeleteMutation,
|
||||
confirmDelete: (record) => `Delete record "${record.name}"?`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "create" })}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Add record
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
{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"
|
||||
message={pendingDelete?.message ?? ""}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmPendingDelete}
|
||||
onCancel={cancelPendingDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
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";
|
||||
|
||||
const DEFAULT_PRIORITY = "100";
|
||||
|
||||
interface UpstreamFormProps {
|
||||
initial?: Upstream;
|
||||
busy: boolean;
|
||||
error: Error | null;
|
||||
onSubmit: (input: UpstreamInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1rem",
|
||||
maxWidth: "36rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
hint: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
cancelButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
const [tlsName, setTlsName] = useState(initial?.tls_name ?? "");
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const parsed = Number(priority);
|
||||
try {
|
||||
// PUT replaces the row, so every field goes on every submit.
|
||||
await onSubmit({
|
||||
url: url.trim(),
|
||||
priority: Number.isFinite(parsed) ? parsed : 0,
|
||||
enabled,
|
||||
tls_name: tlsName.trim(),
|
||||
});
|
||||
if (initial === undefined) {
|
||||
setUrl("");
|
||||
setPriority(DEFAULT_PRIORITY);
|
||||
setEnabled(true);
|
||||
setTlsName("");
|
||||
}
|
||||
} catch {
|
||||
// The page renders the mutation error inline below the form.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
|
||||
<div>
|
||||
<label htmlFor="upstream-url" {...stylex.props(styles.fieldLabel)}>
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
id="upstream-url"
|
||||
type="text"
|
||||
required
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="udp://1.1.1.1:53"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="upstream-priority" {...stylex.props(styles.fieldLabel)}>
|
||||
Priority
|
||||
</label>
|
||||
<input
|
||||
id="upstream-priority"
|
||||
type="number"
|
||||
min={0}
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="upstream-tls-name" {...stylex.props(styles.fieldLabel)}>
|
||||
TLS name
|
||||
</label>
|
||||
<input
|
||||
id="upstream-tls-name"
|
||||
type="text"
|
||||
value={tlsName}
|
||||
onChange={(event) => setTlsName(event.target.value)}
|
||||
placeholder="one.one.one.one"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
<p {...stylex.props(styles.hint)}>
|
||||
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
|
||||
</p>
|
||||
</div>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button type="submit" disabled={busy} {...stylex.props(shared.primaryButton, shared.focusRing)}>
|
||||
{initial === undefined ? "Add upstream" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
{...stylex.props(shared.button, styles.cancelButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<InlineError error={error} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useState } from "react";
|
||||
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 ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
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({
|
||||
url: {
|
||||
display: "block",
|
||||
maxWidth: "18rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontWeight: 500,
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
absent: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
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 [editing, setEditing] = useState<Upstream | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
|
||||
|
||||
const create = useMutation(upstreamCreateMutation(queryClient));
|
||||
const save = useMutation(upstreamUpdateMutation(queryClient));
|
||||
const toggle = useMutation(upstreamUpdateMutation(queryClient));
|
||||
const remove = useMutation(upstreamDeleteMutation(queryClient));
|
||||
|
||||
async function submitForm(input: UpstreamInput) {
|
||||
if (editing === null) {
|
||||
await create.mutateAsync(input);
|
||||
} else {
|
||||
await save.mutateAsync({ id: editing.id, input });
|
||||
setEditing(null);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEnabled(u: Upstream) {
|
||||
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);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
const formError = editing === null ? create.error : save.error;
|
||||
const tableError = remove.error ?? toggle.error;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{upstreams.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>No upstreams yet. Add one below.</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>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{upstreams.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(styles.url)} title={u.url}>
|
||||
{u.url}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${u.url} enabled`}
|
||||
checked={u.enabled}
|
||||
disabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(u)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(u)}
|
||||
{...stylex.props(shared.linkButton, shared.focusRing)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(u)}
|
||||
disabled={remove.isPending}
|
||||
{...stylex.props(
|
||||
shared.dangerLinkButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={tableError} />
|
||||
|
||||
<UpstreamForm
|
||||
key={editing?.id ?? "add"}
|
||||
initial={editing ?? undefined}
|
||||
busy={editing === null ? create.isPending : save.isPending}
|
||||
error={formError}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete upstream"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: `Delete upstream "${pendingDelete.url}"? Queries stop being forwarded to it.`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import {
|
||||
forwardZoneCreateMutation,
|
||||
forwardZoneDeleteMutation,
|
||||
forwardZoneUpdateMutation,
|
||||
forwardZonesQuery,
|
||||
} from "@/lib/queries";
|
||||
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";
|
||||
|
||||
const styles = stylex.create({
|
||||
formHeading: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
intro: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
headCell: {
|
||||
paddingBlock: "0.5rem",
|
||||
paddingRight: "1rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
headCellLast: {
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
bodyRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingBlock: "0.5rem",
|
||||
paddingRight: "1rem",
|
||||
},
|
||||
emptyCell: {
|
||||
paddingBlock: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
actionCell: {
|
||||
paddingBlock: "0.5rem",
|
||||
textAlign: "right",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
function ZoneForm({
|
||||
initial,
|
||||
busy,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: ForwardZone;
|
||||
busy: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: ForwardZoneInput) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [zone, setZone] = useState(initial?.zone ?? "");
|
||||
const [resolver, setResolver] = useState(initial?.resolver ?? "");
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
onSubmit({ zone: zone.trim(), resolver: resolver.trim() });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
|
||||
<h3 {...stylex.props(styles.formHeading)}>
|
||||
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
|
||||
</h3>
|
||||
<div>
|
||||
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
|
||||
Zone
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-zone`}
|
||||
required
|
||||
value={zone}
|
||||
onChange={(event) => setZone(event.target.value)}
|
||||
placeholder="lan.home"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
|
||||
Resolver
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-resolver`}
|
||||
required
|
||||
value={resolver}
|
||||
onChange={(event) => setResolver(event.target.value)}
|
||||
placeholder="udp://192.168.1.1:53"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<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)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={error} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ZonesTab() {
|
||||
const query = useQuery(forwardZonesQuery());
|
||||
const {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
form,
|
||||
openForm,
|
||||
closeForm,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
pendingDelete,
|
||||
confirmPendingDelete,
|
||||
cancelPendingDelete,
|
||||
} = useCrudForm<ForwardZone, ForwardZoneInput>({
|
||||
create: forwardZoneCreateMutation,
|
||||
update: forwardZoneUpdateMutation,
|
||||
remove: forwardZoneDeleteMutation,
|
||||
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
Names under these zones go to their own resolver. Changes apply live.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "create" })}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Add zone
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
{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"
|
||||
message={pendingDelete?.message ?? ""}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmPendingDelete}
|
||||
onCancel={cancelPendingDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,22 @@
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
|
||||
test("toggleSource adds a missing id keeping ascending order", () => {
|
||||
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
|
||||
expect(toggleSource([], 5)).toEqual([5]);
|
||||
});
|
||||
|
||||
test("toggleSource removes a present id", () => {
|
||||
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
|
||||
expect(toggleSource([5], 5)).toEqual([]);
|
||||
});
|
||||
|
||||
test("toggleSource twice is a no-op set-wise", () => {
|
||||
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("sameSet compares regardless of order", () => {
|
||||
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
||||
expect(sameSet([], [])).toBe(true);
|
||||
expect(sameSet([1, 2], [1, 2, 3])).toBe(false);
|
||||
expect(sameSet([1, 2], [1, 4])).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export function toggleSource(ids: number[], id: number): number[] {
|
||||
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
|
||||
return [...ids, id].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function sameSet(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort((x, y) => x - y);
|
||||
const sortedB = [...b].sort((x, y) => x - y);
|
||||
return sortedA.every((value, i) => value === sortedB[i]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user