Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s
The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only. GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry. All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else. Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash. Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round. Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
import type {
|
|
Blocklist,
|
|
BlocklistEcho,
|
|
BlocklistInput,
|
|
CertsReload,
|
|
Client,
|
|
ClientEdit,
|
|
ClientPrefix,
|
|
ClientPrefixInput,
|
|
ConfigStatus,
|
|
DiagnosticEvent,
|
|
DiagnosticsFilter,
|
|
DiagnosticsPage,
|
|
DiagnosticsPurge,
|
|
ForwardZone,
|
|
ForwardZoneInput,
|
|
Group,
|
|
GroupInput,
|
|
Health,
|
|
LocalRecord,
|
|
LocalRecordInput,
|
|
LoginRequest,
|
|
LoginResponse,
|
|
LogoutResponse,
|
|
LookupResult,
|
|
Overview,
|
|
PausePost,
|
|
PauseState,
|
|
Period,
|
|
QueriesFilter,
|
|
QueriesPage,
|
|
QueryDetail,
|
|
Rule,
|
|
RuleEcho,
|
|
RuleInput,
|
|
SettingsEnvelope,
|
|
SettingsPatch,
|
|
SourceStatus,
|
|
Upstream,
|
|
UpstreamEcho,
|
|
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 + overview
|
|
|
|
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
|
|
request(`/api/queries${qs({ ...filter })}`);
|
|
|
|
/** Full provenance of one logged row. 404 when retention has removed it, 503 with no query log. */
|
|
export const getQueryDetail = (id: number): Promise<QueryDetail> => request(`/api/queries/${id}`);
|
|
|
|
/** `EventSource` URL for the live stream; not a fetch route. */
|
|
export const liveQueriesUrl = "/api/queries/live";
|
|
|
|
/** Every Overview panel for one window, from one read transaction; `client` scopes it to one device. */
|
|
export const getOverview = (period?: Period, client?: string): Promise<Overview> =>
|
|
request(`/api/overview${qs({ period, client })}`);
|
|
|
|
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
|
|
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
|
|
|
|
// Diagnostics
|
|
|
|
export const getDiagnostics = (filter: DiagnosticsFilter = {}): Promise<DiagnosticsPage> =>
|
|
request(`/api/diagnostics${qs({ ...filter })}`);
|
|
|
|
export const getDiagnostic = (id: number): Promise<DiagnosticEvent> => request(`/api/diagnostics/${id}`);
|
|
|
|
/** Purges one resolved event. An event still active answers 409, an unknown id 404. */
|
|
export const purgeDiagnostic = (id: number): Promise<void> => request(`/api/diagnostics/${id}`, { method: "DELETE" });
|
|
|
|
/** Purges the whole resolved history; active events are never touched. */
|
|
export const purgeResolvedDiagnostics = (): Promise<DiagnosticsPurge> =>
|
|
request("/api/diagnostics", { method: "DELETE" });
|
|
|
|
// 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 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
|
|
|
|
/**
|
|
* The pause state on its own. Protection is read from `/api/health` everywhere
|
|
* the interface shows it — one source, one story — so this is left for the live
|
|
* stream's session probe, which wants the cheapest authenticated GET there is.
|
|
*/
|
|
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 });
|
|
|
|
// Configuration authority and restart state
|
|
|
|
/**
|
|
* The one route that answers which source governs the running configuration
|
|
* and whether a restart is owed. Both are per-process facts: no other endpoint
|
|
* carries them, and neither survives a restart.
|
|
*/
|
|
export const getConfigStatus = (): Promise<ConfigStatus> => request("/api/config/status");
|
|
|
|
/** Reloads the DoH/DoT certificates from disk. A runtime action: served in both authority modes. */
|
|
export const reloadCerts = (): Promise<CertsReload> => request("/api/certs/reload", { method: "POST" });
|