rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import InlineError from "./InlineError";
|
||||
|
||||
test("no retry button without onRetry", () => {
|
||||
render(<InlineError error={new ApiError(409, "already exists")} />);
|
||||
expect(screen.getByRole("alert").textContent).toBe("already exists");
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
|
||||
});
|
||||
|
||||
test("onRetry renders a focusable retry button that calls back", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Retry" });
|
||||
// The accessibility floor: StyleX compiles the ring to opaque class names, so
|
||||
// the check is that every class `focusRing` produces landed on the button.
|
||||
const ring = (stylex.props(shared.focusRing).className ?? "").split(" ");
|
||||
expect(button.className.split(" ")).toEqual(expect.arrayContaining(ring));
|
||||
fireEvent.click(button);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a null error renders nothing even with onRetry", () => {
|
||||
const { container } = render(<InlineError error={null} onRetry={() => undefined} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
message: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
retry: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
color: "inherit",
|
||||
fontSize: "inherit",
|
||||
fontWeight: 500,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with
|
||||
* countdown. Pass `onRetry` to append a retry button for a failed query.
|
||||
*/
|
||||
export default function InlineError({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
|
||||
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" {...stylex.props(styles.message)}>
|
||||
{message}
|
||||
{onRetry !== undefined && (
|
||||
<>
|
||||
{" "}
|
||||
<button type="button" onClick={onRetry} {...stylex.props(styles.retry, shared.focusRing)}>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</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,220 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 getHealth = (): Promise<Health> => request("/api/health");
|
||||
export const getVersion = (): Promise<Version> => request("/api/version");
|
||||
|
||||
// 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 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 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 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 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 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 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 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,717 @@
|
||||
// Generated file — do not edit by hand.
|
||||
//
|
||||
// Every value below is a real response from the web server, captured by the
|
||||
// contract-sample test in src/web/web_integration_test.zig and canonicalized:
|
||||
// object keys sorted, every number 0, strings and booleans as the deterministic
|
||||
// seed produced them, repeated array elements collapsed to the first. The type
|
||||
// annotations are the ones api.ts hands to its own `request<T>`, so `tsc`
|
||||
// refuses a field the wire does not send, a wire field types.ts does not
|
||||
// declare, and a string outside a literal union.
|
||||
//
|
||||
// Regenerate with:
|
||||
// zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
|
||||
|
||||
import type {
|
||||
Blocklist,
|
||||
BlocklistEcho,
|
||||
Client,
|
||||
ClientPrefix,
|
||||
ErrorEnvelope,
|
||||
ForwardZone,
|
||||
Group,
|
||||
Health,
|
||||
LocalRecord,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
LookupResult,
|
||||
PauseState,
|
||||
QueriesPage,
|
||||
Rule,
|
||||
RuleEcho,
|
||||
SettingsEnvelope,
|
||||
SourceStatus,
|
||||
StatsTimeseries,
|
||||
StatsTotals,
|
||||
Upstream,
|
||||
UpstreamEcho,
|
||||
UpstreamHealth,
|
||||
Version,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const sample_get_health: Health = {
|
||||
disk: {
|
||||
db_bytes: 0,
|
||||
free_bytes: 0,
|
||||
log_bytes: 0,
|
||||
sample_failures: 0,
|
||||
state: "ok",
|
||||
},
|
||||
queries_dropped: 0,
|
||||
refreshes_gated: 0,
|
||||
snapshot_generation: 0,
|
||||
status: "ok",
|
||||
upstreams: {
|
||||
available: 0,
|
||||
total: 0,
|
||||
},
|
||||
writer_failed: false,
|
||||
};
|
||||
|
||||
export const sample_get_version: Version = {
|
||||
git_commit: "<build>",
|
||||
uptime_seconds: 0,
|
||||
version: "w10-test",
|
||||
zig_version: "<build>",
|
||||
};
|
||||
|
||||
export const sample_login: LoginResponse = {
|
||||
auth_required: false,
|
||||
authenticated: true,
|
||||
};
|
||||
|
||||
export const sample_logout: LogoutResponse = {
|
||||
authenticated: false,
|
||||
};
|
||||
|
||||
export const sample_create_blocklist: BlocklistEcho = {
|
||||
enabled: false,
|
||||
id: 0,
|
||||
is_suggested: false,
|
||||
name: "ads",
|
||||
url: "https://lists.example/ads.txt",
|
||||
};
|
||||
|
||||
export const sample_list_blocklists: { blocklists: Blocklist[] } = {
|
||||
blocklists: [
|
||||
{
|
||||
checksum: null,
|
||||
domain_count: 0,
|
||||
enabled: false,
|
||||
exception_count: 0,
|
||||
id: 0,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
name: "ads",
|
||||
skipped_regex_count: 0,
|
||||
skipped_unsupported_count: 0,
|
||||
url: "https://lists.example/ads.txt",
|
||||
wildcard_count: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_blocklist: BlocklistEcho = {
|
||||
enabled: false,
|
||||
id: 0,
|
||||
is_suggested: false,
|
||||
name: "ads2",
|
||||
url: "https://lists.example/ads.txt",
|
||||
};
|
||||
|
||||
export const sample_update_blocklists_now: { sources: SourceStatus[] } = {
|
||||
sources: [
|
||||
{
|
||||
domains: 0,
|
||||
exceptions: 0,
|
||||
id: 0,
|
||||
last_attempt: 0,
|
||||
last_error: "",
|
||||
last_success: 0,
|
||||
loaded: false,
|
||||
skipped_regex: 0,
|
||||
skipped_unsupported: 0,
|
||||
state: "never_fetched",
|
||||
url: "https://lists.example/ads.txt",
|
||||
wildcards: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_list_groups: { groups: Group[] } = {
|
||||
groups: [
|
||||
{
|
||||
id: 0,
|
||||
name: "default",
|
||||
safe_search: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_create_group: Group = {
|
||||
id: 0,
|
||||
name: "kids",
|
||||
safe_search: false,
|
||||
};
|
||||
|
||||
export const sample_update_group: Group = {
|
||||
id: 0,
|
||||
name: "teens",
|
||||
safe_search: true,
|
||||
};
|
||||
|
||||
export const sample_put_group_sources: { source_ids: number[] } = {
|
||||
source_ids: [0],
|
||||
};
|
||||
|
||||
export const sample_get_group_sources: { source_ids: number[] } = {
|
||||
source_ids: [0],
|
||||
};
|
||||
|
||||
export const sample_create_rule: RuleEcho = {
|
||||
action: "block",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
kind: "exact",
|
||||
pattern: "ads.example",
|
||||
};
|
||||
|
||||
export const sample_list_rules: { rules: Rule[] } = {
|
||||
rules: [
|
||||
{
|
||||
action: "block",
|
||||
created_at: 0,
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
kind: "exact",
|
||||
pattern: "ads.example",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_rule: RuleEcho = {
|
||||
action: "block",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
kind: "wildcard",
|
||||
pattern: "*.ads.example",
|
||||
};
|
||||
|
||||
export const sample_get_lookup: LookupResult = {
|
||||
blocked: true,
|
||||
domain: "sub.ads.example",
|
||||
forward_zone: null,
|
||||
group_id: 0,
|
||||
local_records: false,
|
||||
matched: "*.ads.example",
|
||||
reason: "rule_block_wildcard",
|
||||
safe_search_rewrite: null,
|
||||
source_url: null,
|
||||
};
|
||||
|
||||
export const sample_create_local_record: LocalRecord = {
|
||||
id: 0,
|
||||
name: "nas.lan",
|
||||
rtype: "A",
|
||||
ttl: 0,
|
||||
value: "192.168.1.10",
|
||||
};
|
||||
|
||||
export const sample_list_local_records: { local_records: LocalRecord[] } = {
|
||||
local_records: [
|
||||
{
|
||||
id: 0,
|
||||
name: "nas.lan",
|
||||
rtype: "A",
|
||||
ttl: 0,
|
||||
value: "192.168.1.10",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_local_record: LocalRecord = {
|
||||
id: 0,
|
||||
name: "nas.lan",
|
||||
rtype: "A",
|
||||
ttl: 0,
|
||||
value: "192.168.1.11",
|
||||
};
|
||||
|
||||
export const sample_create_forward_zone: ForwardZone = {
|
||||
id: 0,
|
||||
resolver: "udp://10.0.0.1:53",
|
||||
zone: "lan",
|
||||
};
|
||||
|
||||
export const sample_list_forward_zones: { forward_zones: ForwardZone[] } = {
|
||||
forward_zones: [
|
||||
{
|
||||
id: 0,
|
||||
resolver: "udp://10.0.0.1:53",
|
||||
zone: "lan",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_forward_zone: ForwardZone = {
|
||||
id: 0,
|
||||
resolver: "udp://10.0.0.2:53",
|
||||
zone: "lan",
|
||||
};
|
||||
|
||||
export const sample_list_clients: { clients: Client[] } = {
|
||||
clients: [
|
||||
{
|
||||
first_seen: 0,
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
hand_edited: false,
|
||||
id: 0,
|
||||
ip: "192.168.1.50",
|
||||
last_seen: 0,
|
||||
learned_name: "",
|
||||
name: "laptop",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_client: Client = {
|
||||
first_seen: 0,
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
hand_edited: true,
|
||||
id: 0,
|
||||
ip: "192.168.1.50",
|
||||
last_seen: 0,
|
||||
learned_name: "",
|
||||
name: "laptop-renamed",
|
||||
};
|
||||
|
||||
export const sample_put_client_prefixes: { client_prefixes: ClientPrefix[] } = {
|
||||
client_prefixes: [
|
||||
{
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
prefix: "192.168.1.0/24",
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_list_client_prefixes: { client_prefixes: ClientPrefix[] } = {
|
||||
client_prefixes: [
|
||||
{
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
prefix: "192.168.1.0/24",
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_list_upstreams: { upstreams: Upstream[] } = {
|
||||
upstreams: [
|
||||
{
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
tls_name: "",
|
||||
url: "https://dns.example/dns-query",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_create_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
restart_required: true,
|
||||
tls_name: "",
|
||||
url: "https://dns2.example/dns-query",
|
||||
};
|
||||
|
||||
export const sample_update_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
restart_required: true,
|
||||
tls_name: "",
|
||||
url: "https://dns.example/dns-query",
|
||||
};
|
||||
|
||||
export const sample_get_upstream_health: UpstreamHealth = {
|
||||
available: 0,
|
||||
total: 0,
|
||||
upstreams: [
|
||||
{
|
||||
available: true,
|
||||
consecutive_failures: 0,
|
||||
enabled: true,
|
||||
last_error: "",
|
||||
success_rate: 0,
|
||||
total_failures: 0,
|
||||
total_successes: 0,
|
||||
url: "https://dns.example/dns-query",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_get_queries: QueriesPage = {
|
||||
next_before: 0,
|
||||
queries: [
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: true,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d24.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: false,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d23.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: true,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d22.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: false,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d21.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "blocklist_domain",
|
||||
blocked: true,
|
||||
cache_hit: null,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d20.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_get_stats: StatsTotals = {
|
||||
avg_response_time_us: null,
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
clients: 0,
|
||||
period: "1h",
|
||||
queries: 0,
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_stats_timeseries: StatsTimeseries = {
|
||||
bucket_seconds: 0,
|
||||
buckets: [
|
||||
{
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
queries: 0,
|
||||
ts: 0,
|
||||
},
|
||||
],
|
||||
period: "1h",
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_pause: PauseState = {
|
||||
paused: false,
|
||||
until: null,
|
||||
};
|
||||
|
||||
export const sample_post_pause: PauseState = {
|
||||
paused: true,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_settings: SettingsEnvelope = {
|
||||
authority: {
|
||||
mode: "database",
|
||||
path: null,
|
||||
reconciled_at: null,
|
||||
},
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"dns.rate_limit",
|
||||
"dns.rate_window_seconds",
|
||||
"blocking.response",
|
||||
"blocking.ttl",
|
||||
"cache.size",
|
||||
"cache.negative_ttl_max",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.api_localhost_exempt",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
"doh_server.enabled",
|
||||
"doh_server.bind",
|
||||
"doh_server.port",
|
||||
"doh_server.cert_path",
|
||||
"doh_server.key_path",
|
||||
"dot_server.enabled",
|
||||
"dot_server.bind",
|
||||
"dot_server.port",
|
||||
"dot_server.cert_path",
|
||||
"dot_server.key_path",
|
||||
"edns.ecs_mode",
|
||||
"logging.level",
|
||||
"logging.retention_days",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.hide_domains",
|
||||
"logging.hide_client_ips",
|
||||
"logging.output",
|
||||
"logging.file_path",
|
||||
"logging.max_size_mb",
|
||||
"logging.max_files",
|
||||
"disk.min_free_mb",
|
||||
"disk.warn_free_mb",
|
||||
"blocklist_update.enabled",
|
||||
"blocklist_update.interval_hours",
|
||||
],
|
||||
settings: {
|
||||
blocking: {
|
||||
response: "zero",
|
||||
ttl: 0,
|
||||
},
|
||||
blocklist_update: {
|
||||
enabled: true,
|
||||
interval_hours: 0,
|
||||
},
|
||||
cache: {
|
||||
negative_ttl_max: 0,
|
||||
size: 0,
|
||||
},
|
||||
disk: {
|
||||
min_free_mb: 0,
|
||||
warn_free_mb: 0,
|
||||
},
|
||||
dns: {
|
||||
bind_ipv4: "0.0.0.0",
|
||||
bind_ipv6: "::",
|
||||
port: 0,
|
||||
rate_limit: 0,
|
||||
rate_window_seconds: 0,
|
||||
},
|
||||
doh_server: {
|
||||
bind: "0.0.0.0",
|
||||
cert_path: "/etc/nxdns/cert.pem",
|
||||
enabled: false,
|
||||
key_path: "/etc/nxdns/key.pem",
|
||||
port: 0,
|
||||
},
|
||||
dot_server: {
|
||||
bind: "0.0.0.0",
|
||||
cert_path: "/etc/nxdns/cert.pem",
|
||||
enabled: false,
|
||||
key_path: "/etc/nxdns/key.pem",
|
||||
port: 0,
|
||||
},
|
||||
edns: {
|
||||
ecs_mode: "strip",
|
||||
},
|
||||
logging: {
|
||||
file_path: "/var/log/nxdns/nxdns.log",
|
||||
hide_client_ips: false,
|
||||
hide_domains: false,
|
||||
level: "info",
|
||||
max_files: 0,
|
||||
max_size_mb: 0,
|
||||
output: "stderr",
|
||||
query_log_buffer_max: 0,
|
||||
retention_days: 0,
|
||||
},
|
||||
upstream: {
|
||||
attempt_timeout_ms: 0,
|
||||
read_timeout_ms: 0,
|
||||
total_timeout_ms: 0,
|
||||
},
|
||||
web: {
|
||||
api_localhost_exempt: true,
|
||||
api_rate_limit_per_min: 0,
|
||||
auth_enabled: false,
|
||||
bind: "0.0.0.0",
|
||||
enabled: true,
|
||||
port: 0,
|
||||
session_ttl_hours: 0,
|
||||
sse_max_connections_per_ip: 0,
|
||||
trusted_proxies: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const sample_put_settings: SettingsEnvelope = {
|
||||
authority: {
|
||||
mode: "database",
|
||||
path: null,
|
||||
reconciled_at: null,
|
||||
},
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"dns.rate_limit",
|
||||
"dns.rate_window_seconds",
|
||||
"blocking.response",
|
||||
"blocking.ttl",
|
||||
"cache.size",
|
||||
"cache.negative_ttl_max",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.api_localhost_exempt",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
"doh_server.enabled",
|
||||
"doh_server.bind",
|
||||
"doh_server.port",
|
||||
"doh_server.cert_path",
|
||||
"doh_server.key_path",
|
||||
"dot_server.enabled",
|
||||
"dot_server.bind",
|
||||
"dot_server.port",
|
||||
"dot_server.cert_path",
|
||||
"dot_server.key_path",
|
||||
"edns.ecs_mode",
|
||||
"logging.level",
|
||||
"logging.retention_days",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.hide_domains",
|
||||
"logging.hide_client_ips",
|
||||
"logging.output",
|
||||
"logging.file_path",
|
||||
"logging.max_size_mb",
|
||||
"logging.max_files",
|
||||
"disk.min_free_mb",
|
||||
"disk.warn_free_mb",
|
||||
"blocklist_update.enabled",
|
||||
"blocklist_update.interval_hours",
|
||||
],
|
||||
settings: {
|
||||
blocking: {
|
||||
response: "zero",
|
||||
ttl: 0,
|
||||
},
|
||||
blocklist_update: {
|
||||
enabled: true,
|
||||
interval_hours: 0,
|
||||
},
|
||||
cache: {
|
||||
negative_ttl_max: 0,
|
||||
size: 0,
|
||||
},
|
||||
disk: {
|
||||
min_free_mb: 0,
|
||||
warn_free_mb: 0,
|
||||
},
|
||||
dns: {
|
||||
bind_ipv4: "0.0.0.0",
|
||||
bind_ipv6: "::",
|
||||
port: 0,
|
||||
rate_limit: 0,
|
||||
rate_window_seconds: 0,
|
||||
},
|
||||
doh_server: {
|
||||
bind: "0.0.0.0",
|
||||
cert_path: "/etc/nxdns/cert.pem",
|
||||
enabled: false,
|
||||
key_path: "/etc/nxdns/key.pem",
|
||||
port: 0,
|
||||
},
|
||||
dot_server: {
|
||||
bind: "0.0.0.0",
|
||||
cert_path: "/etc/nxdns/cert.pem",
|
||||
enabled: false,
|
||||
key_path: "/etc/nxdns/key.pem",
|
||||
port: 0,
|
||||
},
|
||||
edns: {
|
||||
ecs_mode: "strip",
|
||||
},
|
||||
logging: {
|
||||
file_path: "/var/log/nxdns/nxdns.log",
|
||||
hide_client_ips: false,
|
||||
hide_domains: false,
|
||||
level: "info",
|
||||
max_files: 0,
|
||||
max_size_mb: 0,
|
||||
output: "stderr",
|
||||
query_log_buffer_max: 0,
|
||||
retention_days: 0,
|
||||
},
|
||||
upstream: {
|
||||
attempt_timeout_ms: 0,
|
||||
read_timeout_ms: 0,
|
||||
total_timeout_ms: 0,
|
||||
},
|
||||
web: {
|
||||
api_localhost_exempt: true,
|
||||
api_rate_limit_per_min: 0,
|
||||
auth_enabled: false,
|
||||
bind: "0.0.0.0",
|
||||
enabled: true,
|
||||
port: 0,
|
||||
session_ttl_hours: 0,
|
||||
sse_max_connections_per_ip: 0,
|
||||
trusted_proxies: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const sample_error_bad_request: ErrorEnvelope = {
|
||||
error: "logging.level: not one of the values this setting accepts",
|
||||
};
|
||||
|
||||
export const sample_error_conflict: ErrorEnvelope = {
|
||||
error: "an upstream with that url already exists",
|
||||
};
|
||||
|
||||
export const sample_error_not_found: ErrorEnvelope = {
|
||||
error: "not found",
|
||||
};
|
||||
|
||||
export const sample_error_unauthorized: ErrorEnvelope = {
|
||||
error: "authentication required",
|
||||
};
|
||||
|
||||
export const sample_error_rate_limited: ErrorEnvelope = {
|
||||
error: "rate limited",
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Embeds the committed contract-sample golden (milestone-17 ruling 5). Module
|
||||
//! root for the `contract_samples` anonymous import (test builds only) — a
|
||||
//! `.ts` file cannot root one, and @embedFile paths resolve relative to this
|
||||
//! file. The `docs/docs.zig` pattern.
|
||||
|
||||
pub const bytes = @embedFile("contractSamples.gen.ts");
|
||||
|
||||
/// Repo-relative path, so a failing assertion names the file to regenerate.
|
||||
pub const path = "admin/src/lib/contractSamples.gen.ts";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,286 @@
|
||||
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,
|
||||
ClientPrefixInput,
|
||||
ForwardZoneInput,
|
||||
GroupInput,
|
||||
LocalRecordInput,
|
||||
PausePost,
|
||||
Period,
|
||||
QueriesFilter,
|
||||
QueriesPage,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
});
|
||||
|
||||
// Keyset pagination on `next_before` (handlers/queries.zig). A background
|
||||
// refetch replays every page in cursor order, so newly logged rows shift the
|
||||
// whole window instead of opening a gap between page 1 and page 2.
|
||||
export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: queryKeys.queriesInfinite(filter),
|
||||
queryFn: ({ pageParam }: { pageParam: number | undefined }) =>
|
||||
api.getQueries(pageParam === undefined ? filter : { ...filter, before: pageParam }),
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (last: QueriesPage) => last.next_before ?? undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
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: queryKeys.lookupAll }),
|
||||
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: queryKeys.lookupAll });
|
||||
},
|
||||
});
|
||||
|
||||
function invalidateBlocklistWorld(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.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 refresh store; counters refresh. */
|
||||
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
|
||||
mutationFn: () => api.updateBlocklistsNow(),
|
||||
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
|
||||
setRefreshStatus(sources);
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
function invalidateRules(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.rules }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
]);
|
||||
}
|
||||
|
||||
export const ruleCreateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (input: RuleInput) => api.createRule(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: queryKeys.lookupAll }),
|
||||
]);
|
||||
}
|
||||
|
||||
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: queryKeys.lookupAll }),
|
||||
]);
|
||||
}
|
||||
|
||||
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 {
|
||||
upstream: { attempt_timeout_ms: 2500, 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,
|
||||
trusted_proxies: "",
|
||||
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,39 @@
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
|
||||
const SECTIONS = [
|
||||
"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,429 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* Every non-2xx JSON response: `http_util.respondError` writes this one field
|
||||
* and nothing else. `ApiError.message` in api.ts reads `error` out of it.
|
||||
*/
|
||||
export interface ErrorEnvelope {
|
||||
error: string;
|
||||
}
|
||||
|
||||
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;
|
||||
exception_count: number;
|
||||
skipped_regex_count: number;
|
||||
skipped_unsupported_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;
|
||||
exceptions: number;
|
||||
skipped_regex: number;
|
||||
skipped_unsupported: number;
|
||||
}
|
||||
|
||||
export type RuleKind = "exact" | "wildcard" | "regex";
|
||||
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;
|
||||
/** Learned over reverse DNS. `name` wins whenever it is non-empty. */
|
||||
learned_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 {
|
||||
upstream: {
|
||||
attempt_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;
|
||||
/** Comma-separated IP literals; empty trusts no proxy's X-Forwarded-For. */
|
||||
trusted_proxies: string;
|
||||
/** 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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which configuration source the running process obeys. `path` and
|
||||
* `reconciled_at` are non-null only under `managed_file`: the file the process
|
||||
* loaded, and the epoch second at which it loaded it. Authority lives in the
|
||||
* invocation, never in the database, so this is the only place the UI can read
|
||||
* it — and it rides an authenticated route, never the open ones.
|
||||
*/
|
||||
export interface Authority {
|
||||
mode: "database" | "managed_file";
|
||||
path: string | null;
|
||||
reconciled_at: number | null;
|
||||
}
|
||||
|
||||
export interface SettingsEnvelope {
|
||||
settings: Settings;
|
||||
restart_required: string[];
|
||||
authority: Authority;
|
||||
}
|
||||
|
||||
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 {
|
||||
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