milestone 9: react spa admin ui, frontend ci and embedded dist

This commit is contained in:
2026-08-02 13:04:09 +02:00
parent 5253c47303
commit 617cc966a2
82 changed files with 11833 additions and 17 deletions
+279
View File
@@ -0,0 +1,279 @@
import { queryOptions, type QueryClient } from "@tanstack/react-query";
import * as api from "@/lib/api";
import type {
BlocklistInput,
ClientEdit,
ClientPrefixInput,
ForwardZoneInput,
GroupInput,
LocalRecordInput,
PausePost,
Period,
QueriesFilter,
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,
queries: (filter: QueriesFilter) => ["queries", filter] as const,
upstreamHealth: ["upstream-health"] as const,
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
groups: ["groups"] as const,
groupSources: (id: number) => ["groups", id, "sources"] as const,
blocklists: ["blocklists"] as const,
/** Fed only by POST /api/blocklists/update's 202 snapshot; no GET exists. */
blocklistSources: ["blocklists", "sources"] 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,
});
export const queriesQuery = (filter: QueriesFilter = {}) =>
queryOptions({ queryKey: queryKeys.queries(filter), queryFn: () => api.getQueries(filter) });
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: ["lookup"] }),
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: ["lookup"] });
},
});
function invalidateBlocklistWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
qc.invalidateQueries({ queryKey: ["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 sources cache; counters refresh. */
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
mutationFn: () => api.updateBlocklistsNow(),
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
qc.setQueryData(queryKeys.blocklistSources, sources);
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
]);
},
});
function invalidateRules(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.rules }),
qc.invalidateQueries({ queryKey: ["lookup"] }),
]);
}
export const ruleCreateMutation = (qc: QueryClient) => ({
mutationFn: (input: RuleInput) => api.createRule(input),
onSuccess: () => invalidateRules(qc),
});
export const ruleUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, input }: { id: number; input: RuleInput }) => api.updateRule(id, 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: ["lookup"] }),
]);
}
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: ["lookup"] }),
]);
}
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 });
},
});