347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
import { infiniteQueryOptions, keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
|
|
import * as api from "@/lib/api";
|
|
import type {
|
|
BlocklistInput,
|
|
ClientEdit,
|
|
ClientPrefixInput,
|
|
DiagnosticsFilter,
|
|
DiagnosticsPage,
|
|
ForwardZoneInput,
|
|
GroupInput,
|
|
LocalRecordInput,
|
|
PausePost,
|
|
Period,
|
|
QueriesFilter,
|
|
QueriesPage,
|
|
RuleInput,
|
|
SettingsPatch,
|
|
UpstreamInput,
|
|
} from "@/lib/types";
|
|
|
|
export const queryKeys = {
|
|
health: ["health"] as const,
|
|
version: ["version"] as const,
|
|
overview: (period: Period) => ["overview", period] as const,
|
|
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
|
|
queryDetail: (id: number) => ["queries", "detail", id] as const,
|
|
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
|
|
diagnostic: (id: number) => ["diagnostics", "event", id] as const,
|
|
/** Prefix of every diagnostics entry, page and detail alike; the purge target. */
|
|
diagnosticsAll: ["diagnostics"] 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,
|
|
settings: ["settings"] as const,
|
|
configStatus: ["configStatus"] 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 overviewQuery = (period: Period = "24h") =>
|
|
queryOptions({
|
|
queryKey: queryKeys.overview(period),
|
|
queryFn: () => api.getOverview(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 queryDetailQuery = (id: number) =>
|
|
queryOptions({ queryKey: queryKeys.queryDetail(id), queryFn: () => api.getQueryDetail(id) });
|
|
|
|
// Keyset pagination on `next_before`, exactly as the query log pages
|
|
// (handlers/diagnostics.zig copies the /api/queries contract). The active view
|
|
// polls on healthQuery's cadence because an episode opening is the same news a
|
|
// health banner carries; a resolved-history page is settled and does not poll.
|
|
// `enabled` belongs to the factory rather than to a spread at the call site:
|
|
// spreading the options object loses the page-param type, and the Diagnostics
|
|
// page turns one of its two sections off whenever a filter excludes it.
|
|
export const diagnosticsInfiniteQuery = (filter: DiagnosticsFilter = {}, enabled = true) =>
|
|
infiniteQueryOptions({
|
|
enabled,
|
|
queryKey: queryKeys.diagnosticsInfinite(filter),
|
|
queryFn: ({ pageParam }: { pageParam: number | undefined }) =>
|
|
api.getDiagnostics(pageParam === undefined ? filter : { ...filter, before: pageParam }),
|
|
initialPageParam: undefined as number | undefined,
|
|
getNextPageParam: (last: DiagnosticsPage) => last.next_before ?? undefined,
|
|
placeholderData: keepPreviousData,
|
|
refetchInterval: filter.state === "active" ? 10_000 : undefined,
|
|
});
|
|
|
|
export const diagnosticQuery = (id: number) =>
|
|
queryOptions({ queryKey: queryKeys.diagnostic(id), queryFn: () => api.getDiagnostic(id) });
|
|
|
|
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 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.
|
|
|
|
// Both purges invalidate the whole `diagnostics` prefix rather than one page
|
|
// key: the resolved list, the active list (whose `active` counts ride along) and
|
|
// the detail query of the row just deleted all describe the table that changed.
|
|
export const diagnosticPurgeMutation = (qc: QueryClient) => ({
|
|
mutationFn: (id: number) => api.purgeDiagnostic(id),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.diagnosticsAll }),
|
|
});
|
|
|
|
export const diagnosticsPurgeResolvedMutation = (qc: QueryClient) => ({
|
|
mutationFn: () => api.purgeResolvedDiagnostics(),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.diagnosticsAll }),
|
|
});
|
|
|
|
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),
|
|
});
|
|
|
|
// 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: () =>
|
|
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);
|
|
},
|
|
});
|
|
|
|
// An upstream write rebuilds the pool in the running process, so the row list
|
|
// is the only thing it changes: no restart is owed and /api/config/status says
|
|
// the same thing after the write as before it.
|
|
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),
|
|
});
|
|
|
|
// Protection is a health condition, and health is the only thing that reads it:
|
|
// the sidebar control, the Diagnostics health strip and the related action on a
|
|
// blocked query all render `Health.protection`. Without this invalidation they
|
|
// would contradict a successful mutation until the next ten-second poll.
|
|
export const pauseMutation = (qc: QueryClient) => ({
|
|
mutationFn: (body: PausePost) => api.postPause(body),
|
|
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 Promise.all([
|
|
qc.invalidateQueries({ queryKey: queryKeys.settings }),
|
|
qc.invalidateQueries({ queryKey: queryKeys.configStatus }),
|
|
]);
|
|
},
|
|
});
|