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
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:
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user