milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
This commit is contained in:
@@ -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("");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user