import { useState, type FormEvent } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import InlineError from "@/lib/InlineError"; import { settingsPutMutation, settingsQuery } from "@/lib/queries"; import { buildSettingsPatch } from "@/lib/settingsDiff"; import type { Settings, SettingsPatch } from "@/lib/types"; import { raiseRestartBanner } from "./restartBanner"; import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority"; import { focusRing } from "@/ui/classes"; /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ export function patchRequiresRestart(patch: SettingsPatch): boolean { return Object.entries(patch).some(([section, fields]) => Object.keys(fields ?? {}).some((key) => !(section === "web" && key === "password")), ); } interface FieldDef { key: keyof Settings[S] & string; kind: "number" | "text" | "boolean" | readonly string[]; } interface SectionDef { section: S; title: string; fields: readonly FieldDef[]; } /** Binds each section's field keys to that section's Settings type at definition. */ function defineSection(def: SectionDef): SectionDef { return def; } /** The registry read back as a heterogeneous list, once the per-section binding has been proven. */ type AnyFieldDef = { [S in keyof Settings]: FieldDef }[keyof Settings]; type AnySectionDef = { [S in keyof Settings]: SectionDef }[keyof Settings]; /** * A section's values as a string-keyed view. The keys are proven against * `Settings[S]` where each section is defined; iterating the heterogeneous * registry loses that correlation, so consumption widens here in one place. */ function sectionValues(settings: Settings, section: keyof Settings): Record { return settings[section] as Record; } const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [ { key: "enabled", kind: "boolean" }, { key: "bind", kind: "text" }, { key: "port", kind: "number" }, { key: "cert_path", kind: "text" }, { key: "key_path", kind: "text" }, ]; const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "upstream", title: "Upstream", fields: [ { key: "attempt_timeout_ms", kind: "number" }, { key: "read_timeout_ms", kind: "number" }, { key: "total_timeout_ms", kind: "number" }, ], }), defineSection({ section: "dns", title: "DNS", fields: [ { key: "bind_ipv4", kind: "text" }, { key: "bind_ipv6", kind: "text" }, { key: "port", kind: "number" }, { key: "rate_limit", kind: "number" }, { key: "rate_window_seconds", kind: "number" }, ], }), defineSection({ section: "blocking", title: "Blocking", fields: [ { key: "response", kind: ["zero", "nxdomain"] }, { key: "ttl", kind: "number" }, ], }), defineSection({ section: "cache", title: "Cache", fields: [ { key: "size", kind: "number" }, { key: "negative_ttl_max", kind: "number" }, ], }), defineSection({ section: "web", title: "Web", fields: [ { key: "enabled", kind: "boolean" }, { key: "bind", kind: "text" }, { key: "port", kind: "number" }, { key: "session_ttl_hours", kind: "number" }, { key: "api_rate_limit_per_min", kind: "number" }, { key: "api_localhost_exempt", kind: "boolean" }, { key: "sse_max_connections_per_ip", kind: "number" }, { key: "trusted_proxies", kind: "text" }, ], }), defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }), defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }), defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }), defineSection({ section: "logging", title: "Logging", fields: [ { key: "level", kind: ["error", "warn", "info", "debug"] }, { key: "retention_days", kind: "number" }, { key: "query_log_buffer_max", kind: "number" }, { key: "hide_domains", kind: "boolean" }, { key: "hide_client_ips", kind: "boolean" }, { key: "output", kind: ["stderr", "syslog", "file"] }, { key: "file_path", kind: "text" }, { key: "max_size_mb", kind: "number" }, { key: "max_files", kind: "number" }, ], }), defineSection({ section: "disk", title: "Disk", fields: [ { key: "min_free_mb", kind: "number" }, { key: "warn_free_mb", kind: "number" }, ], }), defineSection({ section: "blocklist_update", title: "Blocklist Update", fields: [ { key: "enabled", kind: "boolean" }, { key: "interval_hours", kind: "number" }, ], }), ]; const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300"; const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`; function FieldRow({ section, def, value, onChange, }: { section: string; def: AnyFieldDef; value: unknown; onChange: (value: unknown) => void; }) { const id = `${section}.${def.key}`; if (def.kind === "boolean") { return (
onChange(e.target.checked)} className={focusRing} />
); } if (Array.isArray(def.kind)) { return (
); } if (def.kind === "number") { const numeric = value as number; return (
onChange(e.target.valueAsNumber)} className={fieldInputClass} />
); } return (
onChange(e.target.value)} className={fieldInputClass} />
); } export default function SettingsPage() { const { data } = useSuspenseQuery(settingsQuery()); const queryClient = useQueryClient(); const mutation = useMutation(settingsPutMutation(queryClient)); // Frozen at mount and re-frozen on save: diffing against live query data would // turn a background refetch's out-of-band changes into phantom user edits. const [baseline, setBaseline] = useState(() => structuredClone(data.settings)); const [edited, setEdited] = useState(() => structuredClone(data.settings)); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm; const hasInvalidNumber = SECTIONS.some(({ section, fields }) => { const values = sectionValues(edited, section); return (fields as readonly AnyFieldDef[]).some( (field) => field.kind === "number" && Number.isNaN(values[field.key]), ); }); const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password); const readOnly = useReadOnlyConfig(); const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly; function setField(section: keyof Settings, key: string, value: unknown): void { setEdited((prev) => ({ ...prev, [section]: { ...sectionValues(prev, section), [key]: value }, })); } function handleSubmit(event: FormEvent): void { event.preventDefault(); if (patch === null || passwordsMismatch || hasInvalidNumber) return; const restartNeeded = patchRequiresRestart(patch); mutation.mutate(patch, { onSuccess: (envelope) => { setBaseline(structuredClone(envelope.settings)); setEdited(structuredClone(envelope.settings)); setPassword(""); setConfirm(""); if (restartNeeded) raiseRestartBanner(); }, }); } return (

Settings

Changes are validated as a whole; every setting requires a restart to take effect.

{SECTIONS.map(({ section, title, fields }) => (
{title}
{(fields as readonly AnyFieldDef[]).map((def) => ( setField(section, def.key, value)} /> ))} {section === "web" && ( <>

auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "} (derived, read-only)

setPassword(e.target.value)} className={fieldInputClass} />
setConfirm(e.target.value)} className={fieldInputClass} />
{password !== "" && (

Changing the password signs out every session; you will be asked to log in again.

)} {passwordsMismatch && (

Passwords do not match.

)} )}
))}
{mutation.isError && }
); }