milestone 29: activity — history, live and policy simulation on one surface
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s

query log, live and lookup merge into /activity. history filters live
in the url, so a pasted link or back/forward reproduces the exact
view; the result column separates servfail and nxdomain from success
in the list. live is follow-by-default with freeze, and a streamed
row opens its in-memory provenance detail — no correlation invented
for rows sqlite has not written. lookup survives as the current
policy simulation under /activity/test. investigation links carry
absolute bounds, and the diagnostics page now honors since/until
instead of ignoring them. the old routes are gone without aliases.
This commit is contained in:
2026-08-22 10:52:56 +02:00
parent 0fd6bbd312
commit fa323c7ed4
42 changed files with 3225 additions and 1426 deletions
+78 -54
View File
@@ -12,7 +12,14 @@ import {
import AppShell from "@/shell/AppShell";
import { ApiError } from "@/lib/api";
import { createQueryClient } from "@/lib/queryClient";
import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter, QueriesFilter } from "@/lib/types";
import { diagnosticsFilterOf, type DiagnosticsSearch } from "@/features/diagnostics/filter";
import {
queriesFilterOf,
validateActivitySearch,
validateText,
validateTimestamp,
type ActivitySearch,
} from "@/features/activity/search";
import {
blocklistsQuery,
clientPrefixesQuery,
@@ -140,46 +147,71 @@ const dashboardRoute = createRoute({
});
/**
* `domain` and `client` seed the filter form, so a detail page can link to
* "every query for this domain". Anything else in the search object is dropped:
* an unknown value would reach the api as a parameter the handler 400s.
* Activity. The URL is the applied state: mode, the five filters, and nothing
* else. Everything is validated by `activity/search.ts`, so a hand-typed or
* stale parameter becomes `undefined` here rather than reaching the API as a
* value it answers 400 to.
*/
const queriesRoute = createRoute({
const activityRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/queries",
validateSearch: (search: Record<string, unknown>): { domain?: string; client?: string } => {
const domain = search["domain"];
const client = search["client"];
return {
domain: typeof domain === "string" && domain !== "" ? domain : undefined,
client: typeof client === "string" && client !== "" ? client : undefined,
};
},
loaderDeps: ({ search }) => search,
path: "/activity",
validateSearch: validateActivitySearch,
// An explicit projection, not `search` itself: the router hands the loader
// whatever else the URL carried, and an unknown key would make two
// otherwise-identical loads look like different deps.
loaderDeps: ({ search }): ActivitySearch => ({
mode: search.mode,
since: search.since,
until: search.until,
domain: search.domain,
client: search.client,
blocked: search.blocked,
}),
/**
* Starts the first page in parallel with the component chunk, and does not
* wait for it. Awaiting would make every Apply a blocking navigation, which
* throws away the `keepPreviousData` placeholder the list is built on: the
* reader would lose the rows they were reading to a pending page instead of
* watching them be replaced. The page owns the loading and error surfaces,
* so the rejection is caught here only to keep it from going unhandled.
*
* Live mode reads the SSE stream and nothing else. Prefetching the log for
* it would spend a request per navigation on rows the page never renders,
* with the retained filters attached to make it look deliberate.
*/
loader: ({ context, deps }) => {
const filter: QueriesFilter = {};
if (deps.domain !== undefined) filter.domain = deps.domain;
if (deps.client !== undefined) filter.client = deps.client;
return context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(filter));
if (deps.mode !== "history") return;
void context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(queriesFilterOf(deps))).catch(() => {});
},
component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")),
component: lazyRouteComponent(() => import("@/features/activity/ActivityPage")),
});
const queryDetailRoute = createRoute({
/**
* One logged query. Its search is the Activity search the reader arrived from,
* validated by the same functions, so the back link and every related action
* restore the exact investigation instead of a default view of it.
*/
const activityDetailRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/queries/$id",
path: "/activity/queries/$id",
validateSearch: validateActivitySearch,
// Swallowed on purpose, as the diagnostics detail route does: a row
// retention has pruned is a 404 the page explains, with the way back to the
// log. The whole-page error component would call it a request failure.
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(queryDetailQuery(Number(params.id))).catch(() => undefined),
component: lazyRouteComponent(() => import("@/features/queries/QueryDetailPage")),
component: lazyRouteComponent(() => import("@/features/activity/ActivityDetailPage")),
});
const liveRoute = createRoute({
/** `domain` prefills and runs the simulation, so a query detail can link into it. */
const activityTestRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/live",
component: lazyRouteComponent(() => import("@/features/live/LiveLogPage")),
path: "/activity/test",
validateSearch: (search: Record<string, unknown>): { domain?: string } => ({
domain: validateText(search["domain"]),
}),
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
component: lazyRouteComponent(() => import("@/features/activity/PolicyTestPage")),
});
const clientsRoute = createRoute({
@@ -241,45 +273,38 @@ const upstreamsRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/upstreams/UpstreamsPage")),
});
/** `domain` prefills and runs the lookup, so a query detail page can link into it. */
const lookupRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/lookup",
validateSearch: (search: Record<string, unknown>): { domain?: string } => {
const domain = search["domain"];
return { domain: typeof domain === "string" && domain !== "" ? domain : undefined };
},
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
component: lazyRouteComponent(() => import("@/features/lookup/LookupPage")),
});
/**
* The three filters live in the url so an episode can be linked to as it was
* read. Anything else in the search object is dropped: an unknown value would
* reach the api as a query parameter the handler rejects with a 400.
* The filters and the window live in the url so an episode can be linked to as
* it was read — a query detail links here with an absolute five-minute window
* around one query, which only means anything if the page applies it. Anything
* else in the search object is dropped: an unknown value would reach the api as
* a query parameter the handler rejects with a 400.
*/
const diagnosticsRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/diagnostics",
validateSearch: (
search: Record<string, unknown>,
): { state?: DiagnosticState; severity?: DiagnosticSeverity; component?: string } => {
validateSearch: (search: Record<string, unknown>): DiagnosticsSearch => {
const state = search["state"];
const severity = search["severity"];
const component = search["component"];
return {
state: state === "active" || state === "resolved" ? state : undefined,
severity: severity === "warning" || severity === "error" ? severity : undefined,
component: typeof component === "string" && component !== "" ? component : undefined,
component: validateText(search["component"]),
since: validateTimestamp(search["since"]),
until: validateTimestamp(search["until"]),
};
},
loaderDeps: ({ search }) => search,
loaderDeps: ({ search }): DiagnosticsSearch => ({
state: search.state,
severity: search.severity,
component: search.component,
since: search.since,
until: search.until,
}),
// allSettled: the two sections render their own state, and the resolved
// history failing must not replace the active list with the error page.
loader: ({ context, deps }) => {
const base: DiagnosticsFilter = {};
if (deps.severity !== undefined) base.severity = deps.severity;
if (deps.component !== undefined) base.component = deps.component;
const base = diagnosticsFilterOf(deps);
return Promise.allSettled([
context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "active" })),
context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "resolved" })),
@@ -310,16 +335,15 @@ const routeTree = rootRoute.addChildren([
loginRoute,
shellRoute.addChildren([
dashboardRoute,
queriesRoute,
queryDetailRoute,
liveRoute,
activityRoute,
activityDetailRoute,
activityTestRoute,
clientsRoute,
groupsRoute,
blocklistsRoute,
rulesRoute,
localDnsRoute,
upstreamsRoute,
lookupRoute,
diagnosticsRoute,
diagnosticDetailRoute,
settingsRoute,