milestone 32: task-shaped configuration, file mode as a rendering, config status api
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s

This commit is contained in:
2026-08-22 22:42:50 +02:00
parent 025edbb093
commit 7e0df5fd94
89 changed files with 6101 additions and 3750 deletions
+14
View File
@@ -2,10 +2,12 @@ import type {
Blocklist,
BlocklistEcho,
BlocklistInput,
CertsReload,
Client,
ClientEdit,
ClientPrefix,
ClientPrefixInput,
ConfigStatus,
DiagnosticEvent,
DiagnosticsFilter,
DiagnosticsPage,
@@ -249,3 +251,15 @@ export const postPause = (body: PausePost): Promise<PauseState> => request("/api
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" });
+22 -10
View File
@@ -14,8 +14,10 @@
import type {
Blocklist,
BlocklistEcho,
CertsReload,
Client,
ClientPrefix,
ConfigStatus,
DiagnosticEvent,
DiagnosticsPage,
DiagnosticsPurge,
@@ -565,11 +567,6 @@ export const sample_post_pause: PauseState = {
};
export const sample_get_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
@@ -690,11 +687,6 @@ export const sample_get_settings: SettingsEnvelope = {
};
export const sample_put_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
@@ -814,6 +806,26 @@ export const sample_put_settings: SettingsEnvelope = {
},
};
export const sample_get_config_status: ConfigStatus = {
authority: "database",
path: null,
reconciled_at: null,
restart_pending: true,
};
export const sample_reload_certs: CertsReload = {
doh: {
enabled: false,
error: null,
reloaded: false,
},
dot: {
enabled: false,
error: null,
reloaded: false,
},
};
export const sample_error_bad_request: ErrorEnvelope = {
error: "logging.level: not one of the values this setting accepts",
};
+36 -9
View File
@@ -1,6 +1,5 @@
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,
@@ -46,6 +45,7 @@ export const queryKeys = {
clientPrefixes: ["client-prefixes"] as const,
upstreams: ["upstreams"] as const,
settings: ["settings"] as const,
configStatus: ["configStatus"] as const,
};
export const healthQuery = () =>
@@ -150,6 +150,19 @@ export const upstreamsQuery = () => queryOptions({ queryKey: queryKeys.upstreams
export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings });
// Restart truth must not depend on the tab that caused it: a notice raised by
// another tab, another API client or a mutation elsewhere in this one has to
// surface here too, and a process restart has to clear it. Hence never stale,
// always refetched when the window regains focus, and polled once a minute.
export const configStatusQuery = () =>
queryOptions({
queryKey: queryKeys.configStatus,
queryFn: api.getConfigStatus,
staleTime: 0,
refetchOnWindowFocus: "always",
refetchInterval: 60_000,
});
// 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.
@@ -223,16 +236,18 @@ export const blocklistDeleteMutation = (qc: QueryClient) => ({
onSuccess: () => invalidateBlocklistWorld(qc),
});
/** Ruling 12: the 202 snapshot REPLACES the refresh store; counters refresh. */
// A runtime action, not a configuration write: it re-fetches the sources the
// running process already declares, so it works under file authority too. The
// 202 body's per-source snapshot is deliberately dropped — refresh outcomes are
// durable counters on the source rows and episodes in Diagnostics, not an
// ephemeral readout that survives one navigation.
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
mutationFn: () => api.updateBlocklistsNow(),
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
setRefreshStatus(sources);
return Promise.all([
onSuccess: () =>
Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]);
},
]),
});
function invalidateRules(qc: QueryClient): Promise<unknown> {
@@ -313,8 +328,14 @@ export const clientPrefixesPutMutation = (qc: QueryClient) => ({
},
});
// The pool builds its clients at startup, so every upstream write leaves the
// server owing a restart. The flag it sets lives on /api/config/status, and the
// shell notice reads it there — hence the second invalidation.
function invalidateUpstreams(qc: QueryClient): Promise<unknown> {
return qc.invalidateQueries({ queryKey: queryKeys.upstreams });
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.upstreams }),
qc.invalidateQueries({ queryKey: queryKeys.configStatus }),
]);
}
export const upstreamCreateMutation = (qc: QueryClient) => ({
@@ -341,10 +362,16 @@ export const pauseMutation = (qc: QueryClient) => ({
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.health }),
});
// Whether the patch needs a restart is the server's decision (it owns the
// restart-required key set), so the client re-reads the status rather than
// deciding for itself.
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 });
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.settings }),
qc.invalidateQueries({ queryKey: queryKeys.configStatus }),
]);
},
});
+36 -14
View File
@@ -636,23 +636,28 @@ export interface Settings {
};
}
/**
* 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;
}
/**
* Which configuration source the running process obeys, and whether it owes a
* restart. `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.
* Both facts live in the invocation and the process, never in the database, so
* `/api/config/status` is the only place the UI can read them — and it is an
* authenticated route, never one of the open ones.
*
* `restart_pending` is true once the server has committed a change only a
* restart applies (an upstream write, a settings key). Nothing but process
* exit clears it, so a browser reload cannot dismiss it.
*/
export interface ConfigStatus {
authority: "database" | "managed_file";
path: string | null;
reconciled_at: number | null;
restart_pending: boolean;
}
export interface TlsListenerPatch {
@@ -677,3 +682,20 @@ export interface SettingsPatch {
disk?: Partial<Settings["disk"]>;
blocklist_update?: Partial<Settings["blocklist_update"]>;
}
/** One endpoint's outcome from `POST /api/certs/reload`. */
export interface CertReloadOutcome {
enabled: boolean;
reloaded: boolean;
/** Why the reload failed; null on success and while the endpoint is disabled. */
error: string | null;
}
/**
* The reload runs per endpoint and always answers 200: a failed reload is an
* outcome, not an error, and the previous certificate keeps serving.
*/
export interface CertsReload {
doh: CertReloadOutcome;
dot: CertReloadOutcome;
}