admin: overview redesign, device scope, one formatting contract (milestone 39)

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.
This commit is contained in:
2026-09-03 17:16:24 +02:00
parent e656670dd4
commit 209c1c77a9
60 changed files with 2329 additions and 1009 deletions
@@ -106,7 +106,7 @@ const styles = stylex.create({
segment: {
cursor: "pointer",
borderStyle: "none",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
paddingInline: "0.625rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
@@ -139,7 +139,7 @@ const styles = stylex.create({
color: colors.textSecondary,
},
popover: {
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
+2 -2
View File
@@ -13,7 +13,7 @@ import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
import HistoryActivity from "./HistoryActivity";
import LiveActivity from "./LiveActivity";
@@ -40,7 +40,7 @@ const styles = stylex.create({
switch: {
display: "flex",
gap: "0.25rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
+1 -1
View File
@@ -79,7 +79,7 @@ const styles = stylex.create({
popover: {
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -17,7 +17,7 @@ import type { QueryRow } from "@/lib/types";
import { useClientNames } from "@/features/clients/clientNames";
import { summarizeRow } from "@/features/provenance/querySummary";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
import { queriesFilterOf, type ActivitySearch } from "./search";
@@ -38,7 +38,7 @@ const styles = stylex.create({
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
+4 -4
View File
@@ -19,7 +19,7 @@ import { useClientNames } from "@/features/clients/clientNames";
import { summarizeEvent } from "@/features/provenance/querySummary";
import Dialog from "@/ui/Dialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
import ProvenanceDetail from "./ProvenanceDetail";
import RelatedActions from "./RelatedActions";
@@ -77,7 +77,7 @@ const styles = stylex.create({
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
@@ -106,7 +106,7 @@ const styles = stylex.create({
},
cappedBox: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
@@ -130,7 +130,7 @@ const styles = stylex.create({
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -8,7 +8,7 @@ import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
@@ -64,7 +64,7 @@ const styles = stylex.create({
},
card: {
marginTop: "1.5rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -22,7 +22,7 @@ import {
} from "@/features/provenance/provenanceCopy";
import { qtypeName } from "@/features/provenance/qtype";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
const styles = stylex.create({
heading: {
@@ -40,7 +40,7 @@ const styles = stylex.create({
record: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
+2 -2
View File
@@ -21,7 +21,7 @@ import { rcodeShortName } from "@/features/provenance/provenanceCopy";
import { qtypeName } from "@/features/provenance/qtype";
import type { QuerySummary } from "@/features/provenance/querySummary";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
@@ -93,7 +93,7 @@ const styles = stylex.create({
*/
badge: {
display: "inline-block",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
@@ -8,7 +8,7 @@ import { formatTime } from "@/lib/format";
import { clientsQuery } from "@/lib/queries";
import { useAuthority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { ClientDisplayName, provenanceOf } from "./clientIdentity";
/** The Activity window this page offers: the last day, in seconds. */
@@ -43,12 +43,12 @@ const styles = stylex.create({
panel: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
padding: metrics.cardPadding,
},
facts: {
display: "grid",
@@ -73,9 +73,10 @@ const styles = stylex.create({
},
sectionHeading: {
marginTop: "1.5rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
fontSize: "1.4rem",
lineHeight: 1.2,
fontWeight: 650,
letterSpacing: "-0.015em",
},
prose: {
marginTop: "0.5rem",
@@ -17,7 +17,7 @@ import InlineError from "@/lib/InlineError";
import AuthorityGate from "@/features/configuration/AuthorityGate";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
interface Props {
prefixes: ClientPrefix[];
@@ -77,7 +77,7 @@ const styles = stylex.create({
},
removeButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -1,5 +1,5 @@
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { useAuthority, type Authority } from "./authority";
const styles = stylex.create({
@@ -20,7 +20,7 @@ const styles = stylex.create({
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
borderRadius: "0.25rem",
borderRadius: metrics.radius,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
@@ -13,7 +13,7 @@ import {
import type { Blocklist, BlocklistInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import BlocklistForm from "./BlocklistForm";
@@ -34,7 +34,7 @@ const styles = stylex.create({
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
backgroundColor: colors.border,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
@@ -7,7 +7,7 @@ import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsEnvelope } from "@/lib/types";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { SECTIONS, sectionValues, type AnyFieldDef } from "./settingsSections";
import { styles as config } from "./styles";
@@ -31,7 +31,7 @@ const styles = stylex.create({
padding: 0,
},
section: {
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -71,7 +71,7 @@ const styles = stylex.create({
gap: "0.25rem",
},
fieldInput: {
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -105,7 +105,7 @@ const styles = stylex.create({
save: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
paddingInline: "1rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
+14 -9
View File
@@ -5,7 +5,7 @@
*/
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
export const styles = stylex.create({
heading: {
@@ -20,18 +20,23 @@ export const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** The card chrome (`ui/Card.tsx`), on panels whose heading carries a key or a control the Card head cannot. */
panel: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
backgroundColor: colors.surfaceRaised,
padding: metrics.cardPadding,
},
panelHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
margin: 0,
marginBottom: "1rem",
fontSize: "1.4rem",
lineHeight: 1.2,
fontWeight: 650,
letterSpacing: "-0.015em",
},
/** The collection's own key in the configuration file, beside its heading. */
panelKey: {
@@ -50,7 +55,7 @@ export const styles = stylex.create({
/** The file-mode page note: where edits happen, and what applies them. */
fileNote: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -90,7 +95,7 @@ export const styles = stylex.create({
},
masterLink: {
display: "block",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
@@ -119,7 +124,7 @@ export const styles = stylex.create({
/** The authority error state: no forms, no definition list, one way forward. */
blocked: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.warnBorder,
@@ -8,7 +8,7 @@ import { formatDuration, formatTime } from "@/lib/format";
import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import SeverityBadge from "./SeverityBadge";
import { componentLabel, copyFor } from "./eventCopy";
@@ -49,12 +49,12 @@ const styles = stylex.create({
panel: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
padding: metrics.cardPadding,
},
facts: {
display: "grid",
@@ -75,9 +75,10 @@ const styles = stylex.create({
},
sectionHeading: {
marginTop: "1.5rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
fontSize: "1.4rem",
lineHeight: 1.2,
fontWeight: 650,
letterSpacing: "-0.015em",
},
prose: {
marginTop: "0.5rem",
@@ -89,7 +90,7 @@ const styles = stylex.create({
marginTop: "0.5rem",
maxWidth: "48rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -16,7 +16,7 @@ import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsP
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import HealthStrip from "./HealthStrip";
import SeverityBadge from "./SeverityBadge";
import { diagnosticsFilterOf } from "./filter";
@@ -67,9 +67,10 @@ const styles = stylex.create({
},
sectionHeading: {
marginTop: "1.5rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
fontSize: "1.4rem",
lineHeight: 1.2,
fontWeight: 650,
letterSpacing: "-0.015em",
},
sectionHeadingRow: {
display: "flex",
@@ -116,7 +117,7 @@ const styles = stylex.create({
padding: 0,
},
card: {
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -149,7 +150,7 @@ const styles = stylex.create({
},
rangeNotice: {
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -163,7 +164,7 @@ const styles = stylex.create({
tableWrap: {
marginTop: "0.75rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -26,7 +26,7 @@ import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { healthQuery } from "@/lib/queries";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { healthFacts, type FactLink, type FactTone, type HealthFact } from "./healthFacts";
const DARK = "@media (prefers-color-scheme: dark)";
@@ -53,7 +53,7 @@ const styles = stylex.create({
flexWrap: "wrap",
alignItems: "baseline",
gap: "0.375rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
paddingInline: "0.625rem",
@@ -6,12 +6,12 @@
import * as stylex from "@stylexjs/stylex";
import type { DiagnosticSeverity } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
const styles = stylex.create({
badge: {
display: "inline-block",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
paddingInline: "0.375rem",
@@ -9,7 +9,7 @@
* condition that is not healthy is the thing the eye lands on.
*/
import { formatBytes, formatClock } from "@/lib/format";
import { formatBytes, formatClock, formatCount } from "@/lib/format";
import type { Health } from "@/lib/types";
export type FactTone = "ok" | "notice" | "warn" | "danger";
@@ -33,12 +33,10 @@ export interface HealthFact {
link?: FactLink;
}
const numberFormat = new Intl.NumberFormat();
/** Kept out of the state rule: rows are lost whether or not the box is losing them now. */
function dropText(dropped: number, lastDrop: number | null, locale?: string, timeZone?: string): string | undefined {
if (dropped <= 0) return undefined;
const count = `${numberFormat.format(dropped)} ${dropped === 1 ? "query" : "queries"} dropped`;
const count = `${formatCount(dropped)} ${dropped === 1 ? "query" : "queries"} dropped`;
return lastDrop === null ? count : `${count}, last at ${formatClock(lastDrop, locale, timeZone)}`;
}
+126
View File
@@ -0,0 +1,126 @@
/**
* The cache hit rate as a progress bar (ui-visual-redesign.md): the share of
* the window's queries answered from memory, the bar it fills, and under it the
* three figures that share went with — hits, what went upstream instead, and
* how long an answer took on average.
*/
import * as stylex from "@stylexjs/stylex";
import { formatCount, formatMicros, formatPercent } from "@/lib/format";
import Card from "@/ui/Card";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
body: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
track: {
height: "0.5rem",
borderRadius: "999px",
backgroundColor: colors.chartGreenSurface,
overflow: "hidden",
},
fill: {
display: "block",
height: "100%",
borderRadius: "999px",
backgroundColor: colors.chartGreen,
},
/** Dynamic: the bar's length is the share itself. */
fillWidth: (percent: number) => ({ width: `${percent}%` }),
percent: {
margin: 0,
fontSize: "2.5rem",
lineHeight: 1,
fontWeight: 400,
letterSpacing: "-0.02em",
color: colors.chartGreen,
},
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
split: {
display: "flex",
flexWrap: "wrap",
gap: "1.5rem",
margin: 0,
marginTop: "0.5rem",
paddingTop: "1rem",
borderTopWidth: 1,
borderTopStyle: "solid",
borderTopColor: colors.border,
},
/** Label first in the DOM, figure on top visually. */
splitItem: {
display: "flex",
flexDirection: "column-reverse",
gap: "0.125rem",
},
splitValue: {
margin: 0,
fontSize: "1.125rem",
lineHeight: "1.5rem",
fontWeight: 550,
color: colors.text,
},
splitLabel: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
export interface CacheCardData {
queries: number;
/** Answers served from cache: the window's `cached` buckets summed. */
hits: number;
/** Answers that went to an upstream resolver or a forward zone. */
forwarded: number;
avg_response_time_us: number | null;
}
export default function CacheCard({ data }: { data: CacheCardData }) {
const share = data.queries === 0 ? null : data.hits / data.queries;
const percent = share === null ? "—" : formatPercent(share);
return (
<Card
title="Cache hit rate"
description="Answers served straight from memory, without asking an upstream resolver."
>
<div {...stylex.props(styles.body)}>
<div
role="img"
aria-label={
share === null ? "No queries in this period" : `${percent} of queries served from cache`
}
{...stylex.props(styles.track)}
>
<span {...stylex.props(styles.fill, styles.fillWidth(share === null ? 0 : share * 100))} />
</div>
<p {...stylex.props(styles.percent, shared.tabularNums)}>{percent}</p>
<p {...stylex.props(styles.note)}>of queries answered from cache</p>
<dl {...stylex.props(styles.split)}>
<div {...stylex.props(styles.splitItem)}>
<dt {...stylex.props(styles.splitLabel)}>cache hits</dt>
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>{formatCount(data.hits)}</dd>
</div>
<div {...stylex.props(styles.splitItem)}>
<dt {...stylex.props(styles.splitLabel)}>forwarded upstream</dt>
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>{formatCount(data.forwarded)}</dd>
</div>
<div {...stylex.props(styles.splitItem)}>
<dt {...stylex.props(styles.splitLabel)}>avg response</dt>
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>
{data.avg_response_time_us === null ? "—" : formatMicros(data.avg_response_time_us)}
</dd>
</div>
</dl>
</div>
</Card>
);
}
@@ -3,7 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import { formatTime } from "@/lib/format";
import ClientChart, { type ClientChartData } from "./ClientChart";
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
import { OTHER_KEY, clientSeriesColor, seriesColor } from "./seriesColors";
const SINCE = 1_700_000_000;
const BUCKET = 1800;
@@ -89,13 +89,41 @@ test("the value scale covers the tallest column's total, not its largest series"
expect(labels[labels.length - 1]).toBe("35");
});
test("the series are drawn in the colour of the client's address, and Other in its own", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [5]));
test("the series are coloured by rank, and Other in its own gray", () => {
const { container } = render(
clients(
[
{ client: "192.0.2.30", buckets: [10] },
{ client: "192.0.2.31", buckets: [4] },
],
[5],
),
);
const fills = Array.from(container.querySelectorAll("rect"))
.map((rect) => rect.getAttribute("fill"))
.filter((fill) => fill !== "transparent");
expect(fills).toEqual([seriesColor(clientKey("192.0.2.30")), seriesColor(OTHER_KEY)]);
expect(fills).toEqual([clientSeriesColor(0), clientSeriesColor(1), seriesColor(OTHER_KEY)]);
});
/**
* The blank lines the owner saw across the chart: two touching fills with an
* antialiased seam of ground between them. Every segment that rests on another
* reaches half a unit down into it; the bottom segment stops at the baseline.
*/
test("a segment resting on another overlaps it by half a unit, so no seam can open", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [10]));
const [lower, upper] = Array.from(container.querySelectorAll("rect")).filter(
(rect) => rect.getAttribute("fill") !== "transparent",
);
const lowerTop = Number(lower.getAttribute("y"));
const upperBottom = Number(upper.getAttribute("y")) + Number(upper.getAttribute("height"));
expect(upperBottom - lowerTop).toBeCloseTo(0.5, 6);
// The bottom segment ends exactly on the baseline (plot bottom is 240 - 22).
expect(Number(lower.getAttribute("y")) + Number(lower.getAttribute("height"))).toBeCloseTo(218, 6);
// No segment strokes itself any more: the bleed does the separating work.
expect(lower.getAttribute("stroke")).toBeNull();
});
test("a window with no queries says so instead of drawing an empty grid", () => {
@@ -151,7 +179,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
fireEvent.mouseOver(overlayRects(container)[0]);
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
).toEqual(["35", "10", "20", "5"]);
).toEqual(["20", "10", "5", "35"]);
rerender(
clients(
@@ -165,7 +193,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
).toEqual(["39", "11", "22", "6"]);
).toEqual(["22", "11", "6", "39"]);
});
/**
@@ -212,28 +240,51 @@ test("each bucket gets its own tooltip mount, so each is measured for itself", (
expect(container.querySelector(".visx-tooltip")).not.toBe(first);
});
test("pointing at a bucket names its total and every series, and dims the rest", () => {
test("pointing at a bucket names its busiest clients, then Other, then the total, and dims the rest", () => {
const { container } = render(TWO_BUCKETS);
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
// Busiest first in this bucket, whatever the legend's order.
expect(Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent)).toEqual([
"Queries",
"192.0.2.30",
"192.0.2.31",
"192.0.2.30",
"Other",
"All clients",
]);
expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["35", "10", "20", "5"]);
expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["20", "10", "5", "35"]);
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
expect(swatches[0]).toContain(seriesColor(clientKey("192.0.2.30")));
expect(swatches[0]).toContain(clientSeriesColor(1));
expect(swatches[1]).toContain(clientSeriesColor(0));
expect(swatches[2]).toContain(seriesColor(OTHER_KEY));
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
});
/**
* Eight named rows would be a table. The reader pointing at a spike wants to
* know who made it, so the tooltip stops at the bucket's four busiest clients
* and leaves the legend and the hidden table to name the rest.
*/
test("a bucket's tooltip lists at most four named clients, the quiet ones dropped", () => {
const named = Array.from({ length: 6 }, (_, i) => ({ client: `192.0.2.${40 + i}`, buckets: [i + 1, 0] }));
const { container } = render(clients(named, [0, 0]));
fireEvent.mouseOver(overlayRects(container)[0]);
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
expect(terms.map((term) => term.textContent)).toEqual([
"192.0.2.45",
"192.0.2.44",
"192.0.2.43",
"192.0.2.42",
"All clients",
]);
expect(screen.getByRole("table").querySelectorAll("th[scope=col]")).toHaveLength(7);
});
test("leaving the chart takes the tooltip and the dimming with it", () => {
const { container } = render(TWO_BUCKETS);
@@ -264,7 +315,12 @@ test("a window where Other counted nothing drops it from every surface", () => {
fireEvent.mouseOver(overlayRects(container)[0]);
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "192.0.2.30"]);
expect(terms.map((term) => term.textContent)).toEqual(["192.0.2.30", "All clients"]);
});
test("the hidden table groups its counts like every other figure on the page", () => {
render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0]));
expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy();
});
test("one query outside the named clients is enough to keep Other", () => {
+40 -65
View File
@@ -3,27 +3,28 @@
* series per named client, plus everything outside the top eight as "Other".
*
* The x-axis is derived from this response's own `since` and `bucket_seconds`,
* which the API aligns with the timeseries endpoint's buckets, so the two charts
* stack directly above one another and a spike in one is at the same horizontal
* position in the other. Colour keys on the client string, so a client that
* changes rank between polls keeps its colour.
* which the API aligns with the timeseries buckets, so the two charts stack
* directly above one another and a spike in one is at the same horizontal
* position in the other. Colour goes by rank (`seriesColors.ts`): the busiest
* client wears the first palette hue, and the legend names every band.
*/
import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { BarStack } from "@visx/shape";
import { formatTime } from "@/lib/format";
import { formatCount, formatTime } from "@/lib/format";
import type { OverviewClientSeries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import {
BucketOverlay,
CHART_HEIGHT,
ChartFrame,
ChartLegend,
ChartRoot,
ChartTooltip,
EmptyChart,
HitBands,
STACK_BLEED,
StackSegment,
bandScale,
labelTickValues,
@@ -35,36 +36,10 @@ import {
valueTicks,
type TooltipContent,
} from "./chartKit";
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors";
const styles = stylex.create({
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
swatch: {
display: "inline-block",
width: "0.625rem",
height: "0.625rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the colour the bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
});
/** How many named clients a bucket's tooltip lists before the aggregate and the total. */
const TOOLTIP_CLIENTS = 4;
interface Series {
key: string;
@@ -81,13 +56,12 @@ interface Series {
* client that went quiet is something the reader wants to see.
*/
function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
const named = data.clients.map((client) => ({
const named = data.clients.map((client, rank) => ({
key: clientKey(client.client),
// The name if the client is registered under one, the address otherwise —
// the same precedence and the same lookup the query tables use. The colour
// keys on the address regardless, so naming a client never repaints it.
// the same precedence and the same lookup the query tables use.
label: clientLabel(client.client, names)?.text ?? client.client,
color: seriesColor(clientKey(client.client)),
color: clientSeriesColor(rank),
buckets: client.buckets,
}));
if (data.other.every((count) => count === 0)) return named;
@@ -137,19 +111,29 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
const yScale = valueScale(Math.max(...totals), [plot.bottom, plot.y]);
const yTicks = valueTicks(yScale);
const colorOf = new Map(series.map((one) => [one.key, one.color]));
const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot));
/**
* The bucket's busiest clients, then the aggregate, then the total — eight
* named rows would be a table, and the reader pointing at a spike wants to
* know who made it.
*/
function tooltipOf(index: number): TooltipContent {
const column = columns[index];
const named = series
.filter((one) => one.key !== OTHER_KEY && column[one.key] > 0)
.sort((a, b) => column[b.key] - column[a.key])
.slice(0, TOOLTIP_CLIENTS);
const other = series.find((one) => one.key === OTHER_KEY);
const rows = [...named, ...(other !== undefined && column[OTHER_KEY] > 0 ? [other] : [])].map((one) => ({
key: one.key,
label: one.label,
color: one.color,
value: formatCount(column[one.key]),
}));
return {
title: formatTime(columns[index].ts),
rows: [
{ key: "queries", label: "Queries", value: String(totals[index]) },
...series.map((one) => ({
key: one.key,
label: one.label,
color: one.color,
value: String(columns[index][one.key]),
})),
],
title: formatTime(column.ts),
rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }],
};
}
@@ -187,6 +171,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
>
{stacks.map((stack) => {
const bar = stack.bars[index];
const restsOnAnother = bar.y + bar.height < plot.bottom - STACK_BLEED;
return (
<StackSegment
key={stack.key}
@@ -195,6 +180,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
width={bar.width}
height={bar.height}
fill={bar.color}
bleed={restsOnAnother ? STACK_BLEED : 0}
/>
);
})}
@@ -202,23 +188,12 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
))
}
</BarStack>
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
</svg>
{hovered.index !== null && (
<ChartTooltip
index={hovered.index}
content={tooltipOf(hovered.index)}
left={slotCenter(xScale, timestamps[hovered.index], plot)}
/>
<ChartTooltip index={hovered.index} content={tooltipOf(hovered.index)} left={centers[hovered.index]} />
)}
<ul {...stylex.props(styles.legend)}>
{series.map((one) => (
<li key={one.key} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
{one.label}
</li>
))}
</ul>
<ChartLegend entries={series} />
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>Queries per client per time bucket</caption>
@@ -237,7 +212,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
<tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th>
{series.map((one) => (
<td key={one.key}>{column[one.key]}</td>
<td key={one.key}>{formatCount(column[one.key])}</td>
))}
</tr>
))}
+15 -15
View File
@@ -57,11 +57,11 @@ test("the ring starts at twelve o'clock and runs clockwise in the order given",
const paths = ring(container);
// The small slice is drawn first because it was given first.
expect(paths[0].getAttribute("fill")).toBe("#112233");
expect(paths[0].getAttribute("d")?.startsWith("M0,-90")).toBe(true);
expect(paths[0].getAttribute("d")?.startsWith("M0,-63.5")).toBe(true);
// A quarter turn clockwise from the top is three o'clock, where the second
// slice picks up.
expect(paths[1].getAttribute("fill")).toBe("#445566");
expect(paths[1].getAttribute("d")?.startsWith("M90,0")).toBe(true);
expect(paths[1].getAttribute("d")?.startsWith("M63.5,0")).toBe(true);
});
/**
@@ -76,16 +76,16 @@ test("a single entry is a closed ring, not a zero-length arc", () => {
expect(paths).toHaveLength(1);
const d = paths[0].getAttribute("d") ?? "";
expect(d.match(/A/g)).toHaveLength(4);
// Two half arcs out at the outer radius and two back at the inner one: a
// 180px ring 36px thick.
expect(arcRadii(d)).toEqual([90, 90, 54, 54]);
// Two half arcs out at the outer radius and two back at the inner one: the
// decision record's ring, 127px across and 15px thick.
expect(arcRadii(d)).toEqual([63.5, 63.5, 48.5, 48.5]);
});
test("shares are of the drawn total, in the legend and in the hidden table alike", () => {
draw([slice("a", 3), slice("b", 1)]);
expect(screen.getAllByText("75.0%")).toHaveLength(2);
expect(screen.getAllByText("25.0%")).toHaveLength(2);
expect(screen.getAllByText("75.00%")).toHaveLength(2);
expect(screen.getAllByText("25.00%")).toHaveLength(2);
});
/**
@@ -136,9 +136,9 @@ test("pointing at a slice names it and dims the rest", () => {
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe("A");
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual(["Queries", "Share"]);
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["3", "75.00%"]);
// The same share the legend and the hidden table already print for this slice.
expect(screen.getAllByText("75.0%")).toHaveLength(3);
expect(screen.getAllByText("75.00%")).toHaveLength(3);
expect(paths.map((path) => path.getAttribute("opacity"))).toEqual(["1", "0.55"]);
expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
@@ -146,11 +146,11 @@ test("pointing at a slice names it and dims the rest", () => {
// The tooltip points at the middle of the arc, which the component computes
// from the slice values rather than from the drawn path. Slice A is three
// quarters of the ring, so its midpoint is at 135 degrees, on a circle of
// radius 72 — (140.9, 140.9) from the ring's top-left corner, plus the 8px
// the tooltip stands off by. Nothing else here would catch that arithmetic
// drifting away from the ring the Pie actually draws.
// radius 56 around the box's centre at 76 — (115.6, 115.6) from the ring's
// top-left corner, plus the 8px the tooltip stands off by. Nothing else here
// would catch that arithmetic drifting away from the ring the Pie draws.
const tooltipBox = container.querySelector(".visx-tooltip") as HTMLElement;
expect(tooltipBox.style.transform).toBe("translate(149px, 149px)");
expect(tooltipBox.style.transform).toBe("translate(124px, 124px)");
});
test("leaving the ring takes the tooltip and the dimming with it", () => {
@@ -175,14 +175,14 @@ test("a refresh keeps the hovered slice current and remeasures it", () => {
fireEvent.mouseOver(ring(container)[0]);
const first = container.querySelector(".visx-tooltip");
expect(Array.from(first?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
expect(Array.from(first?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual(["3", "75.00%"]);
rerender(<Donut slices={[slice("a", 3000), slice("b", 1000)]} caption="Queries by DNS type" unit="Queries" />);
const second = container.querySelector(".visx-tooltip");
expect(Array.from(second?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual([
"3,000",
"75.0%",
"75.00%",
]);
expect(second).not.toBe(first);
});
+56 -68
View File
@@ -15,8 +15,9 @@
import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { Pie } from "@visx/shape";
import { formatCount, formatPercent } from "@/lib/format";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { ChartTooltip, useActiveIndex, type TooltipContent } from "./chartKit";
export interface DonutSlice {
@@ -29,38 +30,25 @@ export interface DonutSlice {
color: string;
}
const SIZE = 180;
const THICKNESS = 36;
const OUTER_RADIUS = SIZE / 2;
/** The decision record's ring: a 152px box, a 15px band, the total in the hole. */
const SIZE = 152;
const THICKNESS = 15;
const OUTER_RADIUS = SIZE / 2 - 12.5;
const INNER_RADIUS = OUTER_RADIUS - THICKNESS;
/** The gap `body` puts between the ring and the legend, in pixels: 1.25rem. */
const BODY_GAP = 20;
/** One `legend` row, in pixels: its 1.25rem line height. */
const LEGEND_ROW = 20;
const numberFormat = new Intl.NumberFormat();
/** The width at which the page puts the two donuts side by side, and the page's
* own grid switches on the same query. StyleX will not take it from an import,
* so it is written out in both modules and must be changed in both. */
const TWO_COLUMN = "@media (min-width: 1280px)";
/** The gap `body` puts between the ring and the legend, in pixels: 1.5rem. */
const BODY_GAP = 24;
/** One `legend` row, in pixels: its 1.25rem line height plus the divider's padding. */
const LEGEND_ROW = 30;
const styles = stylex.create({
/**
* The reserve is the ring and a legend, not the ring alone: 180 + 20 + 20 =
* 220px. `body` wraps once the panel is narrower than the ring plus the
* legend's 12rem floor, and below that width the filled panel is the ring, the
* body gap and at least one legend row. Side by side the same 220px holds a
* legend of nine rows, which is more than either breakdown draws — the API
* caps neither, so the ring's own height is not a ceiling.
*/
/** The reserve is the ring and one legend row, so an empty panel stands as tall as a filled one. */
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: SIZE + BODY_GAP + LEGEND_ROW,
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
@@ -68,18 +56,11 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
/**
* Centred while the panels are stacked, left-anchored once they are side by
* side. Stacked, the panel is as wide as the page and a ring pinned to the
* left edge reads as a mistake; in a column it is one of a pair and lines up
* with everything above it.
*/
body: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
gap: "1.25rem",
gap: "1.5rem",
},
/** The tooltip is placed against the ring's own box, so slice coordinates can
* be used unchanged rather than measured against the whole panel. */
@@ -88,28 +69,36 @@ const styles = stylex.create({
flexShrink: 0,
lineHeight: 0,
},
/**
* Capped and left-anchored. Without the cap the row justifies across whatever
* the panel is given — most of a metre of whitespace on a wide monitor — and a
* label stops reading as belonging to the count opposite it.
*/
centerTotal: {
fill: colors.text,
fontSize: "1.25rem",
fontWeight: 650,
letterSpacing: "-0.01em",
},
centerUnit: {
fill: colors.textMuted,
fontSize: "0.75rem",
},
legend: {
flex: 1,
minWidth: "12rem",
maxWidth: "24rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** Rows are divided by hairlines, not by a gap, so the list reads as one table. */
legendItem: {
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
paddingBlock: "0.3125rem",
borderTopWidth: { default: 0, ":not(:first-child)": 1 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
swatch: {
pointerEvents: "none",
@@ -133,20 +122,17 @@ const styles = stylex.create({
lineHeight: "1rem",
color: colors.textMuted,
},
count: {
figures: {
display: "inline-flex",
gap: "0.375rem",
whiteSpace: "nowrap",
color: colors.textSecondary,
},
share: {
minWidth: "3rem",
textAlign: "right",
color: colors.textMuted,
},
});
function sharePercent(share: number): string {
return `${(share * 100).toFixed(1)}%`;
}
/**
* Where a slice's tooltip points: the middle of its arc, in the ring box's own
* coordinates. This restates the `Pie` configuration below — clockwise from
@@ -157,8 +143,8 @@ function sliceAnchor(drawn: DonutSlice[], index: number, total: number): { left:
const middle = (2 * Math.PI * (before + drawn[index].value / 2)) / total;
const radius = (OUTER_RADIUS + INNER_RADIUS) / 2;
return {
left: OUTER_RADIUS + Math.sin(middle) * radius,
top: OUTER_RADIUS - Math.cos(middle) * radius,
left: SIZE / 2 + Math.sin(middle) * radius,
top: SIZE / 2 - Math.cos(middle) * radius,
};
}
@@ -170,7 +156,7 @@ export default function Donut({
slices: DonutSlice[];
/** Names the hidden table, so a screen reader knows which breakdown it is in. */
caption: string;
/** The column header for the counted thing, e.g. "Queries". */
/** The counted thing, e.g. "Queries": the table's column header and, lowercased, the word under the total. */
unit: string;
}) {
// Zero-valued entries have no arc to draw and a legend entry reading 0 is
@@ -186,8 +172,8 @@ export default function Donut({
return {
title: slice.secondary === undefined ? slice.label : `${slice.label} (${slice.secondary})`,
rows: [
{ key: "value", label: unit, color: slice.color, value: numberFormat.format(slice.value) },
{ key: "share", label: "Share", value: sharePercent(slice.value / total) },
{ key: "value", label: unit, color: slice.color, value: formatCount(slice.value) },
{ key: "share", label: "Share", value: formatPercent(slice.value / total) },
],
};
}
@@ -199,8 +185,8 @@ export default function Donut({
return (
<div {...stylex.props(styles.body)}>
<div {...stylex.props(styles.ring)}>
{/* The ring stays out of the accessibility tree even though it is now
a pointer target: the tooltip repeats what the legend beside it
{/* The ring stays out of the accessibility tree even though it is a
pointer target: the tooltip repeats what the legend beside it
already says in text, so nothing here is the only copy. */}
<svg
aria-hidden="true"
@@ -213,7 +199,7 @@ export default function Donut({
{/* Arc paths are generated around the origin, and `Pie`'s own
`top`/`left` group is skipped when it is given a render prop, so
the ring is centred here instead. */}
<Group top={OUTER_RADIUS} left={OUTER_RADIUS}>
<Group top={SIZE / 2} left={SIZE / 2}>
<Pie
data={drawn}
pieValue={(slice) => slice.value}
@@ -230,13 +216,10 @@ export default function Donut({
>
{({ arcs, path }) =>
arcs.map((arc, index) => (
// The stroke is what keeps a shared hue from lying. Colour is a
// pure function of identity, so two neighbouring slices can come
// out the same; outlined in the panel's own colour they still
// read as two shapes rather than merging into one. Attributes
// rather than a class, as the client chart's segments are, so
// the separation is visible to a test and not only to a
// stylesheet.
// The stroke is what keeps a shared hue from lying: the routes
// ring colours by identity, so two neighbouring slices can come
// out the same, and outlined in the panel's own colour they
// still read as two shapes rather than merging into one.
<path
key={arc.data.key}
d={path(arc) ?? ""}
@@ -249,6 +232,12 @@ export default function Donut({
))
}
</Pie>
<text y={-2} textAnchor="middle" {...stylex.props(styles.centerTotal, shared.tabularNums)}>
{formatCount(total)}
</text>
<text y={15} textAnchor="middle" {...stylex.props(styles.centerUnit)}>
{unit.toLowerCase()}
</text>
</Group>
</svg>
{hovered.index !== null && (
@@ -269,11 +258,10 @@ export default function Donut({
<span {...stylex.props(styles.secondary)}>{slice.secondary}</span>
)}
</span>
<span {...stylex.props(styles.count, shared.tabularNums)}>
{numberFormat.format(slice.value)}
</span>
<span {...stylex.props(styles.share, shared.tabularNums)}>
{sharePercent(slice.value / total)}
<span {...stylex.props(styles.figures, shared.tabularNums)}>
<span>{formatCount(slice.value)}</span>
<span aria-hidden="true">·</span>
<span {...stylex.props(styles.share)}>{formatPercent(slice.value / total)}</span>
</span>
</li>
))}
@@ -296,8 +284,8 @@ export default function Donut({
? slice.label
: `${slice.label} (${slice.secondary})`}
</th>
<td>{slice.value}</td>
<td>{sharePercent(slice.value / total)}</td>
<td>{formatCount(slice.value)}</td>
<td>{formatPercent(slice.value / total)}</td>
</tr>
))}
</tbody>
+118 -88
View File
@@ -1,6 +1,7 @@
/**
* The part of Overview that does not wait for anything: the heading, the period
* picker, and the pulsing body the page shows while the window is in flight.
* 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
@@ -9,18 +10,35 @@
*/
import * as stylex from "@stylexjs/stylex";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Radio, RadioGroup } from "react-aria-components";
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, metrics } from "@/ui/tokens.stylex";
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: "1rem",
gap: "1.25rem",
},
headingRow: {
display: "flex",
@@ -30,60 +48,27 @@ const styles = stylex.create({
gap: "0.75rem",
},
heading: {
margin: 0,
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
fontWeight: 650,
letterSpacing: "-0.015em",
textWrap: "balance",
},
periodGroup: {
/** Device on the left, period on the right; on a phone the pair takes the whole row. */
toolbar: {
display: "flex",
gap: "0.25rem",
gap: "0.5rem",
flexBasis: { default: null, [NARROW]: "100%" },
},
control: {
minWidth: { default: "11rem", [NARROW]: 0 },
flex: { default: null, [NARROW]: 1 },
},
/**
* The weight lives here rather than on the selected variant: selection may
* change colour, but a heavier label would re-measure the row and shift every
* option beside it.
*/
period: {
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: metrics.hitTarget,
minWidth: metrics.hitTarget,
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
periodFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
transitionProperty: metrics.transitionProperty,
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
},
/**
* The height approximates the filled overview — stat tiles, a 240px chart and
* a 180px donut with the panel chrome around them — so that the page does not
* jump when the window lands. That is where the number comes from.
* 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",
@@ -93,31 +78,60 @@ const styles = stylex.create({
},
});
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
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 (
<RadioGroup
aria-label="Period"
orientation="horizontal"
value={period}
onChange={(next) => onChange(next as Period)}
className={() => stylex.props(styles.periodGroup).className ?? ""}
>
{PERIODS.map((option) => (
<Radio
key={option}
value={option}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
styles.period,
isSelected ? styles.periodSelected : styles.periodIdle,
isFocusVisible && styles.periodFocusVisible,
).className ?? ""
}
>
{option}
</Radio>
))}
</RadioGroup>
<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>
);
}
@@ -130,19 +144,19 @@ export function OverviewLoading() {
}
export function OverviewFrame({
period,
scope,
onChange,
children,
}: {
period: Period;
onChange: (period: Period) => void;
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>
<PeriodPicker period={period} onChange={onChange} />
<OverviewToolbar scope={scope} onChange={onChange} />
</div>
{children}
</div>
@@ -150,17 +164,33 @@ export function OverviewFrame({
}
/**
* The route's pending surface. The picker stays live because it only writes the
* search parameter, which the route already re-reads on its own.
* 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 period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
const navigate = useNavigate({ from: "/overview" });
const [scope, setScope] = useOverviewScope();
return (
<OverviewFrame
period={period}
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
>
<OverviewFrame scope={scope} onChange={setScope}>
<OverviewLoading />
</OverviewFrame>
);
+137 -42
View File
@@ -14,7 +14,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { clientKey, qtypeKey, seriesColor } from "./seriesColors";
import { clientSeriesColor, typeRampColor } from "./seriesColors";
import { health } from "@/lib/healthFixture";
import type { Health, Overview } from "@/lib/types";
@@ -144,6 +144,26 @@ function panel(name: string): HTMLElement {
return section;
}
/** The toolbar's two selectors, named by their aria-label (RAC folds the value into the name too). */
function periodTrigger(): HTMLElement {
return screen.getByRole("button", { name: /Period/ });
}
function deviceTrigger(): HTMLElement {
return screen.getByRole("button", { name: /Device/ });
}
/** RAC opens a Select from the keyboard as readily as from a pointer. */
function open(trigger: HTMLElement) {
fireEvent.keyDown(trigger, { key: "Enter" });
fireEvent.keyUp(trigger, { key: "Enter" });
}
/** Whether any request so far carried this query string fragment. */
function requested(fragment: string): boolean {
return vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes(fragment));
}
test("the root path lands on Overview rather than aliasing it", async () => {
const router = renderApp("/");
await screen.findByRole("heading", { name: "Overview", level: 1 });
@@ -155,7 +175,7 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
// one panel sharing a hue. The stroke is what stops neighbours from merging
// into one shape, which makes it part of the contract rather than decoration.
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
const arcs = Array.from(panel("Query types").querySelectorAll("svg path"));
@@ -167,16 +187,20 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
}
});
test("the page builds a donut slice's colour from the entry's identity", async () => {
test("the types ring steps the accent's ramp from the busiest type outward", async () => {
// `Donut` renders the colour it is handed and never recomputes one, so the
// mapping from identity to hue is the page's job and is pinned here.
// mapping from rank to ramp step is the page's job and is pinned here.
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
const item = within(panel("Query types")).getAllByText("A")[0].closest("li") as HTMLElement;
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
expect(swatch.getAttribute("style")).toContain(seriesColor(qtypeKey(1)));
const swatchOf = (label: string) => {
const item = within(panel("Query types")).getAllByText(label)[0].closest("li") as HTMLElement;
return (item.querySelector("span[aria-hidden]") as HTMLElement).getAttribute("style");
};
expect(swatchOf("A")).toContain(typeRampColor(0));
expect(swatchOf("AAAA")).toContain(typeRampColor(1));
expect(swatchOf("Unknown")).toContain(typeRampColor(2));
});
test("a request in flight leaves the heading and the picker usable behind one loading surface", async () => {
@@ -189,7 +213,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
expect(periodTrigger().textContent).toContain("Last 24 hours");
// One loading state for the whole page, not one per panel.
const loading = await screen.findByText("Loading…");
expect(loading.getAttribute("role")).toBe("status");
@@ -198,7 +222,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
release();
delayed = null;
await screen.findByText("1,000");
await screen.findAllByText("1,000");
expect(screen.queryByText("Loading…")).toBeNull();
});
@@ -234,9 +258,9 @@ test("a client named only by reverse DNS is named by it too", async () => {
await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2));
});
test("naming a client does not recolour its series", async () => {
test("naming a client does not recolour its series: colour goes by rank", async () => {
// The rename the palette must not notice: the swatch beside "kitchen-pi" is
// the colour of the address it was drawn under, not of the label on screen.
// the busiest client's hue, whatever the label on screen says.
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
@@ -244,7 +268,7 @@ test("naming a client does not recolour its series", async () => {
const item = within(chart).getAllByText("kitchen-pi")[0].closest("li") as HTMLElement;
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
expect(swatch.getAttribute("style")).toContain(seriesColor(clientKey("192.0.2.30")));
expect(swatch.getAttribute("style")).toContain(clientSeriesColor(0));
});
test("the client chart drops Other in a period where it counted nothing", async () => {
@@ -262,14 +286,24 @@ test("the client chart drops Other in a period where it counted nothing", async
expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
});
test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
test("the page is four tiles, two charts, the cache card and two donuts — no status or issues sections", async () => {
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
await screen.findByText("1,000");
await screen.findAllByText("1,000");
for (const name of ["Queries over time", "Client activity over time", "Query types", "Upstream servers"]) {
for (const name of [
"Queries over time",
"Client activity over time",
"Cache hit rate",
"Query types",
"Upstream servers",
]) {
expect(screen.getByRole("heading", { name })).toBeTruthy();
}
// Every card leads with its title and a one-line description under it.
for (const section of screen.getAllByRole("region")) {
expect(section.querySelector("h2 + p")?.textContent).toBeTruthy();
}
// The sections the layout ruling removed, and the widgets the Dashboard lost.
expect(screen.queryByRole("heading", { name: "Current status" })).toBeNull();
expect(screen.queryByRole("heading", { name: "Active issues" })).toBeNull();
@@ -280,11 +314,18 @@ test("the page is four tiles, two charts and two donuts — no status or issues
test("the four tiles report the window, and each links where its number leads", async () => {
renderApp();
const tiles = within((await screen.findByText("1,000")).closest("dl") as HTMLElement);
await screen.findAllByText("1,000");
const tiles = within(screen.getByRole("list", { name: "Totals" }));
expect(tiles.getByText("1,000")).toBeTruthy();
expect(tiles.getByText("250")).toBeTruthy();
expect(tiles.getByText("25.0%")).toBeTruthy();
expect(tiles.getByText("25.00%")).toBeTruthy();
expect(tiles.getByText("7")).toBeTruthy();
expect(tiles.getByText("2.3 ms")).toBeTruthy();
expect(tiles.getAllByRole("listitem").map((item) => item.querySelector("p + p")?.textContent)).toEqual([
"queries",
"blocked queries",
"active clients",
"of queries blocked",
]);
// The bounds are the ones the stats response returned, not ones computed here.
const queries = new URLSearchParams(
@@ -306,9 +347,23 @@ test("the four tiles report the window, and each links where its number leads",
expect(screen.queryByRole("link", { name: /average/i })).toBeNull();
});
test("the cache card reads the hit share off the buckets and the forwarded count off the routes", async () => {
renderApp();
await screen.findAllByText("1,000");
const cache = within(panel("Cache hit rate"));
// 10 cached answers of 1,000 queries; 500 + 100 went to an upstream.
expect(cache.getByText("1.00%")).toBeTruthy();
expect(cache.getByRole("img", { name: "1.00% of queries served from cache" })).toBeTruthy();
expect(cache.getByText("10")).toBeTruthy();
expect(cache.getByText("600")).toBeTruthy();
expect(cache.getByText("2.3 ms")).toBeTruthy();
expect(cache.getByText("of queries answered from cache")).toBeTruthy();
});
test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => {
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
const types = within(panel("Query types"));
expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy();
@@ -326,7 +381,7 @@ test("both donuts name every entry, nulls included, and disambiguate a nameless
test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => {
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
const svg = panel("Query types").querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
@@ -336,47 +391,87 @@ test("the donut ring is decoration; the legend and the hidden table are the acce
test("an empty window says so in every panel instead of drawing nothing", async () => {
renderApp("/overview?period=1h");
await screen.findByText("12");
await screen.findAllByText("12");
// The two donuts and the client chart; the query-volume chart says it too.
expect(screen.getAllByText("No queries in this period.").length).toBe(4);
});
test("a deep link opens on the period it names", async () => {
test("a deep link opens on the period it names, and the picker offers the four in words", async () => {
renderApp("/overview?period=1h");
await screen.findByText("12");
// One radio group named Period, holding the four periods and exactly one
// selection: the segmented picker is a single choice, not four toggles.
const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
"1h",
"24h",
"7d",
"30d",
await screen.findAllByText("12");
expect(periodTrigger().textContent).toContain("Last hour");
open(periodTrigger());
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
"Last hour",
"Last 24 hours",
"Last 7 days",
"Last 30 days",
]);
expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
});
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
const router = renderApp("/overview?period=90d");
await screen.findByText("1,000");
expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
await screen.findAllByText("1,000");
expect(periodTrigger().textContent).toContain("Last 24 hours");
expect(router.state.location.search).toEqual({});
});
test("the picker rescopes every panel and writes the period into the url", async () => {
const router = renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
open(periodTrigger());
fireEvent.click(screen.getByRole("option", { name: "Last hour" }));
await screen.findByText("12");
await screen.findAllByText("12");
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
// No panel is left describing the period the reader left.
expect(screen.queryByText("1,000")).toBeNull();
});
test("a deep link to one device scopes the request, the tiles' links and the picker", async () => {
renderApp("/overview?client=192.0.2.31");
await screen.findAllByText("1,000");
expect(requested("client=192.0.2.31")).toBe(true);
// Not registered, so the picker shows the address rather than claiming the household.
expect(deviceTrigger().textContent).toContain("192.0.2.31");
const queries = new URLSearchParams(
screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
);
expect(queries.get("client")).toBe("192.0.2.31");
});
test("the device picker names the registered clients and writes the choice into the url", async () => {
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
const router = renderApp();
await screen.findAllByText("1,000");
expect(deviceTrigger().textContent).toContain("All devices");
// The clients list has landed once the chart names the client by it.
await waitFor(() => expect(within(panel("Client activity over time")).getAllByText("kitchen-pi")).toHaveLength(2));
open(deviceTrigger());
fireEvent.click(screen.getByRole("option", { name: "kitchen-pi" }));
await waitFor(() => expect(router.state.location.search).toEqual({ client: "192.0.2.30" }));
await waitFor(() => expect(requested("client=192.0.2.30")).toBe(true));
expect(deviceTrigger().textContent).toContain("kitchen-pi");
// Back to the household drops the parameter rather than writing an empty one.
open(deviceTrigger());
fireEvent.click(screen.getByRole("option", { name: "All devices" }));
await waitFor(() => expect(router.state.location.search).toEqual({}));
});
test("a client list in the url is not this page's grammar and is dropped", async () => {
const router = renderApp("/overview?client=192.0.2.30,192.0.2.31");
await screen.findAllByText("1,000");
expect(router.state.location.search).toEqual({});
expect(requested("client=")).toBe(false);
});
test("a failed request is one error for the whole page, stated once and retryable", async () => {
failing = true;
renderApp();
@@ -388,18 +483,18 @@ test("a failed request is one error for the whole page, stated once and retryabl
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
// The heading and the picker survive it, so the reader can rescope or retry.
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
expect(periodTrigger()).toBeTruthy();
expect(screen.queryByText("Something went wrong")).toBeNull();
failing = false;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await screen.findByText("1,000");
await screen.findAllByText("1,000");
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
});
test("an incomplete window states its watermark once for the whole page", async () => {
coverageComplete = false;
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1);
});
+61 -71
View File
@@ -1,12 +1,13 @@
/**
* Overview: what the resolver did over a period the reader chooses, in the
* layout Pi-hole's dashboard established — four totals, two full-width charts,
* two breakdown donuts. Nothing on this page is a current-state readout; the
* five health conditions live on Diagnostics, and protection lives in the
* sidebar beside its control.
* Overview: what the resolver did over a period the reader chooses, for the
* household or for one device, in the layout the decision record settled
* (ui-visual-redesign.md) — four totals, two full-width charts, then the cache
* rate and the two breakdown rings in a row of cards. Nothing on this page is a
* current-state readout; the five health conditions live on Diagnostics, and
* protection lives in the sidebar beside its control.
*
* The period is URL state, so a view is a link: `/overview?period=1h` opens
* exactly what the sender was reading.
* The scope is URL state, so a view is a link: `/overview?period=1h&client=…`
* opens exactly what the sender was reading.
*
* One request feeds every panel (`overviewWindow.ts`), so the page has one
* loading state and one error state rather than six: there is no longer a
@@ -14,29 +15,20 @@
*/
import * as stylex from "@stylexjs/stylex";
import { useNavigate, useSearch } from "@tanstack/react-router";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { qtypeName } from "@/features/provenance/qtype";
import type { Overview, OverviewRouteRow, OverviewTypeRow } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
import Card from "@/ui/Card";
import CacheCard from "./CacheCard";
import ClientChart from "./ClientChart";
import Donut from "./Donut";
import StatTiles from "./StatTiles";
import TimeseriesChart from "./TimeseriesChart";
import type { DonutSlice } from "./Donut";
import { OverviewFrame, OverviewLoading } from "./OverviewFrame";
import { OverviewFrame, OverviewLoading, useOverviewScope } from "./OverviewFrame";
import { useOverviewWindow, type Panel } from "./overviewWindow";
import { DEFAULT_PERIOD } from "./period";
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
/**
* Where the two donuts stop competing for width and sit side by side. `Donut`
* carries the same query for the alignment it switches at that width; StyleX
* requires the string to be a literal in the module that uses it, so the two
* agree by inspection rather than by sharing a constant.
*/
const TWO_COLUMN = "@media (min-width: 1280px)";
import { qtypeKey, routeKey, seriesColor, typeRampColor } from "./seriesColors";
const ROUTE_LABELS = {
blocked: "Blocked",
@@ -48,31 +40,17 @@ const ROUTE_LABELS = {
} as const;
const styles = stylex.create({
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
donutRow: {
/** As many cards per row as fit at 20rem each: three on a desktop, one on a phone. */
cards: {
display: "grid",
gap: "1rem",
gridTemplateColumns: { default: "minmax(0, 1fr)", [TWO_COLUMN]: "repeat(2, minmax(0, 1fr))" },
gap: "1.25rem",
gridTemplateColumns: "repeat(auto-fit, minmax(20rem, 1fr))",
},
});
/**
* The page's three states. The heading and the period picker stay put through
* all three, so the reader can rescope or retry without waiting for anything.
* The page's three states. The heading and the toolbar stay put through all
* three, so the reader can rescope or retry without waiting for anything.
*/
function PageBody({ panel, children }: { panel: Panel<Overview>; children: (data: Overview) => React.ReactNode }) {
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
@@ -80,12 +58,13 @@ function PageBody({ panel, children }: { panel: Panel<Overview>; children: (data
return <>{children(panel.data)}</>;
}
/** The API ranks the types by count, so the ramp's darkest step is the busiest type. */
function typeSlices(types: OverviewTypeRow[]): DonutSlice[] {
return types.map((row) => ({
return types.map((row, rank) => ({
key: qtypeKey(row.qtype),
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
value: row.count,
color: seriesColor(qtypeKey(row.qtype)),
color: typeRampColor(rank),
}));
}
@@ -108,56 +87,67 @@ function routeSlices(routes: OverviewRouteRow[]): DonutSlice[] {
});
}
/** What the cache card reads: hits are the buckets' cached counts, forwarded is every upstream or zone answer. */
function cacheOf(data: Overview) {
return {
queries: data.totals.queries,
hits: data.buckets.reduce((sum, bucket) => sum + bucket.cached, 0),
forwarded: data.routes
.filter((row) => row.route === "upstream" || row.route === "forward_zone")
.reduce((sum, row) => sum + row.count, 0),
avg_response_time_us: data.totals.avg_response_time_us,
};
}
export default function OverviewPage() {
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
const navigate = useNavigate({ from: "/overview" });
const overview = useOverviewWindow(period);
const [scope, setScope] = useOverviewScope();
const overview = useOverviewWindow(scope.period, scope.client);
return (
<OverviewFrame
period={period}
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
>
<OverviewFrame scope={scope} onChange={setScope}>
<PageBody panel={overview}>
{(data) => (
<>
<StatTiles stats={{ since: data.since, until: data.until, ...data.totals }} />
<StatTiles
stats={{ since: data.since, until: data.until, client: scope.client, ...data.totals }}
/>
{/* One notice for the page: every panel came out of this one
response, so a second copy would only repeat this sentence. */}
<CoverageNotice coverage={data.coverage} />
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
Queries over time
</h2>
<Card
title="Queries over time"
description="Every query the resolver answered in this period, with the blocked share along the bottom."
>
<TimeseriesChart data={data} />
</section>
</Card>
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
Client activity over time
</h2>
<Card
title="Client activity over time"
description="Which devices made the queries, stacked per bucket."
>
<ClientChart data={data} />
</section>
</Card>
<div {...stylex.props(styles.donutRow)}>
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
Query types
</h2>
<div {...stylex.props(styles.cards)}>
<CacheCard data={cacheOf(data)} />
<Card
title="Query types"
description="The record types clients asked for across this period."
>
<Donut slices={typeSlices(data.types)} caption="Queries by DNS type" unit="Queries" />
</section>
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
Upstream servers
</h2>
</Card>
<Card
title="Upstream servers"
description="How each query was answered: by which resolver, from cache, or not at all."
>
<Donut
slices={routeSlices(data.routes)}
caption="Queries by how they were answered"
unit="Queries"
/>
</section>
</Card>
</div>
</>
)}
+107 -100
View File
@@ -1,110 +1,128 @@
/**
* The window's four headline numbers, each with the way into the rows behind it.
* The window's four headline numbers (ui-visual-redesign.md): centred 2.5rem
* numerals over lowercase captions, each with the way into the rows behind it.
* Colour is semantic and nothing else — the blocked count is red, the share is
* muted, the rest is ink — so a tile never implies a state it is not reporting.
*
* Neutral chrome throughout: no coloured accents, no per-tile tone. Emphasis is
* typographic, so the eye ranks the figures rather than the panels, and a tile
* never implies a state it is not reporting.
* On a phone the four tiles merge into one card: the three counts side by side
* and the share on a line under them, so the set fits above the fold.
*
* The Activity links carry the bounds the **overview response** returned, not
* bounds computed here — a client-computed window would send the reader to a
* slightly different span than the one they were just reading.
* slightly different span than the one they were just reading — and the client
* scope the page is under, so the rows they open are the rows the tile counted.
*/
import * as stylex from "@stylexjs/stylex";
import { Link } from "@tanstack/react-router";
import { formatMicros } from "@/lib/format";
import { formatCount, formatPercent } from "@/lib/format";
import type { OverviewTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const NARROW = "@media (max-width: 800px)";
const styles = stylex.create({
/** Two columns on a phone, the whole set of four in one row from `md`. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(4, minmax(0, 1fr))",
},
gap: { default: "1rem", [NARROW]: 0 },
gridTemplateColumns: { default: "repeat(4, minmax(0, 1fr))", [NARROW]: "repeat(3, minmax(0, 1fr))" },
margin: 0,
padding: 0,
listStyleType: "none",
borderRadius: { default: null, [NARROW]: metrics.radius },
borderWidth: { default: 0, [NARROW]: 1 },
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: { default: null, [NARROW]: colors.surfaceRaised },
paddingInline: { default: 0, [NARROW]: "0.5rem" },
paddingBlock: { default: 0, [NARROW]: "1rem" },
},
tile: {
display: "flex",
flexDirection: "column",
gap: "0.125rem",
borderRadius: "0.25rem",
borderWidth: 1,
alignItems: "center",
gap: "0.375rem",
minWidth: 0,
borderRadius: metrics.radius,
borderWidth: { default: 1, [NARROW]: 0 },
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
paddingInline: { default: "1rem", [NARROW]: "0.25rem" },
paddingBlock: { default: "1.5rem", [NARROW]: 0 },
textAlign: "center",
},
label: {
/** The share drops under the three counts on a phone, divided from them by a hairline. */
shareTile: {
gridColumn: { default: null, [NARROW]: "1 / -1" },
marginTop: { default: 0, [NARROW]: "1rem" },
paddingTop: { default: null, [NARROW]: "1rem" },
borderTopWidth: { default: null, [NARROW]: 1 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
value: {
margin: 0,
fontSize: { default: "2.5rem", [NARROW]: "1.75rem" },
lineHeight: 1,
fontWeight: 400,
letterSpacing: "-0.02em",
color: colors.text,
},
valueBlocked: { color: colors.chartRed },
valueMuted: { color: colors.textMuted },
caption: {
margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
valueRow: {
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
flexWrap: "wrap",
},
value: {
fontSize: "1.875rem",
lineHeight: "2.25rem",
fontWeight: 600,
},
detail: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
},
footer: {
link: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
},
link: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
}
function Tile({
label,
value,
detail,
footer,
caption,
tone,
style,
children,
}: {
label: string;
value: string;
detail?: string | null;
footer?: React.ReactNode;
caption: string;
tone?: "blocked" | "muted";
style?: stylex.StyleXStyles;
children?: React.ReactNode;
}) {
return (
<div {...stylex.props(styles.tile)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<dd {...stylex.props(styles.valueRow)}>
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd>
{footer !== undefined && <div {...stylex.props(styles.footer)}>{footer}</div>}
</div>
<li {...stylex.props(styles.tile, style)}>
<p
{...stylex.props(
styles.value,
shared.tabularNums,
tone === "blocked" && styles.valueBlocked,
tone === "muted" && styles.valueMuted,
)}
>
{value}
</p>
<p {...stylex.props(styles.caption)}>{caption}</p>
{children}
</li>
);
}
/** The window's totals with the bounds they were measured over. */
/** The window's totals with the bounds they were measured over and the scope they were read under. */
export interface StatTilesData extends OverviewTotals {
since: number;
until: number;
client: string | undefined;
}
export default function StatTiles({ stats }: { stats: StatTilesData }) {
@@ -113,50 +131,39 @@ export default function StatTiles({ stats }: { stats: StatTilesData }) {
since: stats.since,
until: stats.until,
domain: undefined,
client: undefined,
client: stats.client,
};
return (
<dl {...stylex.props(styles.grid)}>
<ul aria-label="Totals" {...stylex.props(styles.grid)}>
<Tile value={formatCount(stats.queries)} caption="queries">
<Link
to="/activity"
search={{ ...window, blocked: undefined }}
{...stylex.props(styles.link, shared.focusRing)}
>
Open in Activity
</Link>
</Tile>
<Tile value={formatCount(stats.blocked)} caption="blocked queries" tone="blocked">
<Link
to="/activity"
search={{ ...window, blocked: true }}
{...stylex.props(styles.link, shared.focusRing)}
>
Open blocked queries
</Link>
</Tile>
<Tile value={formatCount(stats.clients)} caption="active clients">
<Link to="/clients" {...stylex.props(styles.link, shared.focusRing)}>
Manage clients
</Link>
</Tile>
<Tile
label="Queries"
value={numberFormat.format(stats.queries)}
footer={
<Link
to="/activity"
search={{ ...window, blocked: undefined }}
{...stylex.props(styles.link, shared.focusRing)}
>
Open in Activity
</Link>
}
value={stats.queries === 0 ? "—" : formatPercent(stats.blocked / stats.queries)}
caption="of queries blocked"
tone="muted"
style={styles.shareTile}
/>
<Tile
label="Blocked"
value={numberFormat.format(stats.blocked)}
detail={percentOf(stats.blocked, stats.queries)}
footer={
<Link
to="/activity"
search={{ ...window, blocked: true }}
{...stylex.props(styles.link, shared.focusRing)}
>
Open blocked queries
</Link>
}
/>
<Tile
label="Clients"
value={numberFormat.format(stats.clients)}
footer={
<Link to="/clients" {...stylex.props(styles.link, shared.focusRing)}>
Manage clients
</Link>
}
/>
<Tile
label="Avg response"
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
/>
</dl>
</ul>
);
}
@@ -3,10 +3,17 @@ import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { CHART_BLUE, CHART_RED } from "./seriesColors";
import TimeseriesChart, { type TimeseriesData } from "./TimeseriesChart";
const SINCE = 1_700_000_000;
/** The fallback width less the margins: where the plot starts and ends. */
const PLOT_LEFT = 44;
const PLOT_RIGHT = 44 + 588;
const PLOT_TOP = 8;
const PLOT_BOTTOM = 240 - 22;
function timeseries(buckets: Bucket[]): TimeseriesData {
return { since: SINCE, bucket_seconds: 1800, buckets };
}
@@ -34,6 +41,18 @@ function overlayRects(container: HTMLElement): SVGRectElement[] {
return Array.from(container.querySelectorAll<SVGRectElement>('rect[fill="transparent"]'));
}
/** The area fill and the line of one series. */
function seriesPaths(container: HTMLElement, key: string): { area: SVGPathElement; line: SVGPathElement } {
const group = container.querySelector(`g[data-series="${key}"]`) as SVGGElement;
const [area, line] = Array.from(group.querySelectorAll("path"));
return { area, line };
}
/** Every coordinate pair in a path, in drawing order. */
function pathPoints(d: string): [number, number][] {
return Array.from(d.matchAll(/(-?[\d.]+),(-?[\d.]+)/g)).map((match) => [Number(match[1]), Number(match[2])]);
}
test("the data table is the SVG's accessible equivalent", () => {
render(<TimeseriesChart data={counting(3)} />);
@@ -57,13 +76,13 @@ test("the hidden data table is clipped by a block wrapper, not by the table itse
});
/**
* "Allowed" is what the reported total leaves over, and the three counts come
* "Allowed" is what the reported total leaves over, and the two counts come
* from separate columns that a partial write can leave inconsistent. A negative
* remainder would draw a segment upside down.
* remainder would be a lie in the table.
*/
test("allowed is the remainder of the reported total, clamped at zero", () => {
const { container } = render(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 8, cached: 5 }])} />,
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 12, cached: 5 }])} />,
);
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
@@ -71,13 +90,9 @@ test("allowed is the remainder of the reported total, clamped at zero", () => {
within(row)
.getAllByRole("cell")
.map((cell) => cell.textContent),
).toEqual(["10", "8", "5", "0"]);
// Blocked and cached are drawn; the empty "allowed" segment is not.
expect(container.querySelectorAll('rect[fill="#3b82f6"]')).toHaveLength(0);
).toEqual(["10", "12", "0"]);
// The scale comes from the reported total, not from the stack's own sum.
// Scaling to the sum would reach 13 here and leave the bar four fifths of the
// way up a plot whose own numbers say it is full.
// The scale comes from the reported total, so the plot's own numbers say it is full.
const ticks = Array.from(container.querySelectorAll(".visx-axis-left text")).map((tick) => tick.textContent);
expect(ticks[ticks.length - 1]).toBe("10");
});
@@ -102,18 +117,80 @@ test("no bucket at all says the same thing", () => {
});
/**
* The three category colours are fixed constants of this chart rather than
* anything derived from `seriesColors`, which would paint "other" grey.
* Two series, each a line over its own fill, in the two colours the decision
* record fixed: the accent blue for the total, the softer red for blocked. The
* total is drawn first so the blocked band paints over its faint fill.
*/
test("the segments are drawn in this chart's own category colours", () => {
test("each series is a line over a fill in its own colour, blocked painted over the total", () => {
const { container } = render(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 100, blocked: 40, cached: 10 }])} />,
);
const fills = Array.from(container.querySelectorAll("rect"))
.map((rect) => rect.getAttribute("fill"))
.filter((fill) => fill !== "transparent");
expect(fills).toEqual(["#ef4444", "#059669", "#3b82f6"]);
const groups = Array.from(container.querySelectorAll("g[data-series]")).map((g) => g.getAttribute("data-series"));
expect(groups).toEqual(["queries", "blocked"]);
const total = seriesPaths(container, "queries");
expect(total.area.getAttribute("fill")).toBe(CHART_BLUE);
expect(total.area.getAttribute("fill-opacity")).toBe("0.13");
expect(total.line.getAttribute("stroke")).toBe(CHART_BLUE);
expect(total.line.getAttribute("stroke-width")).toBe("2");
expect(total.line.getAttribute("fill")).toBe("none");
const blocked = seriesPaths(container, "blocked");
expect(blocked.area.getAttribute("fill")).toBe(CHART_RED);
expect(blocked.area.getAttribute("fill-opacity")).toBe("0.35");
expect(blocked.line.getAttribute("stroke")).toBe(CHART_RED);
});
/**
* The owner could not see past the last labelled point when the points sat at
* band centres: the first bucket is on the plot's left edge and the last on its
* right, so the curve covers the whole plot and ends on its data.
*/
test("the points run edge to edge, and the curve passes through every one of them", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 10, blocked: 0, cached: 0 },
{ ts: SINCE + 1800, queries: 5, blocked: 0, cached: 0 },
{ ts: SINCE + 3600, queries: 10, blocked: 0, cached: 0 },
])}
/>,
);
const points = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "");
// M, then two Cs of three pairs each: the last pair of each C is the data point.
expect(points).toHaveLength(7);
expect(points[0]).toEqual([PLOT_LEFT, PLOT_TOP]);
expect(points[3][0]).toBeCloseTo((PLOT_LEFT + PLOT_RIGHT) / 2, 1);
expect(points[6]).toEqual([PLOT_RIGHT, PLOT_TOP]);
// The fill closes down to the baseline under the same two ends.
expect(seriesPaths(container, "queries").area.getAttribute("d")).toMatch(
new RegExp(` L${PLOT_RIGHT},${PLOT_BOTTOM} L${PLOT_LEFT},${PLOT_BOTTOM} Z$`),
);
});
/**
* A spline through a spike overshoots. Clamping the control points keeps the
* curve inside the plot, so a zero bucket beside a busy one never dips below
* the baseline or above the top.
*/
test("the curve never leaves the plot, whatever the data does", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 0, blocked: 0, cached: 0 },
{ ts: SINCE + 1800, queries: 100, blocked: 0, cached: 0 },
{ ts: SINCE + 3600, queries: 0, blocked: 0, cached: 0 },
{ ts: SINCE + 5400, queries: 0, blocked: 0, cached: 0 },
])}
/>,
);
for (const [, y] of pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")) {
expect(y).toBeGreaterThanOrEqual(PLOT_TOP);
expect(y).toBeLessThanOrEqual(PLOT_BOTTOM);
}
});
/**
@@ -126,7 +203,7 @@ test("hover text is the tooltip alone, never a bare SVG title", () => {
expect(container.querySelectorAll("title")).toHaveLength(0);
});
/** The hit target is the whole column slot, including the space above a short stack. */
/** The hit target is the whole column slot, including the space above a low point. */
test("each bucket's hit target spans the full plot height", () => {
const { container } = render(<TimeseriesChart data={counting(3)} />);
@@ -139,20 +216,30 @@ test("each bucket's hit target spans the full plot height", () => {
});
/**
* A band scale spends a gap after the last column as well as between them, so a
* full-step hit target on the last bucket would reach into the right margin and
* catch pointers that are past the plot entirely.
* The bands tile the plot with their boundaries midway between neighbouring
* points, so landing anywhere in the plot is already "snap to the nearest
* bucket" and no pixel belongs to no bucket.
*/
test("the last hit target stops at the plot's right edge", () => {
test("the hit targets tile the plot edge to edge, split midway between points", () => {
const { container } = render(<TimeseriesChart data={counting(3)} />);
const last = overlayRects(container).at(-1) as SVGRectElement;
const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
// 640 fallback width, less the 44px left and 8px right margins.
expect(right).toBeCloseTo(44 + 588, 6);
const edges = overlayRects(container).map((rect) => {
const x = Number(rect.getAttribute("x"));
return [x, x + Number(rect.getAttribute("width"))];
});
const middle = (PLOT_LEFT + PLOT_RIGHT) / 2;
expect(edges[0][0]).toBe(PLOT_LEFT);
expect(edges[0][1]).toBeCloseTo((PLOT_LEFT + middle) / 2, 6);
expect(edges[1][0]).toBeCloseTo(edges[0][1], 6);
expect(edges[2][1]).toBe(PLOT_RIGHT);
});
test("pointing at a bucket names its total and every series, and dims the rest", () => {
/**
* The owner's ask: a dot on each curve and a guide line, so the reader can see
* which point the tooltip describes. The dots sit at the coordinates the curves
* were built from, ringed in the panel's surface so they read over either fill.
*/
test("pointing at a bucket marks it with a guide line and a dot on each curve", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
@@ -161,39 +248,62 @@ test("pointing at a bucket names its total and every series, and dims the rest",
])}
/>,
);
expect(container.querySelector("g[data-hover]")).toBeNull();
fireEvent.mouseOver(overlayRects(container)[1]);
const hover = container.querySelector("g[data-hover]") as SVGGElement;
const guide = hover.querySelector("line") as SVGLineElement;
expect(guide.getAttribute("x1")).toBe(String(PLOT_RIGHT));
expect(guide.getAttribute("y1")).toBe(String(PLOT_TOP));
expect(guide.getAttribute("y2")).toBe(String(PLOT_BOTTOM));
const dots = Array.from(hover.querySelectorAll("circle"));
expect(dots.map((dot) => dot.getAttribute("fill"))).toEqual([CHART_BLUE, CHART_RED]);
const lastPoint = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "").at(-1);
expect(Number(dots[0].getAttribute("cx"))).toBe(PLOT_RIGHT);
expect(Number(dots[0].getAttribute("cy"))).toBeCloseTo(lastPoint?.[1] ?? NaN, 1);
for (const dot of dots) expect(dot.getAttribute("r")).toBe("4");
});
test("pointing at a bucket names the total, the blocked share and the remainder", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 1000, blocked: 400, cached: 10 },
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
])}
/>,
);
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
expect(values).toEqual(["100", "40", "10", "50"]);
const terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent);
expect(terms).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
// One swatch per series, in the colour the segment is drawn in.
expect(terms).toEqual(["Total queries", "Blocked", "Allowed"]);
const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
expect(values).toEqual(["1,000", "400", "600"]);
// One swatch per drawn series; the remainder is arithmetic, not a shape.
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
expect(swatches[0]).toContain("#ef4444");
expect(swatches[1]).toContain("#059669");
expect(swatches[2]).toContain("#3b82f6");
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
expect(swatches).toHaveLength(2);
expect(swatches[0]).toContain(CHART_BLUE);
expect(swatches[1]).toContain(CHART_RED);
});
/**
* The tooltip sits 8px down from the chart's top edge and 8px to the side of the
* slot it names — the placement the hand-rolled tooltip had, restored over
* visx's own 10px defaults.
* Beside the point, never over it: 16px to its right at the point's own height,
* so the box hides neither the dot nor the slot of data the reader is reading.
* `TooltipWithBounds` flips it to the left when it would run off the right edge.
*/
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
test("the tooltip stands 16px beside the point, at the point's own height", () => {
const { container } = render(<TimeseriesChart data={counting(2)} />);
fireEvent.mouseOver(overlayRects(container)[0]);
// The first slot spans 44 to 44 + step, so its centre is 191.5 and the
// tooltip sits 8px right of it. visx rounds the placement to whole pixels.
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
expect(tooltip.style.transform).toBe("translate(200px, 8px)");
const firstPoint = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")[0];
expect(tooltip.style.transform).toBe(`translate(${PLOT_LEFT + 16}px, ${Math.round(firstPoint[1])}px)`);
});
/**
@@ -232,7 +342,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
fireEvent.mouseOver(overlayRects(container)[0]);
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
).toEqual(["100", "40", "10", "50"]);
).toEqual(["100", "40", "60"]);
rerender(
<TimeseriesChart
@@ -246,17 +356,17 @@ test("a refresh in the same window retells the hovered bucket with the new count
const values = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map(
(dd) => dd.textContent,
);
expect(values).toEqual(["120", "60", "10", "50"]);
expect(values).toEqual(["120", "60", "60"]);
});
/**
* A rolling window is the case a stored copy gets wrong: the bucket the pointer
* was over is gone, so index 0 now names a different span. The tooltip goes away
* rather than describing a bucket that is no longer drawn, and nothing stays
* dimmed behind it. Rolling back to the earlier window must not bring it back
* either: the selection is deleted when the window moves, not held aside.
* was over is gone, so index 0 now names a different span. The tooltip and the
* marks go away rather than describing a bucket that is no longer drawn.
* Rolling back to the earlier window must not bring them back either: the
* selection is deleted when the window moves, not held aside.
*/
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
test("a refresh that rolls the window takes the tooltip and the marks down", () => {
const { container, rerender } = render(
<TimeseriesChart
data={timeseries([
@@ -268,6 +378,7 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
fireEvent.mouseOver(overlayRects(container)[0]);
expect(container.querySelectorAll("dl")).toHaveLength(1);
expect(container.querySelector("g[data-hover]")).not.toBeNull();
rerender(
<TimeseriesChart
@@ -282,8 +393,7 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
);
expect(container.querySelectorAll("dl")).toHaveLength(0);
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
expect(container.querySelector("g[data-hover]")).toBeNull();
rerender(
<TimeseriesChart
@@ -299,9 +409,9 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
/**
* The same bucket can change width between refreshes — a count crossing a digit
* boundary, a client name arriving — and a mount measured at the old width is
* placed at the wrong one. A content change therefore remounts the tooltip, the
* same way moving between buckets does.
* boundary — and a mount measured at the old width is placed at the wrong one.
* A content change therefore remounts the tooltip, the same way moving between
* buckets does.
*/
test("a bucket whose numbers change is remounted, so it is measured again", () => {
const { container, rerender } = render(
@@ -319,7 +429,7 @@ test("a bucket whose numbers change is remounted, so it is measured again", () =
expect(second).not.toBe(first);
});
test("leaving the chart takes the tooltip and the dimming with it", () => {
test("leaving the chart takes the tooltip and the marks with it", () => {
const { container } = render(<TimeseriesChart data={counting(3)} />);
fireEvent.mouseOver(overlayRects(container)[0]);
@@ -327,61 +437,36 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
expect(container.querySelectorAll("dl")).toHaveLength(0);
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
expect(container.querySelector("g[data-hover]")).toBeNull();
});
/**
* The third series is every query neither blocked nor served from cache. It was
* called "Other", which named the arithmetic rather than the thing.
* The legend names the two drawn curves and nothing else; the table adds the
* remainder, because a reader of the table has no fill to read it off.
*/
test("the remainder series is called Allowed everywhere it surfaces", () => {
test("the legend names the two curves, and the table adds the remainder", () => {
const { container } = render(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 2, cached: 3 }])} />,
);
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
expect(legend).toEqual(["Total queries", "Blocked"]);
expect(
within(screen.getByRole("table"))
.getAllByRole("columnheader")
.map((cell) => cell.textContent),
).toEqual(["Time", "Queries", "Blocked", "Cached", "Allowed"]);
fireEvent.mouseOver(overlayRects(container)[0]);
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
).toEqual(["Time", "Queries", "Blocked", "Allowed"]);
expect(screen.queryByText("Other")).toBeNull();
expect(screen.queryByText("Cached")).toBeNull();
});
/**
* A window where everything was blocked or served from cache has no allowed
* queries, and that is worth reading rather than hiding: an absent series would
* say the same thing as a series nobody looked at. The three categories are all
* real answers a query can get, so none of them is dropped for counting zero.
* The client chart's "Other" is dropped at zero, but that one aggregates clients
* beyond the top eight rather than naming a kind of answer.
*/
test("a window with nothing allowed keeps the series at zero", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 4, blocked: 4, cached: 0 },
{ ts: SINCE + 1800, queries: 6, blocked: 6, cached: 0 },
])}
/>,
);
test("the hidden table groups its counts like every other figure on the page", () => {
render(<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 12345, blocked: 1234, cached: 1 }])} />);
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual([
"Queries",
"Blocked",
"Cached",
"Allowed",
]);
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["4", "4", "0", "0"]);
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
expect(
within(row)
.getAllByRole("cell")
.map((cell) => cell.textContent),
).toEqual(["12,345", "1,234", "11,111"]);
});
+94 -103
View File
@@ -1,81 +1,69 @@
/**
* Query volume over the window as a smoothed area: every query in blue, the
* blocked share in red along the bottom (ui-visual-redesign.md). Pointing at
* the plot selects the nearest bucket and marks it with a guide line and a dot
* on each curve; the tooltip sits beside the point so it never covers the slot
* the reader is looking at.
*/
import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { BarStack } from "@visx/shape";
import { formatTime } from "@/lib/format";
import { formatCount, formatTime } from "@/lib/format";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import {
BucketOverlay,
CHART_HEIGHT,
ChartFrame,
ChartLegend,
ChartRoot,
ChartTooltip,
EmptyChart,
StackSegment,
bandScale,
HitBands,
areaPath,
labelTickValues,
plotArea,
slotCenter,
pointScale,
smoothPath,
useActiveIndex,
useMeasuredWidth,
valueScale,
valueTicks,
type TooltipContent,
} from "./chartKit";
import { CHART_BLUE, CHART_RED } from "./seriesColors";
// Series colors validated for CVD separation and 3:1 surface contrast in both
// modes (Tailwind red-500 / blue-500 / emerald-600; same hex light and dark).
// The third key stays `other` — it is the colour key and the response field, and
// renaming it would repaint the series. Only what the reader sees is "Allowed".
/**
* Drawn bottom-up in this order: the total's fill is faint so the blocked
* curve, painted over it, stays a solid band along the baseline.
*/
const SERIES = [
{ key: "blocked", label: "Blocked", color: "#ef4444" },
{ key: "cached", label: "Cached", color: "#059669" },
{ key: "other", label: "Allowed", color: "#3b82f6" },
{ key: "queries", label: "Total queries", color: CHART_BLUE, fillOpacity: 0.13 },
{ key: "blocked", label: "Blocked", color: CHART_RED, fillOpacity: 0.35 },
] as const;
type Series = (typeof SERIES)[number];
type SeriesKey = Series["key"];
type SeriesKey = (typeof SERIES)[number]["key"];
const SERIES_COLOR: Record<SeriesKey, string> = {
blocked: SERIES[0].color,
cached: SERIES[1].color,
other: SERIES[2].color,
};
const LINE_WIDTH = 2;
const DOT_RADIUS = 4;
const styles = stylex.create({
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
guide: {
stroke: colors.borderStrong,
strokeWidth: 1,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
/** The dot's ring is the panel's own surface, so it stays legible over either fill. */
dot: {
stroke: colors.surfaceRaised,
strokeWidth: 2,
},
swatch: {
display: "inline-block",
width: "0.625rem",
height: "0.625rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
});
interface Column {
ts: number;
queries: number;
blocked: number;
cached: number;
/** queries - blocked - cached, clamped at 0. */
other: number;
/** queries - blocked, clamped at 0. */
allowed: number;
}
function columnsOf(buckets: Bucket[]): Column[] {
@@ -83,8 +71,7 @@ function columnsOf(buckets: Bucket[]): Column[] {
ts: bucket.ts,
queries: bucket.queries,
blocked: bucket.blocked,
cached: bucket.cached,
other: Math.max(0, bucket.queries - bucket.blocked - bucket.cached),
allowed: Math.max(0, bucket.queries - bucket.blocked),
}));
}
@@ -92,13 +79,13 @@ function tooltipOf(column: Column): TooltipContent {
return {
title: formatTime(column.ts),
rows: [
{ key: "queries", label: "Queries", value: String(column.queries) },
...SERIES.map((series) => ({
key: series.key,
label: series.label,
color: series.color,
value: String(column[series.key]),
value: formatCount(column[series.key]),
})),
{ key: "allowed", label: "Allowed", value: formatCount(column.allowed) },
],
};
}
@@ -126,17 +113,18 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
const columns = columnsOf(data.buckets);
const timestamps = columns.map((column) => column.ts);
const plot = plotArea(width);
const xScale = bandScale(timestamps, plot);
// The scale is the reported total rather than the stack's own sum: blocked
// and cached are parts of `queries`, which a clamped `other` can undercount.
const xScale = pointScale(timestamps, plot);
const yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
const yTicks = valueTicks(yScale);
const centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
const pointsOf = (key: SeriesKey): [number, number][] =>
columns.map((column, i) => [centers[i], yScale(column[key])]);
return (
<ChartRoot containerRef={containerRef}>
<svg
role="img"
aria-label={`Queries over time, ${data.buckets.length} buckets: ${SERIES.map((series) => series.label.toLowerCase()).join(", ")} queries per bucket`}
aria-label={`Queries over time, ${data.buckets.length} buckets: total and blocked queries per bucket`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
@@ -150,54 +138,61 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
xTickValues={labelTickValues(timestamps, plot.width)}
bucketSeconds={data.bucket_seconds}
/>
<BarStack<Column, SeriesKey>
data={columns}
keys={SERIES.map((series) => series.key)}
x={(column) => column.ts}
xScale={xScale}
yScale={yScale}
color={(key) => SERIES_COLOR[key]}
>
{(stacks) =>
columns.map((column, index) => (
<Group
key={column.ts}
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
>
{stacks.map((stack) => {
const bar = stack.bars[index];
return (
<StackSegment
key={stack.key}
x={bar.x}
y={bar.y}
width={bar.width}
height={bar.height}
fill={bar.color}
/>
);
})}
</Group>
))
}
</BarStack>
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
{SERIES.map((series) => {
const points = pointsOf(series.key);
const line = smoothPath(points, plot.y, plot.bottom);
return (
<g key={series.key} data-series={series.key}>
<path
d={areaPath(line, points, plot)}
fill={series.color}
fillOpacity={series.fillOpacity}
/>
<path
d={line}
fill="none"
stroke={series.color}
strokeWidth={LINE_WIDTH}
strokeLinejoin="round"
/>
</g>
);
})}
{hovered.index !== null && (
<g data-hover="">
<line
x1={centers[hovered.index]}
x2={centers[hovered.index]}
y1={plot.y}
y2={plot.bottom}
{...stylex.props(styles.guide)}
/>
{SERIES.map((series) => (
<circle
key={series.key}
cx={centers[hovered.index as number]}
cy={yScale(columns[hovered.index as number][series.key])}
r={DOT_RADIUS}
fill={series.color}
{...stylex.props(styles.dot)}
/>
))}
</g>
)}
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
</svg>
{hovered.index !== null && (
<ChartTooltip
index={hovered.index}
content={tooltipOf(columns[hovered.index])}
left={slotCenter(xScale, timestamps[hovered.index], plot)}
left={centers[hovered.index]}
top={yScale(columns[hovered.index].queries)}
beside
/>
)}
<ul {...stylex.props(styles.legend)}>
{SERIES.map((series) => (
<li key={series.key} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(series.color))} />
{series.label}
</li>
))}
</ul>
<ChartLegend
entries={SERIES.map((series) => ({ key: series.key, label: series.label, color: series.color }))}
/>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>Queries per time bucket</caption>
@@ -205,21 +200,17 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
<tr>
<th scope="col">Time</th>
<th scope="col">Queries</th>
{SERIES.map((series) => (
<th key={series.key} scope="col">
{series.label}
</th>
))}
<th scope="col">Blocked</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{columns.map((column) => (
<tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th>
<td>{column.queries}</td>
{SERIES.map((series) => (
<td key={series.key}>{column[series.key]}</td>
))}
<td>{formatCount(column.queries)}</td>
<td>{formatCount(column.blocked)}</td>
<td>{formatCount(column.allowed)}</td>
</tr>
))}
</tbody>
+154 -58
View File
@@ -1,7 +1,7 @@
/**
* The plumbing the two bar charts on Overview share: the width measurement, the
* scales, the axis and grid chrome, the segment separator, the per-bucket hit
* target and the tooltip.
* The plumbing the charts on Overview share: the width measurement, the scales,
* the axis and grid chrome, the smoothed path, the stacked segment, the
* per-bucket hit target and the tooltip.
*
* Geometry is visx's; the chrome is ours. visx's own axis defaults draw tick
* marks, an axis line on both axes and Arial 10px in #222, none of which this
@@ -13,10 +13,11 @@ import { useLayoutEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
import { GridRows } from "@visx/grid";
import { scaleBand, scaleLinear } from "@visx/scale";
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
import { TooltipWithBounds } from "@visx/tooltip";
import { formatBucketTime, formatCompact } from "@/lib/format";
import { styles as shared } from "@/ui/styles";
import { colors, layers } from "@/ui/tokens.stylex";
import { colors, layers, metrics } from "@/ui/tokens.stylex";
export const CHART_HEIGHT = 240;
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
@@ -102,7 +103,7 @@ export function bandPaddingInner(bucketCount: number, plotWidth: number): number
return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
}
/** The time axis: one band per bucket, in the order the buckets were given. */
/** The time axis of a bar chart: one band per bucket, in the order the buckets were given. */
export function bandScale(values: number[], plot: Plot) {
return scaleBand<number>({
domain: values,
@@ -113,6 +114,19 @@ export function bandScale(values: number[], plot: Plot) {
export type BandScale = ReturnType<typeof bandScale>;
/**
* The time axis of the area chart: the first bucket on the plot's left edge and
* the last on its right, so the curve never runs past its own data and the
* reader can see the last point.
*/
export function pointScale(values: number[], plot: Plot) {
return scalePoint<number>({ domain: values, range: [plot.x, plot.x + plot.width] });
}
export type PointScale = ReturnType<typeof pointScale>;
export type TimeScale = BandScale | PointScale;
/**
* The subset of bucket timestamps that get an x-axis label. A 30-day window is
* 30 columns and a 1-hour window is 60, so at narrow widths the labels have to
@@ -124,14 +138,43 @@ export function labelTickValues(values: number[], plotWidth: number): number[] {
return values.filter((_, i) => i % step === 0);
}
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
/** One decimal of path precision: enough for a pixel, short enough to keep the DOM small. */
function coordinate(value: number): number {
return Math.round(value * 10) / 10;
}
export function formatBucketTime(ts: number, bucketSeconds: number): string {
const date = new Date(ts * 1000);
if (bucketSeconds >= 86_400) {
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
/**
* A curve through every point, as cubic Béziers derived from a Catmull-Rom
* spline. The control points' y is clamped to the plot, because a spline
* through a spike overshoots and would otherwise draw the curve dipping below
* the baseline beside a zero bucket. The curve passes exactly through each
* point, so the hover dots sit at the coordinates the path was built from.
*/
export function smoothPath(points: [number, number][], top: number, bottom: number): string {
if (points.length === 0) return "";
const clamp = (y: number) => Math.max(top, Math.min(bottom, y));
const at = (i: number) => points[Math.max(0, Math.min(points.length - 1, i))];
let d = `M${coordinate(points[0][0])},${coordinate(points[0][1])}`;
for (let i = 0; i < points.length - 1; i += 1) {
const p0 = at(i - 1);
const p1 = at(i);
const p2 = at(i + 1);
const p3 = at(i + 2);
const c1x = p1[0] + (p2[0] - p0[0]) / 6;
const c1y = clamp(p1[1] + (p2[1] - p0[1]) / 6);
const c2x = p2[0] - (p3[0] - p1[0]) / 6;
const c2y = clamp(p2[1] - (p3[1] - p1[1]) / 6);
d += ` C${coordinate(c1x)},${coordinate(c1y)} ${coordinate(c2x)},${coordinate(c2y)} ${coordinate(p2[0])},${coordinate(p2[1])}`;
}
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
return d;
}
/** The same curve closed down to the baseline, for the fill under the line. */
export function areaPath(line: string, points: [number, number][], plot: Plot): string {
if (points.length === 0) return "";
const first = coordinate(points[0][0]);
const last = coordinate(points[points.length - 1][0]);
return `${line} L${last},${plot.bottom} L${first},${plot.bottom} Z`;
}
const styles = stylex.create({
@@ -140,7 +183,7 @@ const styles = stylex.create({
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
@@ -155,15 +198,11 @@ const styles = stylex.create({
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
tooltip: {
pointerEvents: "none",
position: "absolute",
zIndex: layers.tooltip,
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -174,6 +213,10 @@ const styles = stylex.create({
lineHeight: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
/** Beside a point, the box is centred on the point's height rather than hung from it. */
tooltipBeside: {
transform: "translateY(-50%)",
},
tooltipTitle: {
fontWeight: 500,
},
@@ -201,7 +244,7 @@ const styles = stylex.create({
height: "0.5rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
/** Dynamic: the swatch takes the series colour the SVG shapes are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
});
@@ -283,7 +326,7 @@ export function ChartFrame({
plot: Plot;
yScale: ValueScale;
yTicks: number[];
xScale: BandScale;
xScale: TimeScale;
xTickValues: number[];
bucketSeconds: number;
}) {
@@ -298,7 +341,7 @@ export function ChartFrame({
hideAxisLine
hideTicks
tickLength={0}
tickFormat={(value) => compact.format(Number(value))}
tickFormat={(value) => formatCompact(Number(value))}
// `dy` overrides AxisLeft's own 0.25em nudge, which would double up
// with the middle baseline this app centres its value labels on.
tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }}
@@ -320,9 +363,9 @@ export function ChartFrame({
}
/**
* One segment of a stacked column. The separator is drawn only once the column
* is wide enough for two neighbouring segments to read as two shapes; below
* that it would be most of the bar.
* One segment of a stacked column. `bleed` extends the segment down into the
* one drawn before it, so antialiasing cannot open a seam of ground between
* two touching fills: the blank lines the owner saw across the client chart.
*/
export function StackSegment({
x,
@@ -330,59 +373,54 @@ export function StackSegment({
width,
height,
fill,
bleed = 0,
}: {
x: number;
y: number;
width: number;
height: number;
fill: string;
bleed?: number;
}) {
if (height <= 0) return null;
return (
<rect
x={x}
y={y}
width={width}
height={height}
fill={fill}
strokeWidth={width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
);
return <rect x={x} y={y} width={width} height={height + bleed} fill={fill} />;
}
/** The amount one upper segment overlaps the one under it, in plot units. */
export const STACK_BLEED = 0.5;
/**
* The transparent hit targets: one per bucket, spanning the whole plot height so
* that pointing at the empty space above a short column still selects it.
* that pointing at the empty space above a short column or a low point still
* selects it.
*
* A slot is a whole band step, gap included, so that no pixel between two
* columns belongs to neither. The last slot is clipped to the plot's right edge:
* the band scale spends the trailing gap on nothing, and a full-step rect there
* would reach into the right margin.
* The bands tile the plot edge to edge with their boundaries midway between
* neighbouring marks, so no pixel belongs to no bucket and landing in a band is
* already "snap to the nearest bucket". The same tiling serves band centres
* (bars) and points spread edge to edge (the area chart).
*/
export function BucketOverlay({
export function HitBands({
plot,
values,
xScale,
centers,
onEnter,
}: {
plot: Plot;
values: number[];
xScale: BandScale;
/** The x of each bucket's mark, in bucket order. */
centers: number[];
onEnter: (index: number) => void;
}) {
const step = xScale.step();
const right = plot.x + plot.width;
return (
<>
{values.map((value, index) => {
const x = xScale(value) ?? plot.x;
{centers.map((cx, index) => {
const x0 = index === 0 ? plot.x : (centers[index - 1] + cx) / 2;
const x1 = index === centers.length - 1 ? right : (cx + centers[index + 1]) / 2;
return (
<rect
key={value}
x={x}
key={index}
x={x0}
y={plot.y}
width={Math.max(0, Math.min(step, right - x))}
width={Math.max(0, x1 - x0)}
height={plot.height}
fill="transparent"
onMouseEnter={() => onEnter(index)}
@@ -393,13 +431,13 @@ export function BucketOverlay({
);
}
/** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */
/** The x a bar's tooltip points at: the centre of its slot, not of its narrower bar. */
export function slotCenter(xScale: BandScale, value: number, plot: Plot): number {
return (xScale(value) ?? plot.x) + xScale.step() / 2;
}
/**
* Which item the pointer is on, and nothing else — a bucket in the bar charts, a
* Which item the pointer is on, and nothing else — a bucket in the charts, a
* slice in the donuts.
*
* Deliberately not `useTooltip`: holding the hovered item's numbers and screen
@@ -448,20 +486,33 @@ export interface TooltipContent {
*/
const NO_INLINE_STYLE = {};
/** The gap the tooltip keeps from its anchor point. */
const TOOLTIP_OFFSET = 8;
/** The gap a tooltip hung from a chart's corner keeps from its anchor point. */
const CORNER_OFFSET = 8;
/**
* The gap a tooltip beside a point keeps from it: enough that the box never
* covers the dot or the slot of data the reader is looking at.
*/
const BESIDE_OFFSET = 16;
export function ChartTooltip({
content,
index,
left,
top = 0,
beside = false,
}: {
content: TooltipContent;
/** Which item the tooltip names; part of what forces a fresh measurement. */
index: number;
left: number;
top?: number;
/**
* Beside the anchor at its own height rather than hung from the chart's top:
* the area chart's placement, where `top` is the point's y. `TooltipWithBounds`
* flips the box to the anchor's left when it would run past the right edge.
*/
beside?: boolean;
}) {
// `withBoundingRects` measures once, in `componentDidMount`, and never again,
// so every content change needs its own mount to be measured at its own size.
@@ -474,10 +525,10 @@ export function ChartTooltip({
key={measureKey}
left={left}
top={top}
offsetLeft={TOOLTIP_OFFSET}
offsetTop={TOOLTIP_OFFSET}
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
offsetTop={beside ? 0 : CORNER_OFFSET}
style={NO_INLINE_STYLE}
className={stylex.props(styles.tooltip).className}
className={stylex.props(styles.tooltip, beside && styles.tooltipBeside).className}
>
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
<dl {...stylex.props(styles.tooltipList)}>
@@ -499,3 +550,48 @@ export function ChartTooltip({
</TooltipWithBounds>
);
}
const legendStyles = stylex.create({
legend: {
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
item: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
swatch: {
display: "inline-block",
width: "0.625rem",
height: "0.625rem",
borderRadius: "0.125rem",
},
swatchColor: (color: string) => ({ backgroundColor: color }),
});
/** The key under a chart: one swatch and one name per drawn series, in drawing order. */
export function ChartLegend({ entries }: { entries: { key: string; label: string; color: string }[] }) {
return (
<ul {...stylex.props(legendStyles.legend)}>
{entries.map((entry) => (
<li key={entry.key} {...stylex.props(legendStyles.item)}>
<span
aria-hidden="true"
{...stylex.props(legendStyles.swatch, legendStyles.swatchColor(entry.color))}
/>
{entry.label}
</li>
))}
</ul>
);
}
@@ -0,0 +1,14 @@
import { parseClient } from "./clientScope";
test("one address passes, and nothing else does", () => {
expect(parseClient("192.0.2.30")).toBe("192.0.2.30");
expect(parseClient("2001:db8::1")).toBe("2001:db8::1");
expect(parseClient(undefined)).toBeUndefined();
expect(parseClient("")).toBeUndefined();
expect(parseClient(7)).toBeUndefined();
// A list is the Activity filter's grammar, not this page's.
expect(parseClient("192.0.2.30,192.0.2.31")).toBeUndefined();
// Longer than any address the server stores.
expect(parseClient("1".repeat(46))).toBeUndefined();
expect(parseClient("1".repeat(45))).toBe("1".repeat(45));
});
@@ -0,0 +1,16 @@
/**
* The device Overview is scoped to, as URL state beside the period.
*
* The API takes one exact address (`logger.max_client_len` bytes at most, no
* list), so that is what the route lets through: anything else is dropped from
* the URL rather than sent on to a 400. An absent scope is the whole household.
*/
/** `logger.max_client_len` on the server: the longest textual address it stores. */
const MAX_CLIENT_LEN = 45;
export function parseClient(value: unknown): string | undefined {
if (typeof value !== "string" || value.length === 0 || value.length > MAX_CLIENT_LEN) return undefined;
if (value.includes(",")) return undefined;
return value;
}
@@ -64,7 +64,7 @@ afterEach(() => vi.unstubAllGlobals());
let lastRetry: () => void;
function Probe({ period }: { period: Period }) {
const panel = useOverviewWindow(period);
const panel = useOverviewWindow(period, undefined);
if (panel.status === "error") lastRetry = panel.retry;
const detail =
panel.status === "ready"
@@ -23,8 +23,8 @@ import type { Overview, Period } from "@/lib/types";
export type Panel<T> =
{ status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T };
export function useOverviewWindow(period: Period): Panel<Overview> {
const query = useQuery({ ...overviewQuery(period), placeholderData: keepPreviousData });
export function useOverviewWindow(period: Period, client: string | undefined): Panel<Overview> {
const query = useQuery({ ...overviewQuery(period, client), placeholderData: keepPreviousData });
const { refetch } = query;
const retry = useCallback(() => void refetch(), [refetch]);
@@ -1,11 +1,44 @@
import { OTHER_KEY, clientKey, qtypeKey, routeKey, seriesColor } from "./seriesColors";
import { colors } from "@/ui/tokens.stylex";
import {
CHART_GREEN,
CHART_RED,
OTHER_KEY,
clientKey,
clientSeriesColor,
qtypeKey,
routeKey,
seriesColor,
typeRampColor,
} from "./seriesColors";
test("the four source-less route kinds and other are fixed, so they mean one thing everywhere", () => {
expect(seriesColor(routeKey("blocked", null))).toBe("#ef4444");
expect(seriesColor(routeKey("cache", null))).toBe("#059669");
expect(seriesColor(routeKey("local", null))).toBe("#8b5cf6");
expect(seriesColor(routeKey("rejected", null))).toBe("#f59e0b");
expect(seriesColor(OTHER_KEY)).toBe("#71717a");
expect(seriesColor(routeKey("blocked", null))).toBe(CHART_RED);
expect(seriesColor(routeKey("cache", null))).toBe(CHART_GREEN);
expect(seriesColor(routeKey("local", null))).toBe(colors.seriesViolet);
expect(seriesColor(routeKey("rejected", null))).toBe(colors.seriesAmber);
expect(seriesColor(OTHER_KEY)).toBe(colors.seriesOther);
});
test("every colour is a token reference, so the charts and the CSS cannot disagree", () => {
for (const value of [CHART_RED, CHART_GREEN, seriesColor(OTHER_KEY), clientSeriesColor(0), typeRampColor(0)]) {
expect(value).toMatch(/^var\(--/);
}
});
test("the client chart colours by rank: eight distinct hues, then round again", () => {
const first = Array.from({ length: 8 }, (_, rank) => clientSeriesColor(rank));
expect(new Set(first).size).toBe(8);
expect(clientSeriesColor(8)).toBe(clientSeriesColor(0));
// Never the aggregate's gray, and never the reserved red.
expect(first).not.toContain(seriesColor(OTHER_KEY));
expect(first).not.toContain(CHART_RED);
});
test("the types ring steps one hue outward and a long tail shares the lightest step", () => {
const steps = Array.from({ length: 6 }, (_, rank) => typeRampColor(rank));
expect(new Set(steps).size).toBe(6);
expect(typeRampColor(6)).toBe(typeRampColor(5));
expect(typeRampColor(40)).toBe(typeRampColor(5));
});
test("the colour of a key depends on the key and on nothing else", () => {
@@ -52,7 +85,7 @@ test("a panel of realistic entries gets a spread of hues, not one colour repeate
test("a dynamic entry never takes a fixed entry's colour", () => {
// The bug this rules out: a nameless upstream row coming out the same red as
// the Blocked slice beside it in the same ring.
const fixedColors = new Set(["#ef4444", "#059669", "#8b5cf6", "#f59e0b", "#71717a"]);
const fixedColors = new Set([CHART_RED, CHART_GREEN, seriesColor(OTHER_KEY)]);
const keys = [routeKey("upstream", null), routeKey("forward_zone", "lan"), qtypeKey(28), qtypeKey(null)];
for (const key of keys) expect(fixedColors.has(seriesColor(key))).toBe(false);
});
+58 -36
View File
@@ -1,39 +1,68 @@
/**
* A colour per thing, not per position.
* Which colour a series or slice wears, as a StyleX var from `tokens.stylex.ts`.
* An SVG `fill` or `stroke` attribute takes a var reference as readily as CSS
* does (`stroke={colors.surfaceRaised}` renders `var(--…)`), so no chart holds
* a literal of its own and the palette lives in one place.
*
* Every series and slice on Overview is ranked by count, and a rank that changes
* between two thirty-second polls would recolour the whole panel if colour came
* from the ordinal. So colour keys on the entry's semantic identity: the qtype
* value, the client string, or — for routes — the full `(route, source)` pair,
* because keying on the route kind alone would paint two adjacent upstream
* slices the same and merge them into one shape.
*
* The four source-less route kinds and the "other" bucket are fixed rather than
* hashed: they mean the same thing on every install, and Blocked and Cache
* already have colours on the query-volume timeline.
* The routes ring keys colour on identity rather than on rank: a rank that
* changes between two thirty-second polls would recolour the whole panel if
* colour came from the ordinal, and the fixed kinds (blocked, cache, local,
* rejected) mean the same thing on every install. The client chart and the
* types ring rank instead — see their functions.
*/
import type { RouteKind } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
export const CHART_BLUE = colors.primary;
export const CHART_RED = colors.chartRed;
export const CHART_GREEN = colors.chartGreen;
/**
* The dynamic hues, validated for CVD separation and 3:1 contrast against both
* surfaces; the same hex in light and dark, as the timeline's series are. The
* five fixed colours below are deliberately not in here: a nameless upstream row
* must not come out the same red as Blocked in the ring beside it.
* The categorical palette in rank order. The fixed colours below are
* deliberately not in here: a nameless upstream row must not come out the same
* red as Blocked in the ring beside it.
*/
const PALETTE = ["#3b82f6", "#ec4899", "#14b8a6", "#f97316", "#6366f1", "#84cc16", "#06b6d4", "#a855f7"] as const;
const PALETTE = [
colors.seriesBlue,
colors.seriesTeal,
colors.seriesViolet,
colors.seriesAmber,
colors.seriesGreen,
colors.seriesMagenta,
colors.seriesOrange,
colors.seriesOlive,
] as const;
const FIXED: Record<string, string> = {
"route:blocked": "#ef4444",
"route:cache": "#059669",
"route:local": "#8b5cf6",
"route:rejected": "#f59e0b",
other: "#71717a",
"route:blocked": CHART_RED,
"route:cache": CHART_GREEN,
"route:local": colors.seriesViolet,
"route:rejected": colors.seriesAmber,
other: colors.seriesOther,
};
/** The identity of everything outside the top eight clients. */
export const OTHER_KEY = "other";
/**
* The client chart's series colours go by rank, not by identity: the API ranks
* the eight busiest clients and eight hues hashed over eight identities collide
* almost surely, which is exactly the merged-band failure the owner rejected.
* A client that changes rank between polls changes colour; a legend beside the
* chart names every band, so the trade is legibility for stability.
*/
export function clientSeriesColor(rank: number): string {
return PALETTE[rank % PALETTE.length];
}
const TYPE_RAMP = [colors.ramp1, colors.ramp2, colors.ramp3, colors.ramp4, colors.ramp5, colors.ramp6] as const;
/** The query-types ring: one hue stepped outward from the busiest type; a longer tail shares the lightest. */
export function typeRampColor(rank: number): string {
return TYPE_RAMP[Math.min(rank, TYPE_RAMP.length - 1)];
}
export function qtypeKey(qtype: number | null): string {
return qtype === null ? "qtype:none" : `qtype:${qtype}`;
}
@@ -62,22 +91,15 @@ function hash(key: string): number {
}
/**
* The colour of one key, and of nothing else.
* The colour of one key, and of nothing else: a pure function of the identity,
* no panel, no key set, no rank. The routes ring needs that property because
* its entries churn between polls, and an assignment that read the whole set
* would repaint entries that did not change at all.
*
* This is a pure function of the identity: no panel, no key set, no rank. That
* is the property the page needs, because the panels churn — a client enters the
* top eight and another leaves it every few polls — and an assignment that read
* the whole set would repaint entries that did not change at all.
*
* The cost is that a hash is not injective: two entries of one panel can come
* out the same hue. That is a real cost and it is the smaller one. Resolving it
* by probing would mean the entries that lost a slot depend on which entries
* were present, which is the churn this exists to prevent — and eight hues
* cannot colour nine things distinctly in any case. The failure a shared hue
* would cause instead, two neighbouring slices merging into one shape, is
* prevented where it happens: the donut strokes every arc and the client chart
* strokes every segment in the surface colour, so equal hues still read as two.
* The legend and the hidden table name every entry either way.
* A hash is not injective, so two named upstreams can share a hue. The failure
* that would cause, two neighbouring slices merging into one shape, is prevented
* where it happens: the ring strokes every arc in the surface colour, and the
* legend and the hidden table name every entry either way.
*/
export function seriesColor(key: string): string {
return FIXED[key] ?? PALETTE[hash(key) % PALETTE.length];
+2 -2
View File
@@ -27,7 +27,7 @@ import { formatClock } from "@/lib/format";
import { pauseMutation } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { useProtection } from "./protection";
const DURATIONS = [
@@ -66,7 +66,7 @@ const styles = stylex.create({
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
popover: {
width: "var(--trigger-width)",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,