milestone 20: declarative configuration for iac
Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 348e955b8f
commit a8e0fe4617
74 changed files with 6722 additions and 1949 deletions
+1
View File
@@ -1,3 +1,4 @@
dist/
dist-placeholder/
package-lock.json
dist-sourcemap/
@@ -10,7 +10,9 @@ test("swallowMutationError drops an ApiError and rethrows anything else", () =>
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
const { rerender } = render(
<BlocklistForm busy={false} readOnly={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" } });
@@ -22,7 +24,7 @@ test("a rejected submit leaves the typed values in place; a resolved one clears
expect(name.value).toBe("Example");
const resolving = vi.fn(() => Promise.resolve());
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
rerender(<BlocklistForm busy={false} readOnly={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("");
+10 -2
View File
@@ -3,6 +3,7 @@ import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT } from "@/features/settings/authority";
/**
* Drops the rejection the page already renders inline below the form. Anything
@@ -17,12 +18,14 @@ export function swallowMutationError(error: unknown): void {
interface BlocklistFormProps {
initial?: Blocklist;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: BlocklistInput) => Promise<void>;
onCancel?: () => void;
}
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -81,7 +84,12 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
Enabled
</label>
<div className="flex items-center gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
+12 -3
View File
@@ -22,6 +22,7 @@ import {
tdClass,
thClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function BlocklistsPage() {
const queryClient = useQueryClient();
@@ -36,6 +37,9 @@ export default function BlocklistsPage() {
const sources = useRefreshStatus();
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
// The refresh below re-fetches the sources the config already declares, so
// it stays live in file mode; every other control here writes config.
const readOnly = useReadOnlyConfig();
async function submitForm(input: BlocklistInput) {
if (editing === null) {
@@ -122,7 +126,8 @@ export default function BlocklistsPage() {
type="checkbox"
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
className={focusRing}
/>
@@ -138,14 +143,17 @@ export default function BlocklistsPage() {
<button
type="button"
onClick={() => setEditing(b)}
className={linkButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => deleteBlocklist(b)}
disabled={remove.isPending}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
>
Delete
@@ -164,6 +172,7 @@ export default function BlocklistsPage() {
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
@@ -4,6 +4,7 @@ import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
client: Client;
@@ -18,6 +19,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const mutation = useMutation(clientUpdateMutation(queryClient));
const [name, setName] = useState(client.name);
const [groupId, setGroupId] = useState(client.group_id);
const readOnly = useReadOnlyConfig();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
@@ -67,7 +69,12 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
<button type="button" onClick={onClose} className={buttonClass}>
Cancel
</button>
<button type="submit" disabled={mutation.isPending} className={primaryButtonClass}>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Save
</button>
</div>
+19 -2
View File
@@ -7,9 +7,17 @@ import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const cellClass = "px-3 py-2";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
* the file and is the one client DELETE the server answers 403 (ruling 7).
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
@@ -18,6 +26,7 @@ export default function ClientsPage() {
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const readOnly = useReadOnlyConfig();
return (
<section>
@@ -86,14 +95,22 @@ export default function ClientsPage() {
<button
type="button"
onClick={() => setEditing(client)}
className={smallButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${smallButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => setConfirmingId(client.id)}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
disabled={readOnly && client.hand_edited}
title={
readOnly && client.hand_edited
? DECLARED_CLIENT_NOTE
: undefined
}
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
+4 -1
View File
@@ -6,6 +6,7 @@ import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
prefixes: ClientPrefix[];
@@ -19,6 +20,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const save = () => {
const problem = firstProblem(state.rows);
@@ -105,7 +107,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<button
type="button"
onClick={save}
disabled={!dirty || mutation.isPending}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Save prefixes
@@ -5,6 +5,7 @@ import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
groupId: number;
@@ -16,6 +17,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const sources = useQuery(groupSourcesQuery(groupId));
const mutation = useMutation(groupSourcesPutMutation(queryClient));
const [selected, setSelected] = useState<number[] | null>(null);
const readOnly = useReadOnlyConfig();
if (sources.isPending) {
return (
@@ -58,7 +60,8 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
<div className="mt-3 flex gap-2">
<button
type="button"
disabled={!dirty || mutation.isPending}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
}
+23 -7
View File
@@ -12,6 +12,7 @@ import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
@@ -23,6 +24,7 @@ export default function GroupsPage() {
const queryClient = useQueryClient();
const createMutation = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
const readOnly = useReadOnlyConfig();
return (
<section>
@@ -44,9 +46,15 @@ export default function GroupsPage() {
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
disabled={readOnly}
className={smallInputClass}
/>
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
<button
type="submit"
disabled={createMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Create
</button>
</form>
@@ -69,6 +77,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
const [confirming, setConfirming] = useState(false);
const [expanded, setExpanded] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
const readOnly = useReadOnlyConfig();
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return (
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
@@ -94,7 +104,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
className={smallInputClass}
autoFocus
/>
<button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
<button
type="submit"
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={groupButtonClass}
>
Save
</button>
<button
@@ -115,7 +130,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
<input
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending}
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={focusRing}
onChange={(event) =>
updateMutation.mutate({
@@ -138,8 +154,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
{!renaming && (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => {
setName(group.name);
setRenaming(true);
@@ -168,8 +184,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
) : (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => setConfirming(true)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
>
+31 -6
View File
@@ -17,18 +17,21 @@ import {
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
function RecordForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: LocalRecord;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: LocalRecordInput) => void;
onCancel: () => void;
@@ -109,7 +112,12 @@ function RecordForm({
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
@@ -132,18 +140,31 @@ export default function RecordsTab() {
remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
>
Add record
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
<RecordForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
@@ -181,6 +202,7 @@ export default function RecordsTab() {
<RecordForm
initial={record}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
@@ -196,15 +218,18 @@ export default function RecordsTab() {
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
className={rowButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
+31 -6
View File
@@ -17,16 +17,19 @@ import {
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
function ZoneForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: ForwardZone;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void;
@@ -70,7 +73,12 @@ function ZoneForm({
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
@@ -93,6 +101,7 @@ export default function ZonesTab() {
remove: forwardZoneDeleteMutation,
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
@@ -100,13 +109,25 @@ export default function ZonesTab() {
<p className="text-sm text-zinc-500">
Names under these zones go to their own resolver. Changes apply live.
</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
>
Add zone
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
<ZoneForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
@@ -138,6 +159,7 @@ export default function ZonesTab() {
<ZoneForm
initial={zone}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
@@ -151,15 +173,18 @@ export default function ZonesTab() {
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
className={rowButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
+10 -2
View File
@@ -6,6 +6,7 @@ import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function RulesPage() {
const queryClient = useQueryClient();
@@ -19,6 +20,7 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -77,7 +79,8 @@ export default function RulesPage() {
<button
type="button"
onClick={() => deleteRule(rule)}
disabled={remove.isPending}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
>
Delete
@@ -154,7 +157,12 @@ export default function RulesPage() {
</select>
</div>
</div>
<button type="submit" disabled={create.isPending} className={primaryButtonClass}>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<InlineError error={create.error} />
@@ -0,0 +1,19 @@
import { useAuthority } from "./authority";
/**
* File authority is a standing condition, not an event, so this banner has no
* dismiss button: it stays up for as long as the process runs from a file.
*/
export default function ReadOnlyConfigBanner() {
const authority = useAuthority();
if (authority?.mode !== "managed_file") return null;
return (
<div
role="status"
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
>
Configuration is managed by <code className="font-mono">{authority.path}</code>. Edit the file and restart
nxdns to change it; the server rejects edits made here.
</div>
);
}
+5 -2
View File
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner";
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
import { focusRing } from "@/ui/classes";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
@@ -242,7 +243,8 @@ export default function SettingsPage() {
);
});
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
const readOnly = useReadOnlyConfig();
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly;
function setField(section: keyof Settings, key: string, value: unknown): void {
setEdited((prev) => ({
@@ -273,7 +275,7 @@ export default function SettingsPage() {
Changes are validated as a whole; every setting requires a restart to take effect.
</p>
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl">
<fieldset disabled={mutation.isPending} className="space-y-6">
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6">
{SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
<legend className="px-1 text-sm font-semibold">{title}</legend>
@@ -339,6 +341,7 @@ export default function SettingsPage() {
<button
type="submit"
disabled={saveDisabled}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
>
{mutation.isPending ? "Saving…" : "Save"}
@@ -0,0 +1,254 @@
import { render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { Authority, Settings, SettingsEnvelope } from "@/lib/types";
// One file for the whole file-mode sweep: the settings envelope is the only
// discovery mechanism, so every page test needs the same stubbed envelope.
const CONFIG_PATH = "/etc/nxdns/config.zon";
const DATABASE: Authority = { mode: "database", path: null, reconciled_at: null };
const MANAGED_FILE: Authority = { mode: "managed_file", path: CONFIG_PATH, reconciled_at: 1754899200 };
function baseSettings(): Settings {
return {
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 },
web: {
enabled: true,
bind: "127.0.0.1",
port: 8080,
session_ttl_hours: 24,
api_rate_limit_per_min: 60,
api_localhost_exempt: true,
sse_max_connections_per_ip: 2,
trusted_proxies: "",
auth_enabled: true,
},
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
edns: { ecs_mode: "strip" },
logging: {
level: "info",
retention_days: 30,
query_log_buffer_max: 10000,
hide_domains: false,
hide_client_ips: false,
output: "stderr",
file_path: "",
max_size_mb: 50,
max_files: 3,
},
disk: { min_free_mb: 100, warn_free_mb: 500 },
blocklist_update: { enabled: true, interval_hours: 24 },
};
}
function envelope(authority: Authority): SettingsEnvelope {
return { settings: baseSettings(), restart_required: [], authority };
}
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const BLOCKLISTS = {
blocklists: [
{
id: 1,
url: "https://example.com/ads.txt",
name: "Ads",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 100,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
],
};
const RULES = {
rules: [
{
id: 1,
group_id: 1,
group: "default",
pattern: "ads.example.com",
kind: "exact",
action: "block",
created_at: 1700000000,
},
],
};
const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
const BASE: Record<string, unknown> = {
"GET /api/version": VERSION,
"GET /api/groups": GROUPS,
"GET /api/blocklists": BLOCKLISTS,
"GET /api/rules": RULES,
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
};
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
}
async function renderAt(route: string, heading: string, authority: Authority) {
stubFetch({ ...BASE, "GET /api/settings": envelope(authority) });
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: heading });
}
function button(name: string): HTMLButtonElement {
return screen.getByRole("button", { name }) as HTMLButtonElement;
}
function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr");
if (row === null) throw new Error(`no client row for ${ip}`);
return row;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("the banner names the managed file in file mode", async () => {
await renderAt("/rules", "Rules", MANAGED_FILE);
const banner = await screen.findByText(/configuration is managed by/i);
expect(banner.textContent).toContain(CONFIG_PATH);
expect(banner.textContent).toMatch(/restart/i);
expect(banner.closest('[role="status"]')).toBeTruthy();
});
test("the banner is absent in database mode", async () => {
await renderAt("/rules", "Rules", DATABASE);
await screen.findByRole("button", { name: "Create rule" });
expect(screen.queryByText(/configuration is managed by/i)).toBeNull();
});
function kidsRow(): HTMLElement {
const row = screen.getByText("kids").closest("li");
if (row === null) throw new Error("no row for group kids");
return row;
}
test("file mode disables the Groups create and delete controls", async () => {
await renderAt("/groups", "Groups", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Create").disabled).toBe(true);
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(true);
});
test("database mode leaves the Groups create and delete controls enabled", async () => {
await renderAt("/groups", "Groups", DATABASE);
await screen.findByRole("button", { name: "Create" });
expect(button("Create").disabled).toBe(false);
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(false);
});
test("file mode disables the Rules create and delete controls", async () => {
await renderAt("/rules", "Rules", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Create rule").disabled).toBe(true);
expect(button("Delete").disabled).toBe(true);
});
test("database mode leaves the Rules create and delete controls enabled", async () => {
await renderAt("/rules", "Rules", DATABASE);
await screen.findByRole("button", { name: "Create rule" });
expect(button("Create rule").disabled).toBe(false);
expect(button("Delete").disabled).toBe(false);
});
test("file mode keeps delete live for an observed client and blocks it for a declared one", async () => {
await renderAt("/clients", "Clients", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
const declared = clientRow("192.168.1.10");
const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(observed).getByRole("button", { name: "Edit" }) as HTMLButtonElement).disabled).toBe(true);
});
test("file mode leaves the blocklist refresh button enabled", async () => {
await renderAt("/blocklists", "Blocklists", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Update now").disabled).toBe(false);
expect(button("Add source").disabled).toBe(true);
expect((screen.getByRole("checkbox", { name: "Ads enabled" }) as HTMLInputElement).disabled).toBe(true);
});
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { settingsQuery } from "@/lib/queries";
import type { Authority } from "@/lib/types";
/** The one-line explanation on every control file authority takes away. */
export const READ_ONLY_HINT = "Configuration is managed by a file; edit the file and restart nxdns.";
/**
* The running server's configuration authority, read from the settings
* envelope — the only route that carries it. `undefined` until that query
* resolves. Every page may call this: it is the shared `["settings"]` key, so
* the shell's own subscription serves them all from cache.
*/
export function useAuthority(): Authority | undefined {
return useQuery(settingsQuery()).data?.authority;
}
/**
* True only once the server has said a file owns the configuration. While the
* mode is unknown nothing is disabled — the 403 is the enforcement, this is
* the courtesy.
*/
export function useReadOnlyConfig(): boolean {
return useAuthority()?.mode === "managed_file";
}
+10 -2
View File
@@ -2,18 +2,21 @@ import { useState, type FormEvent } from "react";
import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100";
interface UpstreamFormProps {
initial?: Upstream;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: UpstreamInput) => Promise<void>;
onCancel?: () => void;
}
export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel }: UpstreamFormProps) {
export default function UpstreamForm({ initial, busy, readOnly, 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);
@@ -97,7 +100,12 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
Enabled
</label>
<div className="flex items-center gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
+10 -3
View File
@@ -6,6 +6,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function UpstreamsPage() {
const queryClient = useQueryClient();
@@ -16,6 +17,7 @@ export default function UpstreamsPage() {
const save = useMutation(upstreamUpdateMutation(queryClient));
const toggle = useMutation(upstreamUpdateMutation(queryClient));
const remove = useMutation(upstreamDeleteMutation(queryClient));
const readOnly = useReadOnlyConfig();
async function submitForm(input: UpstreamInput) {
if (editing === null) {
@@ -84,7 +86,8 @@ export default function UpstreamsPage() {
type="checkbox"
aria-label={`${u.url} enabled`}
checked={u.enabled}
disabled={toggle.isPending}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(u)}
className={focusRing}
/>
@@ -95,14 +98,17 @@ export default function UpstreamsPage() {
<button
type="button"
onClick={() => setEditing(u)}
className={linkButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => deleteUpstream(u)}
disabled={remove.isPending}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
>
Delete
@@ -121,6 +127,7 @@ export default function UpstreamsPage() {
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
+10
View File
@@ -445,6 +445,11 @@ export const sample_post_pause: PauseState = {
};
export const sample_get_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
@@ -563,6 +568,11 @@ export const sample_get_settings: SettingsEnvelope = {
};
export const sample_put_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
+14
View File
@@ -380,9 +380,23 @@ export interface Settings {
};
}
/**
* Which configuration source the running process obeys. `path` and
* `reconciled_at` are non-null only under `managed_file`: the file the process
* loaded, and the epoch second at which it loaded it. Authority lives in the
* invocation, never in the database, so this is the only place the UI can read
* it — and it rides an authenticated route, never the open ones.
*/
export interface Authority {
mode: "database" | "managed_file";
path: string | null;
reconciled_at: number | null;
}
export interface SettingsEnvelope {
settings: Settings;
restart_required: string[];
authority: Authority;
}
export interface TlsListenerPatch {
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget";
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
import RestartBanner from "../features/settings/RestartBanner";
import { buttonClass, focusRing } from "@/ui/classes";
@@ -112,6 +113,7 @@ export default function AppShell() {
</div>
</header>
<RestartBanner />
<ReadOnlyConfigBanner />
{drawerOpen && (
<div id="mobile-nav" className="border-b border-zinc-200 md:hidden dark:border-zinc-800">
<nav aria-label="Main" className="px-2 py-2">