Files
nxdns/web/src/lib/queries.ts
T
mokhtar 6c507992e4
CI / test (push) Successful in 1m46s
CI / test-aarch64 (push) Successful in 5m30s
CI / frontend (push) Successful in 46s
CI / cross (push) Successful in 8m12s
CI / docker (push) Successful in 3m46s
milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
2026-08-07 20:39:27 +02:00

287 lines
11 KiB
TypeScript

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<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),
});
/** Ruling 12: the 202 snapshot REPLACES the refresh store; counters refresh. */
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
mutationFn: () => api.updateBlocklistsNow(),
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
setRefreshStatus(sources);
return 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);
},
});
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),
});
export const pauseMutation = (qc: QueryClient) => ({
mutationFn: (body: PausePost) => api.postPause(body),
onSuccess: (state: Awaited<ReturnType<typeof api.postPause>>) => {
qc.setQueryData(queryKeys.pause, state);
},
});
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 });
},
});