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 { 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 { 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 { 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 { 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 { 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>) => { 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 { 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>) => { qc.setQueryData(queryKeys.settings, envelope); return Promise.all([ qc.invalidateQueries({ queryKey: queryKeys.settings }), qc.invalidateQueries({ queryKey: queryKeys.configStatus }), ]); }, });