import type { QueryClient } from "@tanstack/react-query"; import * as stylex from "@stylexjs/stylex"; import { createRootRouteWithContext, createRoute, createRouter, lazyRouteComponent, redirect, useRouter, type ErrorComponentProps, type RouterHistory, } from "@tanstack/react-router"; import AppShell from "@/shell/AppShell"; import { ApiError } from "@/lib/api"; import { createQueryClient } from "@/lib/queryClient"; import { diagnosticsFilterOf, type DiagnosticsSearch } from "@/features/diagnostics/filter"; import { queriesFilterOf, validateActivitySearch, validateText, validateTimestamp, type ActivitySearch, } from "@/features/activity/search"; import { blocklistsQuery, clientPrefixesQuery, clientsQuery, configStatusQuery, diagnosticQuery, diagnosticsInfiniteQuery, forwardZonesQuery, groupsQuery, healthQuery, localRecordsQuery, queriesInfiniteQuery, queryDetailQuery, rulesQuery, settingsQuery, statsClientsQuery, statsQuery, statsRoutesQuery, statsTypesQuery, timeseriesQuery, upstreamsQuery, } from "@/lib/queries"; import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period"; import { validateGroupId, validateProtectionSearch, validateResolutionSearch, type ProtectionSearch, type ResolutionSearch, } from "@/features/configuration/search"; import type { Period } from "@/lib/types"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; export interface RouterContext { queryClient: QueryClient; } const styles = stylex.create({ pending: { padding: "2rem", textAlign: "center", color: colors.textMuted, }, errorBox: { margin: "1rem", borderRadius: "0.25rem", borderWidth: 1, borderStyle: "solid", borderColor: colors.dangerBorder, backgroundColor: colors.dangerSurface, padding: "1rem", }, errorTitle: { fontWeight: 600, color: colors.dangerText, }, errorDetail: { marginTop: "0.25rem", fontSize: "0.875rem", lineHeight: "1.25rem", color: colors.dangerText, }, }); function RoutePending() { return (
Loading…
); } function RouteError({ error }: ErrorComponentProps) { const router = useRouter(); let title = "Something went wrong"; let detail = error.message; if (error instanceof ApiError) { if (error.status === 503) { title = "Server starting or degraded"; detail = error.message; } else if (error.status === 429) { title = "Rate limited"; detail = error.retryAfter !== undefined ? `Try again in ${error.retryAfter}s.` : "Try again shortly."; } else if (error.status >= 500) { title = "Internal error"; } else { title = `Request failed (${error.status})`; } } return (

{title}

{detail}

); } const rootRoute = createRootRouteWithContext()(); const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: "/login", validateSearch: (search: Record): { redirect?: string } => ({ redirect: typeof search["redirect"] === "string" ? search["redirect"] : undefined, }), component: lazyRouteComponent(() => import("@/auth/LoginPage")), }); const shellRoute = createRoute({ getParentRoute: () => rootRoute, id: "shell", component: AppShell, }); /** * The landing default, not a compatibility alias: Overview is where the app * opens, and `/` is spelled out rather than left as a second name for it. */ const indexRoute = createRoute({ getParentRoute: () => shellRoute, path: "/", beforeLoad: () => { throw redirect({ to: "/overview" }); }, }); /** * Overview. The period is the whole of its applied state, so a view of the page * is a link: a hand-typed or stale value falls back to the default rather than * reaching the API as a parameter it answers 400 to. */ const overviewRoute = createRoute({ getParentRoute: () => shellRoute, path: "/overview", validateSearch: (search: Record): { period?: Period } => ({ period: parsePeriod(search["period"]), }), loaderDeps: ({ search }): { period: Period } => ({ period: search.period ?? DEFAULT_PERIOD }), /** * Started here, awaited nowhere. Every panel reads these with `useQuery` and * owns its own loading and error surface, so awaiting would trade that whole * contract for one blocking navigation: the page would sit on the slowest of * five requests and then appear complete, instead of the four that answered * rendering beside the one still in flight. The rejections are caught only to * keep them from going unhandled; the panels state them. */ loader: ({ context, deps }) => { const start = (promise: Promise) => void promise.catch(() => {}); start(context.queryClient.ensureQueryData(healthQuery())); start(context.queryClient.ensureQueryData(statsQuery(deps.period))); start(context.queryClient.ensureQueryData(timeseriesQuery(deps.period))); start(context.queryClient.ensureQueryData(statsClientsQuery(deps.period))); // The registered names the client chart labels its series with. Started here // so the lookup is not a second round trip after the page chunk lands. start(context.queryClient.ensureQueryData(clientsQuery())); start(context.queryClient.ensureQueryData(statsTypesQuery(deps.period))); start(context.queryClient.ensureQueryData(statsRoutesQuery(deps.period))); }, component: lazyRouteComponent(() => import("@/features/overview/OverviewPage")), }); /** * 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 activityRoute = createRoute({ getParentRoute: () => shellRoute, 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 }) => { if (deps.mode !== "history") return; void context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(queriesFilterOf(deps))).catch(() => {}); }, component: lazyRouteComponent(() => import("@/features/activity/ActivityPage")), }); /** * 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: "/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/activity/ActivityDetailPage")), }); /** `domain` prefills and runs the simulation, so a query detail can link into it. */ const activityTestRoute = createRoute({ getParentRoute: () => shellRoute, path: "/activity/test", validateSearch: (search: Record): { domain?: string } => ({ domain: validateText(search["domain"]), }), loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()), component: lazyRouteComponent(() => import("@/features/activity/PolicyTestPage")), }); const clientsRoute = createRoute({ getParentRoute: () => shellRoute, path: "/clients", // `group` is the filter Protection's client count links into. Validated here // so a hand-typed id cannot reach the page as anything but a row id. validateSearch: (search: Record): { group?: number } => ({ group: validateGroupId(search["group"]), }), loader: ({ context }) => Promise.all([ context.queryClient.ensureQueryData(clientsQuery()), context.queryClient.ensureQueryData(clientPrefixesQuery()), context.queryClient.ensureQueryData(groupsQuery()), ]), component: lazyRouteComponent(() => import("@/features/clients/ClientsPage")), }); /** * One client, keyed by the row id the client API already identifies it with. * * No endpoint answers for a single client, so the list is the source. It is * awaited, as the other detail routes await theirs: a cold deep link has * nothing to render until it lands, and holding the navigation for one request * beats a page that flashes "no such client" before the rows arrive. The * rejection is swallowed for the same reason theirs are — the page states a * missing row and a failed fetch itself, where the whole-page error component * would call both a request failure. */ const clientDetailRoute = createRoute({ getParentRoute: () => shellRoute, path: "/clients/$id", loader: ({ context }) => context.queryClient.ensureQueryData(clientsQuery()).catch(() => undefined), component: lazyRouteComponent(() => import("@/features/clients/ClientDetailPage")), }); /** * 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): DiagnosticsSearch => { const state = search["state"]; const severity = search["severity"]; return { state: state === "active" || state === "resolved" ? state : undefined, severity: severity === "warning" || severity === "error" ? severity : undefined, component: validateText(search["component"]), since: validateTimestamp(search["since"]), until: validateTimestamp(search["until"]), }; }, loaderDeps: ({ search }): DiagnosticsSearch => ({ state: search.state, severity: search.severity, component: search.component, since: search.since, until: search.until, }), /** * Started, not awaited, as Overview's is: the strip and the two lists each * render their own loading and error state, and the health strip's whole * contract begins with a visible loading state it would never reach if the * route held the page back until the reading arrived. */ loader: ({ context, deps }) => { const base = diagnosticsFilterOf(deps); void context.queryClient.ensureQueryData(healthQuery()).catch(() => {}); for (const state of ["active", "resolved"] as const) { void context.queryClient .ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state })) .catch(() => {}); } }, component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticsPage")), }); const diagnosticDetailRoute = createRoute({ getParentRoute: () => shellRoute, path: "/diagnostics/$id", // Swallowed on purpose: an event retention has removed is a 404 the page // itself explains, with the way back to the list. The whole-page error // component would state it as a request failure instead. loader: ({ context, params }) => context.queryClient.ensureQueryData(diagnosticQuery(Number(params.id))).catch(() => undefined), component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticDetailPage")), }); /** * The three task-shaped configuration pages. There is no `/configuration` * landing route: a bare `/configuration` is not a question anyone has, and a * route that only redirects is a second name for a page. * * Every loader here is started and awaited nowhere, the pattern the rest of the * app uses: each panel owns its loading and error surface, so awaiting would * trade that for one blocking navigation on the slowest request. The rejections * are caught only to keep them from going unhandled. * * `configStatusQuery` starts with all of them. Every configuration page renders * nothing — neither form nor definition list — until it answers, so it is on the * critical path of all three. */ function startConfiguration(queryClient: QueryClient, queries: readonly unknown[]): void { const start = (promise: Promise) => void promise.catch(() => {}); start(queryClient.ensureQueryData(configStatusQuery())); for (const options of queries) { start(queryClient.ensureQueryData(options as Parameters[0])); } } const protectionRoute = createRoute({ getParentRoute: () => shellRoute, path: "/configuration/protection", validateSearch: validateProtectionSearch, loaderDeps: ({ search }): ProtectionSearch => ({ tab: search.tab, group: search.group }), // Clients rides along because the selected group's detail counts them. loader: ({ context }) => startConfiguration(context.queryClient, [groupsQuery(), blocklistsQuery(), rulesQuery(), clientsQuery()]), component: lazyRouteComponent(() => import("@/features/configuration/ProtectionPage")), }); const resolutionRoute = createRoute({ getParentRoute: () => shellRoute, path: "/configuration/resolution", validateSearch: validateResolutionSearch, loaderDeps: ({ search }): ResolutionSearch => ({ tab: search.tab }), loader: ({ context }) => startConfiguration(context.queryClient, [upstreamsQuery(), localRecordsQuery(), forwardZonesQuery()]), component: lazyRouteComponent(() => import("@/features/configuration/ResolutionPage")), }); const systemRoute = createRoute({ getParentRoute: () => shellRoute, path: "/configuration/system", loader: ({ context }) => startConfiguration(context.queryClient, [settingsQuery()]), component: lazyRouteComponent(() => import("@/features/configuration/SystemPage")), }); const routeTree = rootRoute.addChildren([ loginRoute, shellRoute.addChildren([ indexRoute, overviewRoute, activityRoute, activityDetailRoute, activityTestRoute, clientsRoute, clientDetailRoute, diagnosticsRoute, diagnosticDetailRoute, protectionRoute, resolutionRoute, systemRoute, ]), ]); export function createAppRouter(history?: RouterHistory, queryClient: QueryClient = createQueryClient()) { return createRouter({ routeTree, history, context: { queryClient }, defaultPreload: "intent", defaultPreloadStaleTime: 0, defaultPendingComponent: RoutePending, defaultErrorComponent: RouteError, }); } declare module "@tanstack/react-router" { interface Register { router: ReturnType; } }