milestone 9: react spa admin ui, frontend ci and embedded dist

This commit is contained in:
2026-08-02 13:04:09 +02:00
parent 5253c47303
commit 617cc966a2
82 changed files with 11833 additions and 17 deletions
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { ApiError } from "@/lib/api";
/** Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with countdown. */
export default function InlineError({ error }: { error: unknown }) {
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
const [remaining, setRemaining] = useState<number | null>(retryAfter);
useEffect(() => {
setRemaining(retryAfter);
if (retryAfter === null) return;
const timer = setInterval(() => setRemaining((s) => (s === null || s <= 1 ? 0 : s - 1)), 1000);
return () => clearInterval(timer);
}, [error, retryAfter]);
if (error === null || error === undefined) return null;
let message: string;
if (error instanceof ApiError) {
if (error.status === 429) {
message =
remaining !== null && remaining > 0
? `Rate limited. Try again in ${remaining}s.`
: "Rate limited. Try again.";
} else if (error.status === 503) {
message = "The server is starting or degraded. Try again shortly.";
} else {
message = error.message;
}
} else {
message = "Could not reach the server.";
}
return (
<p role="alert" className="mt-2 text-sm text-red-600 dark:text-red-400">
{message}
</p>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { ApiError, deleteGroup, getQueries, getStats, listGroups, login, putGroupSources } from "@/lib/api";
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("sends same-origin credentials and parses JSON", async () => {
fetchMock.mockResolvedValue(jsonResponse({ authenticated: true, auth_required: true }));
const response = await login({ password: "hunter2" });
expect(response.auth_required).toBe(true);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/auth/login");
expect(init?.credentials).toBe("same-origin");
expect(init?.method).toBe("POST");
expect(init?.body).toBe(JSON.stringify({ password: "hunter2" }));
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers["content-type"]).toBe("application/json");
});
test("throws ApiError with the {error} envelope message", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "duplicate group name" }, 409));
const failure = await listGroups().catch((e: unknown) => e);
expect(failure).toBeInstanceOf(ApiError);
expect((failure as ApiError).status).toBe(409);
expect((failure as ApiError).message).toBe("duplicate group name");
expect((failure as ApiError).retryAfter).toBeUndefined();
});
test("falls back to a status message on a non-JSON error body", async () => {
fetchMock.mockResolvedValue(new Response("<html>bad gateway</html>", { status: 502 }));
const failure = await listGroups().catch((e: unknown) => e);
expect(failure).toBeInstanceOf(ApiError);
expect((failure as ApiError).message).toBe("HTTP 502");
});
test("parses Retry-After on 429", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "17" }));
const failure = await getStats("1h").catch((e: unknown) => e);
expect(failure).toBeInstanceOf(ApiError);
expect((failure as ApiError).status).toBe(429);
expect((failure as ApiError).retryAfter).toBe(17);
});
test("ignores a malformed Retry-After header", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "soon" }));
const failure = await getStats().catch((e: unknown) => e);
expect((failure as ApiError).retryAfter).toBeUndefined();
});
test("resolves void on 204", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
await expect(deleteGroup(3)).resolves.toBeUndefined();
expect(fetchMock.mock.calls[0]![0]).toBe("/api/groups/3");
expect(fetchMock.mock.calls[0]![1]?.method).toBe("DELETE");
});
test("unwraps list envelopes", async () => {
fetchMock.mockResolvedValue(jsonResponse({ groups: [{ id: 1, name: "default", safe_search: false }] }));
const groups = await listGroups();
expect(groups).toEqual([{ id: 1, name: "default", safe_search: false }]);
});
test("serializes query filters, omitting undefined", async () => {
fetchMock.mockResolvedValue(jsonResponse({ queries: [], next_before: null }));
await getQueries({ limit: 50, blocked: true, domain: "ads.example", before: undefined });
expect(fetchMock.mock.calls[0]![0]).toBe("/api/queries?limit=50&blocked=true&domain=ads.example");
});
test("requests with no filters carry no query string", async () => {
fetchMock.mockResolvedValue(jsonResponse({ queries: [], next_before: null }));
await getQueries();
expect(fetchMock.mock.calls[0]![0]).toBe("/api/queries");
});
test("wraps and unwraps group sources", async () => {
fetchMock.mockResolvedValue(jsonResponse({ source_ids: [2, 5] }));
const stored = await putGroupSources(4, [5, 2]);
expect(stored).toEqual([2, 5]);
expect(fetchMock.mock.calls[0]![0]).toBe("/api/groups/4/sources");
expect(fetchMock.mock.calls[0]![1]?.body).toBe(JSON.stringify({ source_ids: [5, 2] }));
});
+235
View File
@@ -0,0 +1,235 @@
import type {
Blocklist,
BlocklistEcho,
BlocklistInput,
Client,
ClientEdit,
ClientPrefix,
ClientPrefixInput,
ForwardZone,
ForwardZoneInput,
Group,
GroupInput,
Health,
LocalRecord,
LocalRecordInput,
LoginRequest,
LoginResponse,
LogoutResponse,
LookupResult,
PausePost,
PauseState,
Period,
QueriesFilter,
QueriesPage,
Rule,
RuleEcho,
RuleInput,
SettingsEnvelope,
SettingsPatch,
SourceStatus,
StatsTimeseries,
StatsTotals,
Upstream,
UpstreamEcho,
UpstreamHealth,
UpstreamInput,
Version,
} from "@/lib/types";
export class ApiError extends Error {
readonly status: number;
readonly retryAfter?: number;
constructor(status: number, message: string, retryAfter?: number) {
super(message);
this.name = "ApiError";
this.status = status;
this.retryAfter = retryAfter;
}
}
async function toApiError(res: Response): Promise<ApiError> {
let message = `HTTP ${res.status}`;
try {
const body: unknown = await res.json();
if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string") {
message = body.error;
}
} catch {
// Non-JSON error body; keep the status fallback.
}
let retryAfter: number | undefined;
if (res.status === 429) {
const header = res.headers.get("Retry-After");
const seconds = header === null ? NaN : Number(header);
if (Number.isFinite(seconds) && seconds >= 0) retryAfter = seconds;
}
return new ApiError(res.status, message, retryAfter);
}
async function request<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
const body = init?.body;
const res = await fetch(path, {
method: init?.method ?? "GET",
credentials: "same-origin",
headers: body !== undefined ? { "content-type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw await toApiError(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
async function requestText(path: string): Promise<string> {
const res = await fetch(path, { credentials: "same-origin" });
if (!res.ok) throw await toApiError(res);
return res.text();
}
type QueryValue = string | number | boolean | undefined;
function qs(params: Record<string, QueryValue>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) search.set(key, String(value));
}
const encoded = search.toString();
return encoded === "" ? "" : `?${encoded}`;
}
// Monitoring + meta
export const getMetrics = (): Promise<string> => requestText("/metrics");
export const getHealth = (): Promise<Health> => request("/api/health");
export const getVersion = (): Promise<Version> => request("/api/version");
export const getOpenapiYaml = (): Promise<string> => requestText("/api/openapi.yaml");
// Auth
export const login = (body: LoginRequest): Promise<LoginResponse> =>
request("/api/auth/login", { method: "POST", body });
export const logout = (): Promise<LogoutResponse> => request("/api/auth/logout", { method: "POST", body: {} });
// Query log + stats
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
request(`/api/queries${qs({ ...filter })}`);
/** `EventSource` URL for the live stream; not a fetch route. */
export const liveQueriesUrl = "/api/queries/live";
export const getStats = (period?: Period): Promise<StatsTotals> => request(`/api/stats${qs({ period })}`);
export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
request(`/api/stats/timeseries${qs({ period })}`);
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
export const getUpstreamHealth = (): Promise<UpstreamHealth> => request("/api/upstream/health");
// Groups
export const listGroups = async (): Promise<Group[]> => (await request<{ groups: Group[] }>("/api/groups")).groups;
export const createGroup = (input: GroupInput): Promise<Group> =>
request("/api/groups", { method: "POST", body: input });
export const getGroup = (id: number): Promise<Group> => request(`/api/groups/${id}`);
export const updateGroup = (id: number, input: GroupInput): Promise<Group> =>
request(`/api/groups/${id}`, { method: "PUT", body: input });
export const deleteGroup = (id: number): Promise<void> => request(`/api/groups/${id}`, { method: "DELETE" });
export const getGroupSources = async (id: number): Promise<number[]> =>
(await request<{ source_ids: number[] }>(`/api/groups/${id}/sources`)).source_ids;
export const putGroupSources = async (id: number, sourceIds: number[]): Promise<number[]> =>
(
await request<{ source_ids: number[] }>(`/api/groups/${id}/sources`, {
method: "PUT",
body: { source_ids: sourceIds },
})
).source_ids;
// Blocklists
export const listBlocklists = async (): Promise<Blocklist[]> =>
(await request<{ blocklists: Blocklist[] }>("/api/blocklists")).blocklists;
export const createBlocklist = (input: BlocklistInput): Promise<BlocklistEcho> =>
request("/api/blocklists", { method: "POST", body: input });
export const updateBlocklistsNow = async (): Promise<SourceStatus[]> =>
(await request<{ sources: SourceStatus[] }>("/api/blocklists/update", { method: "POST", body: {} })).sources;
export const getBlocklist = (id: number): Promise<Blocklist> => request(`/api/blocklists/${id}`);
export const updateBlocklist = (id: number, input: BlocklistInput): Promise<BlocklistEcho> =>
request(`/api/blocklists/${id}`, { method: "PUT", body: input });
export const deleteBlocklist = (id: number): Promise<void> => request(`/api/blocklists/${id}`, { method: "DELETE" });
// Rules
export const listRules = async (): Promise<Rule[]> => (await request<{ rules: Rule[] }>("/api/rules")).rules;
export const createRule = (input: RuleInput): Promise<RuleEcho> =>
request("/api/rules", { method: "POST", body: input });
export const getRule = (id: number): Promise<Rule> => request(`/api/rules/${id}`);
export const updateRule = (id: number, input: RuleInput): Promise<RuleEcho> =>
request(`/api/rules/${id}`, { method: "PUT", body: input });
export const deleteRule = (id: number): Promise<void> => request(`/api/rules/${id}`, { method: "DELETE" });
// Local records
export const listLocalRecords = async (): Promise<LocalRecord[]> =>
(await request<{ local_records: LocalRecord[] }>("/api/local-records")).local_records;
export const createLocalRecord = (input: LocalRecordInput): Promise<LocalRecord> =>
request("/api/local-records", { method: "POST", body: input });
export const getLocalRecord = (id: number): Promise<LocalRecord> => request(`/api/local-records/${id}`);
export const updateLocalRecord = (id: number, input: LocalRecordInput): Promise<LocalRecord> =>
request(`/api/local-records/${id}`, { method: "PUT", body: input });
export const deleteLocalRecord = (id: number): Promise<void> =>
request(`/api/local-records/${id}`, { method: "DELETE" });
// Forward zones
export const listForwardZones = async (): Promise<ForwardZone[]> =>
(await request<{ forward_zones: ForwardZone[] }>("/api/forward-zones")).forward_zones;
export const createForwardZone = (input: ForwardZoneInput): Promise<ForwardZone> =>
request("/api/forward-zones", { method: "POST", body: input });
export const getForwardZone = (id: number): Promise<ForwardZone> => request(`/api/forward-zones/${id}`);
export const updateForwardZone = (id: number, input: ForwardZoneInput): Promise<ForwardZone> =>
request(`/api/forward-zones/${id}`, { method: "PUT", body: input });
export const deleteForwardZone = (id: number): Promise<void> =>
request(`/api/forward-zones/${id}`, { method: "DELETE" });
// Clients + prefixes
export const listClients = async (): Promise<Client[]> =>
(await request<{ clients: Client[] }>("/api/clients")).clients;
export const getClient = (id: number): Promise<Client> => request(`/api/clients/${id}`);
export const updateClient = (id: number, edit: ClientEdit): Promise<Client> =>
request(`/api/clients/${id}`, { method: "PUT", body: edit });
export const deleteClient = (id: number): Promise<void> => request(`/api/clients/${id}`, { method: "DELETE" });
export const listClientPrefixes = async (): Promise<ClientPrefix[]> =>
(await request<{ client_prefixes: ClientPrefix[] }>("/api/client-prefixes")).client_prefixes;
export const putClientPrefixes = async (prefixes: ClientPrefixInput[]): Promise<ClientPrefix[]> =>
(
await request<{ client_prefixes: ClientPrefix[] }>("/api/client-prefixes", {
method: "PUT",
body: { client_prefixes: prefixes },
})
).client_prefixes;
// Upstreams
export const listUpstreams = async (): Promise<Upstream[]> =>
(await request<{ upstreams: Upstream[] }>("/api/upstreams")).upstreams;
export const createUpstream = (input: UpstreamInput): Promise<UpstreamEcho> =>
request("/api/upstreams", { method: "POST", body: input });
export const getUpstream = (id: number): Promise<Upstream> => request(`/api/upstreams/${id}`);
export const updateUpstream = (id: number, input: UpstreamInput): Promise<UpstreamEcho> =>
request(`/api/upstreams/${id}`, { method: "PUT", body: input });
export const deleteUpstream = (id: number): Promise<void> => request(`/api/upstreams/${id}`, { method: "DELETE" });
// Pause + settings
export const getPause = (): Promise<PauseState> => request("/api/pause");
export const postPause = (body: PausePost): Promise<PauseState> => request("/api/pause", { method: "POST", body });
export const getSettings = (): Promise<SettingsEnvelope> => request("/api/settings");
export const putSettings = (patch: SettingsPatch): Promise<SettingsEnvelope> =>
request("/api/settings", { method: "PUT", body: patch });
+23
View File
@@ -0,0 +1,23 @@
import { formatBytes, formatMicros, formatTime } from "@/lib/format";
test("formatTime renders unix seconds in the given locale and zone", () => {
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
expect(formatTime(1704067200, "en-US", "UTC").replace(//g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
});
test("formatBytes humanizes with binary units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(1023)).toBe("1023 B");
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1536)).toBe("1.5 KiB");
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MiB");
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe("3.0 GiB");
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
});
test("formatMicros renders milliseconds with one decimal", () => {
expect(formatMicros(0)).toBe("0.0 ms");
expect(formatMicros(1234)).toBe("1.2 ms");
expect(formatMicros(999)).toBe("1.0 ms");
expect(formatMicros(2_500_000)).toBe("2500.0 ms");
});
+27
View File
@@ -0,0 +1,27 @@
/** Unix seconds → localized date-time. `locale`/`timeZone` exist for deterministic tests. */
export function formatTime(unixSeconds: number, locale?: string, timeZone?: string): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "medium",
timeZone,
}).format(new Date(unixSeconds * 1000));
}
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
let value = bytes;
let unit: string = BYTE_UNITS[0];
for (const next of BYTE_UNITS) {
unit = next;
value /= 1024;
if (value < 1024) break;
}
return `${value.toFixed(1)} ${unit}`;
}
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
export function formatMicros(micros: number): string {
return `${(micros / 1000).toFixed(1)} ms`;
}
+279
View File
@@ -0,0 +1,279 @@
import { queryOptions, type QueryClient } from "@tanstack/react-query";
import * as api from "@/lib/api";
import type {
BlocklistInput,
ClientEdit,
ClientPrefixInput,
ForwardZoneInput,
GroupInput,
LocalRecordInput,
PausePost,
Period,
QueriesFilter,
RuleInput,
SettingsPatch,
UpstreamInput,
} from "@/lib/types";
export const queryKeys = {
health: ["health"] as const,
version: ["version"] as const,
stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
queries: (filter: QueriesFilter) => ["queries", filter] as const,
upstreamHealth: ["upstream-health"] as const,
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] 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,
clients: ["clients"] as const,
clientPrefixes: ["client-prefixes"] as const,
upstreams: ["upstreams"] as const,
pause: ["pause"] as const,
settings: ["settings"] as const,
};
export const healthQuery = () =>
queryOptions({ queryKey: queryKeys.health, queryFn: api.getHealth, refetchInterval: 10_000 });
export const versionQuery = () =>
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
export const statsQuery = (period: Period = "24h") =>
queryOptions({ queryKey: queryKeys.stats(period), queryFn: () => api.getStats(period), refetchInterval: 30_000 });
export const timeseriesQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.timeseries(period),
queryFn: () => api.getStatsTimeseries(period),
refetchInterval: 30_000,
});
export const queriesQuery = (filter: QueriesFilter = {}) =>
queryOptions({ queryKey: queryKeys.queries(filter), queryFn: () => api.getQueries(filter) });
export const upstreamHealthQuery = () =>
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 });
export const lookupQuery = (domain: string, groupId?: number) =>
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
export const groupsQuery = () => queryOptions({ queryKey: queryKeys.groups, queryFn: api.listGroups });
export const groupSourcesQuery = (id: number) =>
queryOptions({ queryKey: queryKeys.groupSources(id), queryFn: () => api.getGroupSources(id) });
export const blocklistsQuery = () => queryOptions({ queryKey: queryKeys.blocklists, queryFn: api.listBlocklists });
export const rulesQuery = () => queryOptions({ queryKey: queryKeys.rules, queryFn: api.listRules });
export const localRecordsQuery = () =>
queryOptions({ queryKey: queryKeys.localRecords, queryFn: api.listLocalRecords });
export const forwardZonesQuery = () =>
queryOptions({ queryKey: queryKeys.forwardZones, queryFn: api.listForwardZones });
export const clientsQuery = () => queryOptions({ queryKey: queryKeys.clients, queryFn: api.listClients });
export const clientPrefixesQuery = () =>
queryOptions({ queryKey: queryKeys.clientPrefixes, queryFn: api.listClientPrefixes });
export const upstreamsQuery = () => queryOptions({ queryKey: queryKeys.upstreams, queryFn: api.listUpstreams });
export const pauseQuery = () => queryOptions({ queryKey: queryKeys.pause, queryFn: api.getPause });
export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings });
// Mutation option factories. Usage: useMutation(groupCreateMutation(useQueryClient())).
// Group membership and names feed lookup verdicts and the group columns on
// clients, prefixes and rules, hence the wide invalidation on group mutations.
function invalidateGroupWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.groups }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: queryKeys.clients }),
qc.invalidateQueries({ queryKey: queryKeys.clientPrefixes }),
qc.invalidateQueries({ queryKey: queryKeys.rules }),
]);
}
export const groupCreateMutation = (qc: QueryClient) => ({
mutationFn: (input: GroupInput) => api.createGroup(input),
onSuccess: () => invalidateGroupWorld(qc),
});
export const groupUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, input }: { id: number; input: GroupInput }) => api.updateGroup(id, input),
onSuccess: () => invalidateGroupWorld(qc),
});
export const groupDeleteMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.deleteGroup(id),
onSuccess: () => invalidateGroupWorld(qc),
});
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"] });
},
});
function invalidateBlocklistWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: ["groups"] }),
]);
}
export const blocklistCreateMutation = (qc: QueryClient) => ({
mutationFn: (input: BlocklistInput) => api.createBlocklist(input),
onSuccess: () => invalidateBlocklistWorld(qc),
});
export const blocklistUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, input }: { id: number; input: BlocklistInput }) => api.updateBlocklist(id, input),
onSuccess: () => invalidateBlocklistWorld(qc),
});
export const blocklistDeleteMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.deleteBlocklist(id),
onSuccess: () => invalidateBlocklistWorld(qc),
});
/** Ruling 12: the 202 snapshot REPLACES the sources cache; counters refresh. */
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
mutationFn: () => api.updateBlocklistsNow(),
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
qc.setQueryData(queryKeys.blocklistSources, sources);
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
]);
},
});
function invalidateRules(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.rules }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
]);
}
export const ruleCreateMutation = (qc: QueryClient) => ({
mutationFn: (input: RuleInput) => api.createRule(input),
onSuccess: () => invalidateRules(qc),
});
export const ruleUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, input }: { id: number; input: RuleInput }) => api.updateRule(id, input),
onSuccess: () => invalidateRules(qc),
});
export const ruleDeleteMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.deleteRule(id),
onSuccess: () => invalidateRules(qc),
});
function invalidateLocalRecords(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.localRecords }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
]);
}
export const localRecordCreateMutation = (qc: QueryClient) => ({
mutationFn: (input: LocalRecordInput) => api.createLocalRecord(input),
onSuccess: () => invalidateLocalRecords(qc),
});
export const localRecordUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, input }: { id: number; input: LocalRecordInput }) => api.updateLocalRecord(id, input),
onSuccess: () => invalidateLocalRecords(qc),
});
export const localRecordDeleteMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.deleteLocalRecord(id),
onSuccess: () => invalidateLocalRecords(qc),
});
function invalidateForwardZones(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.forwardZones }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
]);
}
export const forwardZoneCreateMutation = (qc: QueryClient) => ({
mutationFn: (input: ForwardZoneInput) => api.createForwardZone(input),
onSuccess: () => invalidateForwardZones(qc),
});
export const forwardZoneUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, input }: { id: number; input: ForwardZoneInput }) => api.updateForwardZone(id, input),
onSuccess: () => invalidateForwardZones(qc),
});
export const forwardZoneDeleteMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.deleteForwardZone(id),
onSuccess: () => invalidateForwardZones(qc),
});
export const clientUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, edit }: { id: number; edit: ClientEdit }) => api.updateClient(id, edit),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.clients }),
});
export const clientDeleteMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.deleteClient(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.clients }),
});
export const clientPrefixesPutMutation = (qc: QueryClient) => ({
mutationFn: (prefixes: ClientPrefixInput[]) => api.putClientPrefixes(prefixes),
onSuccess: (stored: Awaited<ReturnType<typeof api.putClientPrefixes>>) => {
qc.setQueryData(queryKeys.clientPrefixes, stored);
},
});
function invalidateUpstreams(qc: QueryClient): Promise<unknown> {
return qc.invalidateQueries({ queryKey: queryKeys.upstreams });
}
export const upstreamCreateMutation = (qc: QueryClient) => ({
mutationFn: (input: UpstreamInput) => api.createUpstream(input),
onSuccess: () => invalidateUpstreams(qc),
});
export const upstreamUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, input }: { id: number; input: UpstreamInput }) => api.updateUpstream(id, input),
onSuccess: () => invalidateUpstreams(qc),
});
export const upstreamDeleteMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.deleteUpstream(id),
onSuccess: () => invalidateUpstreams(qc),
});
export const pauseMutation = (qc: QueryClient) => ({
mutationFn: (body: PausePost) => api.postPause(body),
onSuccess: (state: Awaited<ReturnType<typeof api.postPause>>) => {
qc.setQueryData(queryKeys.pause, state);
},
});
export const settingsPutMutation = (qc: QueryClient) => ({
mutationFn: (patch: SettingsPatch) => api.putSettings(patch),
onSuccess: (envelope: Awaited<ReturnType<typeof api.putSettings>>) => {
qc.setQueryData(queryKeys.settings, envelope);
return qc.invalidateQueries({ queryKey: queryKeys.settings });
},
});
+42
View File
@@ -0,0 +1,42 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/api";
import { rememberAuthRequired } from "@/auth/store";
export function handleUnauthorized(error: unknown): void {
if (!(error instanceof ApiError) || error.status !== 401) return;
if (window.location.pathname === "/login") return;
rememberAuthRequired(true);
const current = window.location.pathname + window.location.search;
window.location.assign(`/login?redirect=${encodeURIComponent(current)}`);
}
function shouldRetry(failureCount: number, error: unknown): boolean {
if (error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429) {
return false;
}
return failureCount < 2;
}
function retryDelay(attemptIndex: number, error: unknown): number {
if (error instanceof ApiError && error.status === 429 && error.retryAfter !== undefined) {
return error.retryAfter * 1000;
}
return Math.min(1000 * 2 ** attemptIndex, 30_000);
}
export function createQueryClient(): QueryClient {
return new QueryClient({
queryCache: new QueryCache({ onError: handleUnauthorized }),
mutationCache: new MutationCache({ onError: handleUnauthorized }),
defaultOptions: {
queries: {
staleTime: 30_000,
retry: shouldRetry,
retryDelay,
},
mutations: {
retry: false,
},
},
});
}
+104
View File
@@ -0,0 +1,104 @@
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings } from "@/lib/types";
function baseSettings(): Settings {
return {
runtime: { io_backend: "threaded" },
upstream: { connect_timeout_ms: 2000, 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,
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 edit(mutate: (s: Settings) => void): Settings {
const edited = structuredClone(baseSettings());
mutate(edited);
return edited;
}
test("no changes and no password produces null", () => {
expect(buildSettingsPatch(baseSettings(), baseSettings())).toBeNull();
});
test("an empty password is not a change", () => {
expect(buildSettingsPatch(baseSettings(), baseSettings(), "")).toBeNull();
});
test("a single scalar change patches only its section field", () => {
const edited = edit((s) => {
s.dns.port = 5353;
});
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ dns: { port: 5353 } });
});
test("changes across sections stay grouped and minimal", () => {
const edited = edit((s) => {
s.logging.level = "debug";
s.logging.retention_days = 7;
s.cache.size = 20000;
});
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({
cache: { size: 20000 },
logging: { level: "debug", retention_days: 7 },
});
});
test("tls listener sections diff like any other", () => {
const edited = edit((s) => {
s.dot_server.enabled = true;
s.dot_server.cert_path = "/etc/nxdns/dot.pem";
});
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({
dot_server: { enabled: true, cert_path: "/etc/nxdns/dot.pem" },
});
});
test("web.auth_enabled is never emitted even when it differs", () => {
const edited = edit((s) => {
s.web.auth_enabled = false;
s.web.port = 9090;
});
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ web: { port: 9090 } });
});
test("a password alone produces a web-only patch", () => {
expect(buildSettingsPatch(baseSettings(), baseSettings(), "hunter2")).toEqual({
web: { password: "hunter2" },
});
});
test("a password merges into an existing web section diff", () => {
const edited = edit((s) => {
s.web.session_ttl_hours = 48;
});
expect(buildSettingsPatch(baseSettings(), edited, "hunter2")).toEqual({
web: { session_ttl_hours: 48, password: "hunter2" },
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { Settings, SettingsPatch } from "@/lib/types";
const SECTIONS = [
"runtime",
"upstream",
"dns",
"blocking",
"cache",
"web",
"doh_server",
"dot_server",
"edns",
"logging",
"disk",
"blocklist_update",
] as const;
/**
* Minimal partial patch for PUT /api/settings: only fields whose edited value
* differs from the original, grouped by section. The derived `web.auth_enabled`
* is never emitted. A non-empty `password` passes through as `web.password`.
* Returns null when nothing changed and no password was given.
*/
export function buildSettingsPatch(original: Settings, edited: Settings, password?: string): SettingsPatch | null {
const patch: Record<string, Record<string, unknown>> = {};
for (const section of SECTIONS) {
const before = original[section] as Record<string, unknown>;
const after = edited[section] as Record<string, unknown>;
let changed: Record<string, unknown> | undefined;
for (const key of Object.keys(after)) {
if (section === "web" && key === "auth_enabled") continue;
if (before[key] !== after[key]) (changed ??= {})[key] = after[key];
}
if (changed !== undefined) patch[section] = changed;
}
if (password !== undefined && password !== "") {
patch["web"] = { ...patch["web"], password };
}
return Object.keys(patch).length === 0 ? null : (patch as SettingsPatch);
}
+403
View File
@@ -0,0 +1,403 @@
// Hand-transcribed from src/web/openapi.yaml. Field names stay snake_case to
// match the wire format exactly; nullability mirrors the contract.
export type Period = "1h" | "24h" | "7d" | "30d";
export interface Health {
status: "ok" | "degraded";
disk: {
state: "ok" | "warn" | "critical";
free_bytes: number;
db_bytes: number;
log_bytes: number;
sample_failures: number;
};
upstreams: {
available: number;
total: number;
};
queries_dropped: number;
writer_failed: boolean;
refreshes_gated: number;
snapshot_generation: number | null;
}
export interface Version {
version: string;
git_commit: string;
zig_version: string;
uptime_seconds: number;
}
export interface LoginRequest {
password: string;
}
export interface LoginResponse {
authenticated: true;
auth_required: boolean;
}
export interface LogoutResponse {
authenticated: false;
}
export interface QueryRow {
id: number;
ts: number;
domain: string;
client_ip: string;
qtype: number | null;
blocked: boolean;
block_reason: string;
response_time_us: number | null;
cache_hit: boolean | null;
upstream: string;
}
/** SSE `event: query` payload: a QueryRow minus `id` (precedes persistence). */
export type LiveQueryEvent = Omit<QueryRow, "id">;
export interface QueriesPage {
queries: QueryRow[];
next_before: number | null;
}
export interface QueriesFilter {
limit?: number;
before?: number;
domain?: string;
client?: string;
blocked?: boolean;
since?: number;
until?: number;
}
export interface StatsTotals {
period: Period;
since: number;
until: number;
queries: number;
blocked: number;
cached: number;
clients: number;
avg_response_time_us: number | null;
}
export interface Bucket {
ts: number;
queries: number;
blocked: number;
cached: number;
}
export interface StatsTimeseries {
period: Period;
since: number;
until: number;
bucket_seconds: number;
buckets: Bucket[];
}
export interface LookupResult {
domain: string;
group_id: number;
local_records: boolean;
forward_zone: string | null;
blocked: boolean;
reason: string;
matched: string;
source_url: string | null;
safe_search_rewrite: string | null;
}
export interface UpstreamHealthEntry {
url: string;
enabled: boolean;
available: boolean;
consecutive_failures: number;
total_successes: number;
total_failures: number;
success_rate: number;
last_error: string;
}
export interface UpstreamHealth {
upstreams: UpstreamHealthEntry[];
available: number;
total: number;
}
export interface Group {
id: number;
name: string;
safe_search: boolean;
}
export interface GroupInput {
name: string;
safe_search?: boolean;
}
export interface GroupSources {
source_ids: number[];
}
export interface Blocklist {
id: number;
url: string;
name: string;
enabled: boolean;
is_suggested: boolean;
last_updated: number | null;
domain_count: number;
wildcard_count: number;
skipped_regex_count: number;
checksum: string | null;
}
export interface BlocklistInput {
url: string;
name: string;
enabled?: boolean;
is_suggested?: boolean;
}
export interface BlocklistEcho {
id: number;
url: string;
name: string;
enabled: boolean;
is_suggested: boolean;
}
export interface SourceStatus {
id: number;
state: string;
loaded: boolean;
last_attempt: number;
last_success: number;
url: string;
last_error: string;
domains: number;
wildcards: number;
skipped_regex: number;
}
export type RuleKind = "exact" | "wildcard";
export type RuleAction = "allow" | "block";
export interface Rule {
id: number;
group_id: number;
group: string;
pattern: string;
kind: RuleKind;
action: RuleAction;
created_at: number;
}
export interface RuleInput {
group_id: number;
pattern: string;
kind: RuleKind;
action: RuleAction;
}
export interface RuleEcho {
id: number;
group_id: number;
pattern: string;
kind: RuleKind;
action: RuleAction;
}
export type LocalRecordType = "A" | "AAAA" | "CNAME";
export interface LocalRecord {
id: number;
name: string;
rtype: LocalRecordType;
value: string;
ttl: number;
}
export interface LocalRecordInput {
name: string;
rtype: LocalRecordType;
value: string;
ttl?: number;
}
export interface ForwardZone {
id: number;
zone: string;
resolver: string;
}
export interface ForwardZoneInput {
zone: string;
resolver: string;
}
export interface Client {
id: number;
ip: string;
name: string;
group_id: number;
group: string;
hand_edited: boolean;
first_seen: number;
last_seen: number;
}
export interface ClientEdit {
name?: string;
group_id: number;
}
export interface ClientPrefix {
id: number;
prefix: string;
group_id: number;
group: string;
priority: number;
}
export interface ClientPrefixInput {
prefix: string;
group_id: number;
priority?: number;
}
export interface Upstream {
id: number;
url: string;
priority: number;
enabled: boolean;
tls_name: string;
}
export interface UpstreamInput {
url: string;
priority?: number;
enabled?: boolean;
tls_name?: string;
}
export interface UpstreamEcho {
id: number;
url: string;
priority: number;
enabled: boolean;
tls_name: string;
restart_required: true;
}
export interface PauseState {
paused: boolean;
until: number | null;
}
export interface PausePost {
paused: boolean;
duration_seconds?: number | null;
}
export interface TlsListenerSettings {
enabled: boolean;
bind: string;
port: number;
cert_path: string;
key_path: string;
}
export interface Settings {
runtime: {
io_backend: "threaded" | "evented";
};
upstream: {
connect_timeout_ms: number;
read_timeout_ms: number;
total_timeout_ms: number;
};
dns: {
bind_ipv4: string;
bind_ipv6: string;
port: number;
rate_limit: number;
rate_window_seconds: number;
};
blocking: {
response: "zero" | "nxdomain";
ttl: number;
};
cache: {
size: number;
negative_ttl_max: number;
};
web: {
enabled: boolean;
bind: string;
port: number;
session_ttl_hours: number;
api_rate_limit_per_min: number;
api_localhost_exempt: boolean;
sse_max_connections_per_ip: number;
/** Derived, read-only; true iff a password hash is stored. Never sent back. */
auth_enabled: boolean;
};
doh_server: TlsListenerSettings;
dot_server: TlsListenerSettings;
edns: {
ecs_mode: "strip" | "forward";
};
logging: {
level: "error" | "warn" | "info" | "debug";
retention_days: number;
query_log_buffer_max: number;
hide_domains: boolean;
hide_client_ips: boolean;
output: "stderr" | "syslog" | "file";
file_path: string;
max_size_mb: number;
max_files: number;
};
disk: {
min_free_mb: number;
warn_free_mb: number;
};
blocklist_update: {
enabled: boolean;
interval_hours: number;
};
}
export interface SettingsEnvelope {
settings: Settings;
restart_required: string[];
}
export interface TlsListenerPatch {
enabled?: boolean;
bind?: string;
port?: number;
cert_path?: string;
key_path?: string;
}
/** Partial update; `web.password` is write-only, `web.auth_enabled` is never sent. */
export interface SettingsPatch {
runtime?: Partial<Settings["runtime"]>;
upstream?: Partial<Settings["upstream"]>;
dns?: Partial<Settings["dns"]>;
blocking?: Partial<Settings["blocking"]>;
cache?: Partial<Settings["cache"]>;
web?: Partial<Omit<Settings["web"], "auth_enabled">> & { password?: string };
doh_server?: TlsListenerPatch;
dot_server?: TlsListenerPatch;
edns?: Partial<Settings["edns"]>;
logging?: Partial<Settings["logging"]>;
disk?: Partial<Settings["disk"]>;
blocklist_update?: Partial<Settings["blocklist_update"]>;
}