Files
nxdns/admin/src/routes.tsx
T
mokhtar 6a0630c288
Gates / frontend (push) Successful in 2m6s
Gates / test (push) Successful in 2m57s
Gates / test-aarch64 (push) Successful in 8m31s
Gates / package (push) Successful in 4m19s
Gates / container (push) Failing after 2s
CI / gates (push) Failing after 26m21s
overview: one endpoint, live projections and a response cache (m36)
2026-08-27 17:48:20 +02:00

439 lines
16 KiB
TypeScript

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,
overviewQuery,
queriesInfiniteQuery,
queryDetailQuery,
rulesQuery,
settingsQuery,
upstreamsQuery,
} from "@/lib/queries";
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
import { OverviewPending } from "@/features/overview/OverviewFrame";
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 (
<div {...stylex.props(styles.pending)} role="status">
<span {...stylex.props(shared.pulse)}>Loading</span>
</div>
);
}
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 (
<div role="alert" {...stylex.props(styles.errorBox)}>
<h2 {...stylex.props(styles.errorTitle)}>{title}</h2>
<p {...stylex.props(styles.errorDetail)}>{detail}</p>
<button
type="button"
onClick={() => void router.invalidate()}
{...stylex.props(shared.retryButton, shared.focusRing)}
>
Retry
</button>
</div>
);
}
const rootRoute = createRootRouteWithContext<RouterContext>()();
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/login",
validateSearch: (search: Record<string, unknown>): { 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<string, unknown>): { period?: Period } => ({
period: parsePeriod(search["period"]),
}),
loaderDeps: ({ search }): { period: Period } => ({ period: search.period ?? DEFAULT_PERIOD }),
/**
* Started here, awaited nowhere. The page reads these with `useQuery` and owns
* its own loading and error surface, so awaiting would trade that contract for
* one blocking navigation: nothing at all until the request answered, rather
* than the heading and the period picker while it is in flight. The rejections
* are caught only to keep them from going unhandled; the page states them.
*/
loader: ({ context, deps }) => {
const start = (promise: Promise<unknown>) => void promise.catch(() => {});
start(context.queryClient.ensureQueryData(healthQuery()));
start(context.queryClient.ensureQueryData(overviewQuery(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()));
},
component: lazyRouteComponent(() => import("@/features/overview/OverviewPage")),
/**
* The page's own loading surface, rendered while its chunk is still in flight.
* The default pending component would put a second, differently-placed
* "Loading…" before it, which reads as a stutter rather than one wait.
* `OverviewFrame` is a separate module so this import leaves the charts lazy.
*/
pendingComponent: OverviewPending,
});
/**
* 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<string, unknown>): { 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<string, unknown>): { 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<string, unknown>): 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<unknown>) => void promise.catch(() => {});
start(queryClient.ensureQueryData(configStatusQuery()));
for (const options of queries) {
start(queryClient.ensureQueryData(options as Parameters<QueryClient["ensureQueryData"]>[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,
scrollRestoration: true,
scrollToTopSelectors: ["#main-content"],
defaultPendingComponent: RoutePending,
defaultErrorComponent: RouteError,
});
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}