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
@@ -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("");
});
+19 -7
View File
@@ -1,8 +1,19 @@
import { useState, type FormEvent } from "react";
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";
/**
* 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;
@@ -20,13 +31,14 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
event.preventDefault();
try {
await onSubmit({ url: url.trim(), name: name.trim(), enabled });
if (initial === undefined) {
setUrl("");
setName("");
setEnabled(true);
}
} catch {
// The page renders the mutation error inline below the form.
} catch (error) {
swallowMutationError(error);
return;
}
if (initial === undefined) {
setUrl("");
setName("");
setEnabled(true);
}
}
@@ -1,9 +1,10 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { clearRefreshStatus } from "@/features/blocklists/refreshStore";
const BLOCKLISTS = {
blocklists: [
@@ -42,6 +43,7 @@ const RESPONSES: Record<string, unknown> = {
let resolveUpdate: ((response: Response) => void) | null;
beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null;
vi.stubGlobal(
"fetch",
@@ -66,18 +68,35 @@ afterEach(() => {
vi.unstubAllGlobals();
});
function renderBlocklistsRoute() {
const queryClient = createQueryClient();
function renderBlocklistsRoute(queryClient = createQueryClient()) {
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
render(
const view = render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { queryClient, unmount: view.unmount };
}
const SNAPSHOT = {
sources: [
{
id: 1,
state: "loaded",
loaded: true,
last_attempt: 1700000100,
last_success: 1700000100,
url: "https://example.com/hosts.txt",
last_error: "",
domains: 1200,
wildcards: 12,
skipped_regex: 4,
},
],
};
test("renders the source table and the status empty state", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
@@ -149,7 +168,8 @@ test("update now disables the button, then replaces the status section from the
expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy();
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
expect(screen.getByText(/Update completed/)).toBeTruthy();
// The store notifies one flush before the mutation's success state lands.
await screen.findByText(/Update completed/);
await waitFor(() => {
const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
@@ -175,3 +195,35 @@ test("update now shows a countdown when rate limited with Retry-After", async ()
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
});
test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { queryClient, unmount } = renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await screen.findByRole("button", { name: "Updating…" });
resolveUpdate!(
new Response(JSON.stringify(SNAPSHOT), {
status: 202,
headers: { "content-type": "application/json" },
}),
);
await screen.findByText("loaded");
unmount();
// Well past the default 5-minute gcTime: an unsubscribed cache entry is
// collected by now, which is what used to erase the snapshot.
await act(async () => {
await vi.advanceTimersByTimeAsync(6 * 60_000);
});
renderBlocklistsRoute(queryClient);
await screen.findByRole("heading", { name: "Blocklists" });
expect(await screen.findByText("loaded")).toBeTruthy();
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
} finally {
vi.useRealTimers();
}
});
@@ -8,10 +8,10 @@ import {
blocklistUpdateMutation,
blocklistsQuery,
blocklistsUpdateNowMutation,
queryKeys,
} from "@/lib/queries";
import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection";
import {
dangerLinkButtonClass,
@@ -34,9 +34,7 @@ export default function BlocklistsPage() {
const remove = useMutation(blocklistDeleteMutation(queryClient));
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
// Fed only by the update-now 202 snapshot (no GET exists); the mutation's
// state change re-renders this page right after setQueryData runs.
const sources = queryClient.getQueryData<SourceStatus[]>(queryKeys.blocklistSources);
const sources = useRefreshStatus();
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
async function submitForm(input: BlocklistInput) {
@@ -7,7 +7,7 @@ function formatAttempt(unixSeconds: number): string {
}
interface SourceStatusSectionProps {
sources: SourceStatus[] | undefined;
sources: SourceStatus[] | null;
namesById: ReadonlyMap<number, string>;
}
@@ -15,7 +15,7 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
return (
<section className="mt-8">
<h2 className="text-lg font-medium">Source status</h2>
{sources === undefined ? (
{sources === null ? (
<p className="mt-2 text-zinc-500">
No status snapshot yet run Update now to fetch status for every enabled source.
</p>
@@ -0,0 +1,31 @@
import { useSyncExternalStore } from "react";
import type { SourceStatus } from "@/lib/types";
// Client UI state, not server state: the snapshot exists only as the 202 body of
// POST /api/blocklists/update and no GET can refetch it. Held here so it outlives
// the query cache's gcTime instead of vanishing from an unsubscribed cache entry.
let snapshot: SourceStatus[] | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): SourceStatus[] | null {
return snapshot;
}
export function setRefreshStatus(sources: SourceStatus[]): void {
snapshot = sources;
for (const listener of listeners) listener();
}
export function clearRefreshStatus(): void {
snapshot = null;
for (const listener of listeners) listener();
}
export function useRefreshStatus(): SourceStatus[] | null {
return useSyncExternalStore(subscribe, getSnapshot);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
@@ -17,7 +18,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state);
const defaultGroupId = groups.find((group) => group.id === 1)?.id ?? groups[0]?.id ?? 1;
const fallbackGroupId = defaultGroupId(groups);
const save = () => {
const problem = firstProblem(state.rows);
@@ -96,7 +97,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => dispatch({ type: "add", groupId: defaultGroupId })}
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
className={buttonClass}
>
Add prefix
@@ -88,11 +88,21 @@ const RESPONSES: Record<string, unknown> = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
// Endpoints forced to fail with a 4xx, which the query client does not retry.
let failing: Set<string>;
beforeEach(() => {
failing = new Set();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (failing.has(url)) {
return new Response(JSON.stringify({ error: "upstream health unavailable" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
@@ -161,3 +171,19 @@ test("period picker refetches stats and shows the empty chart state", async () =
await screen.findByText("No queries in this period.");
expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
});
test("one failing endpoint degrades its own widget on cold navigation", async () => {
failing.add("/api/upstream/health");
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
// The page renders; only the upstream widget carries the error.
await screen.findByText("upstream health unavailable");
expect(screen.queryByText("Something went wrong")).toBeNull();
expect(screen.queryByText("Request failed (400)")).toBeNull();
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Disk")).toBeTruthy();
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
});
+1 -1
View File
@@ -11,8 +11,8 @@ import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
const DEFAULT_GROUP_ID = 1;
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
+3 -2
View File
@@ -3,6 +3,7 @@ import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
interface Submitted {
@@ -128,10 +129,10 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
export default function LookupPage() {
const groups = useSuspenseQuery(groupsQuery()).data;
const defaultGroupId = groups.find((group) => group.id === 1)?.id ?? groups[0]?.id ?? 1;
const preselectedGroupId = defaultGroupId(groups);
const [domain, setDomain] = useState("");
const [groupId, setGroupId] = useState(defaultGroupId);
const [groupId, setGroupId] = useState(preselectedGroupId);
const [submitted, setSubmitted] = useState<Submitted | null>(null);
const lookup = useQuery({
+32 -7
View File
@@ -28,16 +28,17 @@ const RESPONSES: Record<string, unknown> = {
},
],
},
"/api/groups": {
groups: [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
],
},
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
// The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[];
beforeEach(() => {
groups = [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -48,7 +49,7 @@ beforeEach(() => {
headers: { "content-type": "application/json", "Retry-After": "5" },
});
}
const payload = RESPONSES[url];
const payload = url === "/api/groups" ? { groups } : RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
@@ -106,3 +107,27 @@ test("rule create shows a countdown when rate limited with Retry-After", async (
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 5s.");
});
test("the group select preselects the id-1 default, not the alphabetically first group", async () => {
groups = [
{ id: 5, name: "Attic", safe_search: false },
{ id: 1, name: "Default", safe_search: false },
];
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]);
expect(groupSelect.value).toBe("1");
});
test("the group select falls back to the first group when the default is absent", async () => {
groups = [
{ id: 5, name: "Attic", safe_search: false },
{ id: 7, name: "Basement", safe_search: false },
];
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5");
});
+2 -1
View File
@@ -4,6 +4,7 @@ import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
export default function RulesPage() {
@@ -17,7 +18,7 @@ export default function RulesPage() {
const [pattern, setPattern] = useState("");
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(groups[0]?.id ?? 1);
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -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)}
/>
))}
+13
View File
@@ -0,0 +1,13 @@
import type { Group } from "@/lib/types";
/** The seeded group every client falls back to; the API forbids renaming or deleting it. */
export const DEFAULT_GROUP_ID = 1;
/**
* The group a form preselects. The list arrives ordered by name
* (groups_repo.zig), so `groups[0]` is the alphabetically first group, not the
* default — it is only the fallback for a list that lost the seeded group.
*/
export function defaultGroupId(groups: readonly Group[]): number {
return groups.find((group) => group.id === DEFAULT_GROUP_ID)?.id ?? groups[0]?.id ?? DEFAULT_GROUP_ID;
}
+13 -12
View File
@@ -1,5 +1,6 @@
import { infiniteQueryOptions, keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
import * as api from "@/lib/api";
import { setRefreshStatus } from "@/features/blocklists/refreshStore";
import type {
BlocklistInput,
ClientEdit,
@@ -24,11 +25,11 @@ export const queryKeys = {
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
upstreamHealth: ["upstream-health"] as const,
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
lookupAll: ["lookup"] as const,
groups: ["groups"] as const,
groupSources: (id: number) => ["groups", id, "sources"] as const,
blocklists: ["blocklists"] as const,
/** Fed only by POST /api/blocklists/update's 202 snapshot; no GET exists. */
blocklistSources: ["blocklists", "sources"] as const,
rules: ["rules"] as const,
localRecords: ["local-records"] as const,
forwardZones: ["forward-zones"] as const,
@@ -107,7 +108,7 @@ export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings,
function invalidateGroupWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.groups }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
qc.invalidateQueries({ queryKey: queryKeys.clients }),
qc.invalidateQueries({ queryKey: queryKeys.clientPrefixes }),
qc.invalidateQueries({ queryKey: queryKeys.rules }),
@@ -133,15 +134,15 @@ export const groupSourcesPutMutation = (qc: QueryClient) => ({
mutationFn: ({ id, sourceIds }: { id: number; sourceIds: number[] }) => api.putGroupSources(id, sourceIds),
onSuccess: (sourceIds: number[], { id }: { id: number; sourceIds: number[] }) => {
qc.setQueryData(queryKeys.groupSources(id), sourceIds);
return qc.invalidateQueries({ queryKey: ["lookup"] });
return qc.invalidateQueries({ queryKey: queryKeys.lookupAll });
},
});
function invalidateBlocklistWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: ["groups"] }),
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
qc.invalidateQueries({ queryKey: queryKeys.groups }),
]);
}
@@ -160,14 +161,14 @@ export const blocklistDeleteMutation = (qc: QueryClient) => ({
onSuccess: () => invalidateBlocklistWorld(qc),
});
/** Ruling 12: the 202 snapshot REPLACES the sources cache; counters refresh. */
/** Ruling 12: the 202 snapshot REPLACES the refresh store; counters refresh. */
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
mutationFn: () => api.updateBlocklistsNow(),
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
qc.setQueryData(queryKeys.blocklistSources, sources);
setRefreshStatus(sources);
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]);
},
});
@@ -175,7 +176,7 @@ export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
function invalidateRules(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.rules }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]);
}
@@ -192,7 +193,7 @@ export const ruleDeleteMutation = (qc: QueryClient) => ({
function invalidateLocalRecords(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.localRecords }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]);
}
@@ -214,7 +215,7 @@ export const localRecordDeleteMutation = (qc: QueryClient) => ({
function invalidateForwardZones(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.forwardZones }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]);
}
+4 -1
View File
@@ -92,8 +92,11 @@ const shellRoute = createRoute({
const dashboardRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/",
// allSettled, not all: DashboardPage reads these with useQuery so each widget
// can render its own error. A rejecting loader would replace the whole page
// with RouteError and take the three healthy widgets down with the failed one.
loader: ({ context }) =>
Promise.all([
Promise.allSettled([
context.queryClient.ensureQueryData(statsQuery("24h")),
context.queryClient.ensureQueryData(timeseriesQuery("24h")),
context.queryClient.ensureQueryData(healthQuery()),