milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
CI / test (push) Successful in 1m46s
CI / test-aarch64 (push) Successful in 5m30s
CI / frontend (push) Successful in 46s
CI / cross (push) Successful in 8m12s
CI / docker (push) Successful in 3m46s

This commit is contained in:
2026-08-07 20:39:27 +02:00
parent 6f67940995
commit 6c507992e4
59 changed files with 1020 additions and 382 deletions
@@ -1,11 +1,12 @@
import { Suspense } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act } from "react";
import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage";
import RestartBanner from "@/features/settings/RestartBanner";
import { dismissRestartBanner } from "@/features/settings/restartBanner";
import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import type { Settings, SettingsPatch } from "@/lib/types";
function baseSettings(): Settings {
@@ -88,9 +89,10 @@ afterEach(() => {
vi.unstubAllGlobals();
});
async function renderPage() {
async function renderPage(): Promise<QueryClient> {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={createQueryClient()}>
<QueryClientProvider client={queryClient}>
<RestartBanner />
<Suspense fallback={<p>loading</p>}>
<SettingsPage />
@@ -98,6 +100,7 @@ async function renderPage() {
</QueryClientProvider>,
);
await screen.findByRole("heading", { name: "Settings" });
return queryClient;
}
function saveButton(): HTMLButtonElement {
@@ -232,3 +235,39 @@ test("patchRequiresRestart ignores only a bare web.password", () => {
expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true);
expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true);
});
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
const queryClient = await renderPage();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
// Someone else changes cache.size; a background refetch brings it in. The
// derived auth_enabled line is read straight from the query data, so it
// witnesses that the refetch reached the component.
storedSettings.cache.size = 99999;
storedSettings.web.auth_enabled = false;
await act(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
});
await waitFor(() => expect(screen.getByText(/auth_enabled: false/)).toBeTruthy());
const cache = screen.getByRole("group", { name: "Cache" });
expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
});
test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
await renderPage();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
await waitFor(() => expect(saveButton().disabled).toBe(true));
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5454" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(2));
expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
});
+60 -37
View File
@@ -10,22 +10,40 @@ 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 as Record<string, unknown>).some((key) => !(section === "web" && key === "password")),
Object.keys(fields ?? {}).some((key) => !(section === "web" && key === "password")),
);
}
interface FieldDef {
key: string;
interface FieldDef<S extends keyof Settings> {
key: keyof Settings[S] & string;
kind: "number" | "text" | "boolean" | readonly string[];
}
interface SectionDef {
section: keyof Settings;
interface SectionDef<S extends keyof Settings> {
section: S;
title: string;
fields: readonly FieldDef[];
fields: readonly FieldDef<S>[];
}
const TLS_FIELDS: readonly FieldDef[] = [
/** Binds each section's field keys to that section's Settings type at definition. */
function defineSection<S extends keyof Settings>(def: SectionDef<S>): SectionDef<S> {
return def;
}
/** The registry read back as a heterogeneous list, once the per-section binding has been proven. */
type AnyFieldDef = { [S in keyof Settings]: FieldDef<S> }[keyof Settings];
type AnySectionDef = { [S in keyof Settings]: SectionDef<S> }[keyof Settings];
/**
* A section's values as a string-keyed view. The keys are proven against
* `Settings[S]` where each section is defined; iterating the heterogeneous
* registry loses that correlation, so consumption widens here in one place.
*/
function sectionValues(settings: Settings, section: keyof Settings): Record<string, unknown> {
return settings[section] as Record<string, unknown>;
}
const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [
{ key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" },
{ key: "port", kind: "number" },
@@ -33,8 +51,8 @@ const TLS_FIELDS: readonly FieldDef[] = [
{ key: "key_path", kind: "text" },
];
const SECTIONS: readonly SectionDef[] = [
{
const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "upstream",
title: "Upstream",
fields: [
@@ -42,8 +60,8 @@ const SECTIONS: readonly SectionDef[] = [
{ key: "read_timeout_ms", kind: "number" },
{ key: "total_timeout_ms", kind: "number" },
],
},
{
}),
defineSection({
section: "dns",
title: "DNS",
fields: [
@@ -53,24 +71,24 @@ const SECTIONS: readonly SectionDef[] = [
{ 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: [
@@ -83,11 +101,11 @@ const SECTIONS: readonly SectionDef[] = [
{ key: "sse_max_connections_per_ip", kind: "number" },
{ key: "trusted_proxies", kind: "text" },
],
},
{ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS },
{ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS },
{ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] },
{
}),
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: [
@@ -101,23 +119,23 @@ const SECTIONS: readonly SectionDef[] = [
{ 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";
@@ -130,7 +148,7 @@ function FieldRow({
onChange,
}: {
section: string;
def: FieldDef;
def: AnyFieldDef;
value: unknown;
onChange: (value: unknown) => void;
}) {
@@ -209,23 +227,27 @@ export default function SettingsPage() {
const { data } = useSuspenseQuery(settingsQuery());
const queryClient = useQueryClient();
const mutation = useMutation(settingsPutMutation(queryClient));
// Frozen at mount and re-frozen on save: diffing against live query data would
// turn a background refetch's out-of-band changes into phantom user edits.
const [baseline, setBaseline] = useState<Settings>(() => structuredClone(data.settings));
const [edited, setEdited] = useState<Settings>(() => structuredClone(data.settings));
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
const hasInvalidNumber = SECTIONS.some(({ section, fields }) =>
fields.some(
(field) => field.kind === "number" && Number.isNaN((edited[section] as Record<string, unknown>)[field.key]),
),
);
const patch = buildSettingsPatch(data.settings, edited, password === "" ? undefined : password);
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]: { ...(prev[section] as Record<string, unknown>), [key]: value },
[section]: { ...sectionValues(prev, section), [key]: value },
}));
}
@@ -235,6 +257,7 @@ export default function SettingsPage() {
const restartNeeded = patchRequiresRestart(patch);
mutation.mutate(patch, {
onSuccess: (envelope) => {
setBaseline(structuredClone(envelope.settings));
setEdited(structuredClone(envelope.settings));
setPassword("");
setConfirm("");
@@ -255,12 +278,12 @@ export default function SettingsPage() {
<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>
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((def) => (
{(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow
key={def.key}
section={section}
def={def}
value={(edited[section] as Record<string, unknown>)[def.key]}
value={sectionValues(edited, section)[def.key]}
onChange={(value) => setField(section, def.key, value)}
/>
))}