milestone 32: task-shaped configuration, file mode as a rendering, config status api

This commit is contained in:
2026-08-22 22:42:50 +02:00
parent c99a37d170
commit 24521ab9a9
89 changed files with 6101 additions and 3750 deletions
+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 }),
]);
},
});