Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
438 lines
16 KiB
TypeScript
438 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,
|
|
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 (
|
|
<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. 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<unknown>) => 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<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,
|
|
defaultPendingComponent: RoutePending,
|
|
defaultErrorComponent: RouteError,
|
|
});
|
|
}
|
|
|
|
declare module "@tanstack/react-router" {
|
|
interface Register {
|
|
router: ReturnType<typeof createAppRouter>;
|
|
}
|
|
}
|