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, ClientPrefixInput, ForwardZoneInput, GroupInput, LocalRecordInput, PausePost, Period, QueriesFilter, QueriesPage, RuleInput, SettingsPatch, UpstreamInput, } from "@/lib/types"; export const queryKeys = { health: ["health"] as const, version: ["version"] as const, stats: (period: Period) => ["stats", period] as const, timeseries: (period: Period) => ["stats", "timeseries", period] as const, queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const, upstreamHealth: ["upstream-health"] 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, pause: ["pause"] as const, settings: ["settings"] 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 statsQuery = (period: Period = "24h") => queryOptions({ queryKey: queryKeys.stats(period), queryFn: () => api.getStats(period), refetchInterval: 30_000 }); export const timeseriesQuery = (period: Period = "24h") => queryOptions({ queryKey: queryKeys.timeseries(period), queryFn: () => api.getStatsTimeseries(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 upstreamHealthQuery = () => queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 }); 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 pauseQuery = () => queryOptions({ queryKey: queryKeys.pause, queryFn: api.getPause }); export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings }); // 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. 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), }); /** Ruling 12: the 202 snapshot REPLACES the refresh store; counters refresh. */ export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({ mutationFn: () => api.updateBlocklistsNow(), onSuccess: (sources: Awaited>) => { setRefreshStatus(sources); return 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); }, }); 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), }); export const pauseMutation = (qc: QueryClient) => ({ mutationFn: (body: PausePost) => api.postPause(body), onSuccess: (state: Awaited>) => { qc.setQueryData(queryKeys.pause, state); }, }); export const settingsPutMutation = (qc: QueryClient) => ({ mutationFn: (patch: SettingsPatch) => api.putSettings(patch), onSuccess: (envelope: Awaited>) => { qc.setQueryData(queryKeys.settings, envelope); return qc.invalidateQueries({ queryKey: queryKeys.settings }); }, });