The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip beside the point, a stacked client chart in eight distinct hues plus one Other band, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. All numbers go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else. Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and description, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), and the accent darkened to 5.43:1 on its wash. Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex could not review sessions B to D, its endpoint returned 404 for every call on 2026-09-03; the owner accepted the smoke-server screenshots.
198 lines
5.7 KiB
TypeScript
198 lines
5.7 KiB
TypeScript
/**
|
|
* The part of Overview that does not wait for anything: the heading, the
|
|
* toolbar with the device and period selectors, and the pulsing body the page
|
|
* shows while the window is in flight.
|
|
*
|
|
* It lives apart from `OverviewPage` so the route's pending component can render
|
|
* the identical surface while the page chunk loads. Importing the page itself
|
|
* would pull the charts into the main bundle, and a second hand-written copy of
|
|
* the frame would drift. Nothing here imports a chart.
|
|
*/
|
|
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
|
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
|
import { clientsQuery } from "@/lib/queries";
|
|
import type { Period } from "@/lib/types";
|
|
import Select, { type SelectOption } from "@/ui/Select";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
import { DEFAULT_PERIOD, PERIODS } from "./period";
|
|
|
|
const NARROW = "@media (max-width: 800px)";
|
|
|
|
const PERIOD_LABELS: Record<Period, string> = {
|
|
"1h": "Last hour",
|
|
"24h": "Last 24 hours",
|
|
"7d": "Last 7 days",
|
|
"30d": "Last 30 days",
|
|
};
|
|
|
|
const PERIOD_OPTIONS: SelectOption[] = PERIODS.map((period) => ({ value: period, label: PERIOD_LABELS[period] }));
|
|
|
|
/** The Select's key for the whole household; a client scope is the address itself. */
|
|
const ALL_DEVICES = "";
|
|
|
|
const styles = stylex.create({
|
|
page: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "1.25rem",
|
|
},
|
|
headingRow: {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: "0.75rem",
|
|
},
|
|
heading: {
|
|
margin: 0,
|
|
fontSize: "1.5rem",
|
|
lineHeight: "2rem",
|
|
fontWeight: 650,
|
|
letterSpacing: "-0.015em",
|
|
textWrap: "balance",
|
|
},
|
|
/** Device on the left, period on the right; on a phone the pair takes the whole row. */
|
|
toolbar: {
|
|
display: "flex",
|
|
gap: "0.5rem",
|
|
flexBasis: { default: null, [NARROW]: "100%" },
|
|
},
|
|
control: {
|
|
minWidth: { default: "11rem", [NARROW]: 0 },
|
|
flex: { default: null, [NARROW]: 1 },
|
|
},
|
|
/**
|
|
* The height approximates the filled overview — stat tiles, two 240px charts
|
|
* and the row of cards under them — so that the page does not jump when the
|
|
* window lands. That is where the number comes from.
|
|
*/
|
|
loading: {
|
|
minHeight: "48rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
});
|
|
|
|
export interface OverviewScope {
|
|
period: Period;
|
|
client: string | undefined;
|
|
}
|
|
|
|
/**
|
|
* The devices the reader can scope to: the whole household first, then every
|
|
* registered client under its name. A scope the URL carries that the list has
|
|
* never seen is still offered, as its address, so the control shows the scope
|
|
* the page is actually under rather than silently claiming the household.
|
|
*/
|
|
function useDeviceOptions(client: string | undefined): SelectOption[] {
|
|
const clients = useQuery(clientsQuery());
|
|
const names = useClientNames();
|
|
const known = (clients.data ?? []).map((one) => ({
|
|
value: one.ip,
|
|
label: clientLabel(one.ip, names)?.text ?? one.ip,
|
|
}));
|
|
const options = [{ value: ALL_DEVICES, label: "All devices" }, ...known];
|
|
if (client !== undefined && !known.some((option) => option.value === client)) {
|
|
options.push({ value: client, label: client });
|
|
}
|
|
return options;
|
|
}
|
|
|
|
export function OverviewToolbar({
|
|
scope,
|
|
onChange,
|
|
}: {
|
|
scope: OverviewScope;
|
|
onChange: (next: OverviewScope) => void;
|
|
}) {
|
|
const devices = useDeviceOptions(scope.client);
|
|
return (
|
|
<div {...stylex.props(styles.toolbar)}>
|
|
<div {...stylex.props(styles.control)}>
|
|
<Select
|
|
aria-label="Device"
|
|
variant="toolbar"
|
|
options={devices}
|
|
value={scope.client ?? ALL_DEVICES}
|
|
onChange={(value) => onChange({ ...scope, client: value === ALL_DEVICES ? undefined : value })}
|
|
/>
|
|
</div>
|
|
<div {...stylex.props(styles.control)}>
|
|
<Select
|
|
aria-label="Period"
|
|
variant="toolbar"
|
|
options={PERIOD_OPTIONS}
|
|
value={scope.period}
|
|
onChange={(value) => onChange({ ...scope, period: value as Period })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function OverviewLoading() {
|
|
return (
|
|
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
|
|
Loading…
|
|
</p>
|
|
);
|
|
}
|
|
|
|
export function OverviewFrame({
|
|
scope,
|
|
onChange,
|
|
children,
|
|
}: {
|
|
scope: OverviewScope;
|
|
onChange: (next: OverviewScope) => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div {...stylex.props(styles.page)}>
|
|
<div {...stylex.props(styles.headingRow)}>
|
|
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
|
<OverviewToolbar scope={scope} onChange={onChange} />
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The scope the URL names, and the navigation that rewrites it. The default
|
|
* period and the household scope are the absence of a parameter, so a link to
|
|
* the plain page stays `/overview` rather than growing `?period=24h`.
|
|
*/
|
|
export function useOverviewScope(): [OverviewScope, (next: OverviewScope) => void] {
|
|
const search = useSearch({ from: "/shell/overview" });
|
|
const navigate = useNavigate({ from: "/overview" });
|
|
const scope = { period: search.period ?? DEFAULT_PERIOD, client: search.client };
|
|
const setScope = (next: OverviewScope) =>
|
|
void navigate({
|
|
search: (prev) => ({
|
|
...prev,
|
|
period: next.period === DEFAULT_PERIOD ? undefined : next.period,
|
|
client: next.client,
|
|
}),
|
|
});
|
|
return [scope, setScope];
|
|
}
|
|
|
|
/**
|
|
* The route's pending surface. The toolbar stays live because it only writes
|
|
* the search parameters, which the route already re-reads on its own.
|
|
*/
|
|
export function OverviewPending() {
|
|
const [scope, setScope] = useOverviewScope();
|
|
return (
|
|
<OverviewFrame scope={scope} onChange={setScope}>
|
|
<OverviewLoading />
|
|
</OverviewFrame>
|
|
);
|
|
}
|