milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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] }));
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]>;
|
||||
}
|
||||
Reference in New Issue
Block a user