admin: overview redesign, device scope, one formatting contract (milestone 39)
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s

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 centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only.

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. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry.

All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. 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 a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, 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 reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round.

Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
This commit is contained in:
2026-09-07 23:11:50 +02:00
parent e656670dd4
commit 85b8be50a0
77 changed files with 2869 additions and 1163 deletions
+2 -1
View File
@@ -5,6 +5,7 @@ import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { formatDuration } from "@/lib/format";
const styles = stylex.create({
/** Login renders outside AppShell, so it paints the page ground itself. */
@@ -65,7 +66,7 @@ function errorMessage(error: unknown, remaining: number | null): string {
if (error.status === 401) return "Incorrect password.";
if (error.status === 429) {
return remaining !== null && remaining > 0
? `Too many attempts. Try again in ${remaining}s.`
? `Too many attempts. Try again in ${formatDuration(remaining)}.`
: "Too many attempts. Try again shortly.";
}
if (error.status === 503) return "The server is starting or degraded. Try again shortly.";
@@ -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,
+4 -3
View File
@@ -29,6 +29,7 @@ import { useEffect, useMemo, useRef } from "react";
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
import { formatCount } from "@/lib/format";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
import { MAX_CLIENTS } from "./search";
@@ -79,7 +80,7 @@ const styles = stylex.create({
popover: {
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -220,7 +221,7 @@ export function joinClients(ips: readonly string[]): string | undefined {
function triggerLabel(selected: readonly string[], options: readonly ClientOption[]): string {
if (selected.length === 0) return "Clients";
if (selected.length === 1) return displayFor(selected[0] as string, options);
return `${selected.length} clients`;
return `${formatCount(selected.length)} clients`;
}
interface Props {
@@ -373,7 +374,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
</button>
))}
{selected.length > MAX_CHIPS && (
<span {...stylex.props(styles.chipMore)}>+{selected.length - MAX_CHIPS} more</span>
<span {...stylex.props(styles.chipMore)}>+{formatCount(selected.length - MAX_CHIPS)} more</span>
)}
</div>
);
@@ -12,12 +12,13 @@ import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { formatCount } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries";
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 +39,7 @@ const styles = stylex.create({
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -152,7 +153,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
</div>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
@@ -184,7 +185,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
<div {...stylex.props(styles.footer)}>
<p {...stylex.props(styles.note)}>
{/* The count is the one part of this line that moves as pages load. */}
Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
Showing <span {...stylex.props(shared.tabularNums)}>{formatCount(rows.length)}</span>{" "}
{rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"}
</p>
+14 -9
View File
@@ -16,10 +16,11 @@ import { useEffect, useRef, useState } from "react";
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames";
import { formatCount } from "@/lib/format";
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 +78,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 +107,7 @@ const styles = stylex.create({
},
cappedBox: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
@@ -130,7 +131,7 @@ const styles = stylex.create({
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -287,7 +288,8 @@ export default function LiveActivity({
{live.frozen && (
<p {...stylex.props(styles.note)} role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).
Display frozen new queries keep buffering ({formatCount(live.liveCount)} in buffer, newest{" "}
{formatCount(capacity)} kept).
</p>
)}
@@ -295,7 +297,10 @@ export default function LiveActivity({
<div role="status" {...stylex.props(styles.resumed)}>
<span>
Stream resumed {" "}
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
{live.missed === 0
? "no queries missed"
: `${formatCount(live.missed)} missed queries recovered`}
.
</span>
<button
type="button"
@@ -346,7 +351,7 @@ export default function LiveActivity({
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
@@ -393,8 +398,8 @@ export default function LiveActivity({
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
last {capacity} kept).
Showing {formatCount(live.rows.length)} {live.rows.length === 1 ? "query" : "queries"}{" "}
(newest first, last {formatCount(capacity)} kept).
</p>
</>
)}
@@ -8,7 +8,8 @@ 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";
import { formatDuration } from "@/lib/format";
const DARK = "@media (prefers-color-scheme: dark)";
@@ -64,7 +65,7 @@ const styles = stylex.create({
},
card: {
marginTop: "1.5rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
@@ -178,7 +179,7 @@ function errorMessage(error: unknown): string {
}
if (error.status === 429) {
return error.retryAfter !== undefined
? `Rate limited. Try again in ${error.retryAfter}s.`
? `Rate limited. Try again in ${formatDuration(error.retryAfter)}.`
: "Rate limited. Try again shortly.";
}
return error.message;
@@ -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",
+11 -11
View File
@@ -7,6 +7,7 @@ import InlineError from "@/lib/InlineError";
import { formatTime } from "@/lib/format";
import { clientsQuery } from "@/lib/queries";
import { useAuthority } from "@/features/configuration/authority";
import Card from "@/ui/Card";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ClientDisplayName, provenanceOf } from "./clientIdentity";
@@ -43,12 +44,6 @@ const styles = stylex.create({
panel: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
},
facts: {
display: "grid",
@@ -73,9 +68,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",
@@ -181,7 +177,11 @@ export default function ClientDetailPage() {
</h1>
<p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p>
<div {...stylex.props(styles.panel)}>
<Card
title="Details"
description="The name nxdns shows for this device, where that name came from, and when the device was last seen."
style={styles.panel}
>
<dl {...stylex.props(styles.facts)}>
<dt {...stylex.props(styles.term)}>Name</dt>
<dd {...stylex.props(styles.value)}>
@@ -197,7 +197,7 @@ export default function ClientDetailPage() {
<dt {...stylex.props(styles.term)}>Last seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd>
</dl>
</div>
</Card>
<h2 {...stylex.props(styles.sectionHeading)}>Policy</h2>
<p {...stylex.props(styles.prose)}>
+1 -1
View File
@@ -185,7 +185,7 @@ export default function ClientsPage() {
) : rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>No clients match this filter.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
@@ -4,6 +4,7 @@ import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { formatCount } from "@/lib/format";
import {
firstProblem,
initPrefixEditor,
@@ -17,7 +18,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 +78,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,
@@ -142,7 +143,7 @@ export default function NetworkAssignments({ prefixes, groups }: Props) {
function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
if (prefixes.length === 0) return <p {...stylex.props(styles.empty)}>The file declares no network assignments.</p>;
return (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
@@ -210,7 +211,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<li key={index} {...stylex.props(styles.row)}>
<input
type="text"
aria-label={`Range ${index + 1}`}
aria-label={`Range ${formatCount(index + 1)}`}
aria-invalid={invalid(index, "prefix")}
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
placeholder="192.168.1.0/24"
@@ -221,7 +222,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/>
<Select
aria-label={`Group for range ${index + 1}`}
aria-label={`Group for range ${formatCount(index + 1)}`}
variant="inline"
value={String(row.group_id)}
onChange={(value) =>
@@ -232,7 +233,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<input
type="text"
inputMode="numeric"
aria-label={`Priority for range ${index + 1}`}
aria-label={`Priority for range ${formatCount(index + 1)}`}
aria-invalid={invalid(index, "priority")}
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
placeholder="100"
+7 -2
View File
@@ -1,3 +1,4 @@
import { formatCount } from "@/lib/format";
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
export interface PrefixRow {
@@ -65,10 +66,14 @@ export interface PrefixProblem {
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
for (const [index, row] of rows.entries()) {
if (row.prefix.trim() === "")
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
return { index, field: "prefix", message: `Row ${formatCount(index + 1)}: prefix is required.` };
const priority = row.priority.trim();
if (priority !== "" && !/^\d+$/.test(priority))
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
return {
index,
field: "priority",
message: `Row ${formatCount(index + 1)}: priority must be a whole number.`,
};
}
return null;
}
@@ -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",
@@ -15,10 +15,13 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
Local records
<code {...stylex.props(shared.mono, config.panelKey)}>local_records</code>
</h2>
<p {...stylex.props(config.panelDescription)}>
Names this resolver answers by itself, without asking an upstream.
</p>
{records.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no local records.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
@@ -52,10 +55,13 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
Forward zones
<code {...stylex.props(shared.mono, config.panelKey)}>forward_zones</code>
</h2>
<p {...stylex.props(config.panelDescription)}>
Domains whose queries go to a resolver of their own instead of the upstream pool.
</p>
{zones.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no forward zones.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { formatTime } from "@/lib/format";
import { formatCount, formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
blocklistsQuery,
@@ -225,7 +225,7 @@ function ClientCountLink({ group }: { group: Group }) {
<Link to="/clients" search={{ group: group.id }} {...stylex.props(shared.focusRing)}>
{count === undefined
? "Clients in this group"
: `${count} client${count === 1 ? "" : "s"} in this group`}
: `${formatCount(count)} client${count === 1 ? "" : "s"} in this group`}
</Link>
</p>
);
@@ -236,6 +236,10 @@ function GroupDetailReadOnly({ group }: { group: Group }) {
<div>
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>Settings</h3>
<p {...stylex.props(config.panelDescription)}>
The group's name and whether its clients get safe search.
</p>
<DefinitionList
items={[
{ label: "Name", zonKey: "groups[].name", value: group.name },
@@ -259,6 +263,7 @@ function GroupSourcesReadOnly({ group }: { group: Group }) {
Assigned sources
<code {...stylex.props(shared.mono, config.panelKey)}>group_sources</code>
</h3>
<p {...stylex.props(config.panelDescription)}>The blocklists that apply to clients in this group.</p>
<QueryPanel query={assigned}>
{(sourceIds) => (
<QueryPanel query={blocklists}>
@@ -274,7 +279,7 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
return (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
@@ -391,6 +396,7 @@ function GroupDetailEditable({ group }: { group: Group }) {
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>Assigned sources</h3>
<p {...stylex.props(config.panelDescription)}>The blocklists that apply to clients in this group.</p>
<QueryPanel query={blocklists}>
{(catalogue) => <GroupSourcesEditor groupId={group.id} blocklists={catalogue} />}
</QueryPanel>
@@ -428,6 +434,9 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
Rules
{!editable && <code {...stylex.props(shared.mono, config.panelKey)}>rules</code>}
</h3>
<p {...stylex.props(config.panelDescription)}>
This group's own block and allow patterns, applied before any list.
</p>
<QueryPanel query={rules}>
{(all) => {
const scoped = all.filter((rule) => rule.group_id === group.id);
@@ -457,7 +466,7 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
return (
<>
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import { formatCount, formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
blocklistCreateMutation,
@@ -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",
@@ -131,10 +131,13 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
Blocklist sources
<code {...stylex.props(shared.mono, config.panelKey)}>blocklist_sources</code>
</h2>
<p {...stylex.props(config.panelDescription)}>
The lists nxdns downloads, and what each one contributed at its last refresh.
</p>
{blocklists.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no blocklist sources.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
@@ -158,12 +161,20 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
</td>
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</td>
<td {...stylex.props(shared.td)}>{String(b.enabled)}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{b.skipped_unsupported_count}
{formatCount(b.domain_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.wildcard_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.exception_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.skipped_regex_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.skipped_unsupported_count)}
</td>
<td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
@@ -219,7 +230,7 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
{blocklists.length === 0 ? (
<p {...stylex.props(config.empty)}>No blocklist sources yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
@@ -257,12 +268,20 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
onChange={() => toggleEnabled(b)}
/>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{b.skipped_unsupported_count}
{formatCount(b.domain_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.wildcard_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.exception_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.skipped_regex_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.skipped_unsupported_count)}
</td>
<td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
@@ -223,7 +223,7 @@ export default function RecordsTab() {
)}
<QueryPanel query={query}>
{(records) => (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
@@ -37,7 +37,7 @@ function renderValue(value: unknown) {
export default function SettingsDefinitions({ settings }: { settings: Settings }) {
return (
<>
{SECTIONS.map(({ section, title, fields }) => {
{SECTIONS.map(({ section, title, description, fields }) => {
const values = sectionValues(settings, section);
const items: Definition[] = (fields as readonly AnyFieldDef[]).map((def) => ({
label: humanize(def.key),
@@ -56,6 +56,7 @@ export default function SettingsDefinitions({ settings }: { settings: Settings }
return (
<section key={section} {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>{title}</h2>
<p {...stylex.props(config.panelDescription)}>{description}</p>
<div {...stylex.props(config.note)}>
<DefinitionList items={items} />
</div>
@@ -5,9 +5,10 @@ import InlineError from "@/lib/InlineError";
import { settingsPutMutation } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsEnvelope } from "@/lib/types";
import { cardStyles } from "@/ui/Card";
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";
@@ -30,18 +31,25 @@ const styles = stylex.create({
margin: 0,
padding: 0,
},
/** A `fieldset` shrinks to its content by default, which would undo the card's own `minWidth: 0`. */
section: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
minInlineSize: 0,
},
/**
* A `legend` is otherwise drawn through the fieldset's top border, which cuts
* the hairline and lifts the title away from its description. A floated
* legend is not the fieldset's rendered legend (HTML rendering, "The fieldset
* and legend elements"), so it lays out as ordinary content inside the
* padding; the full width, and the `clear` on the description, keep the two
* stacked.
*/
legend: {
paddingInline: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
float: "left",
width: "100%",
padding: 0,
},
description: {
clear: "both",
},
/** One column on a phone, two from `sm`. */
fieldGrid: {
@@ -71,7 +79,7 @@ const styles = stylex.create({
gap: "0.25rem",
},
fieldInput: {
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -105,7 +113,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",
@@ -261,9 +269,10 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
return (
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending} {...stylex.props(styles.sections)}>
{SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} {...stylex.props(styles.section)}>
<legend {...stylex.props(styles.legend)}>{title}</legend>
{SECTIONS.map(({ section, title, description, fields }) => (
<fieldset key={section} {...stylex.props(cardStyles.card, styles.section)}>
<legend {...stylex.props(cardStyles.title, styles.legend)}>{title}</legend>
<p {...stylex.props(config.panelDescription, styles.description)}>{description}</p>
<div {...stylex.props(styles.fieldGrid)}>
{(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow
@@ -67,10 +67,13 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
Upstream pool
<code {...stylex.props(shared.mono, config.panelKey)}>upstreams</code>
</h2>
<p {...stylex.props(config.panelDescription)}>
The resolvers nxdns forwards to when neither a local record nor the cache has the answer.
</p>
{upstreams.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no upstreams.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
@@ -143,7 +146,7 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
{upstreams.length === 0 ? (
<p {...stylex.props(config.empty)}>No upstreams yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
@@ -193,7 +193,7 @@ export default function ZonesTab() {
)}
<QueryPanel query={query}>
{(zones) => (
<div {...stylex.props(shared.tableWrap)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
@@ -14,6 +14,8 @@ export interface FieldDef<S extends keyof Settings> {
export interface SectionDef<S extends keyof Settings> {
section: S;
title: string;
/** The card head's one line: what the section governs, in the reader's words. */
description: string;
fields: readonly FieldDef<S>[];
}
@@ -47,6 +49,8 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "upstream",
title: "Upstream",
description:
"How long nxdns waits on the upstream pool, per attempt and in total, and on a forward zone's resolver per read.",
fields: [
{ key: "attempt_timeout_ms", kind: "number" },
{ key: "read_timeout_ms", kind: "number" },
@@ -56,6 +60,8 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "dns",
title: "DNS",
description:
"The addresses and port the resolver listens on, and how many queries one client may send within the rate window.",
fields: [
{ key: "bind_ipv4", kind: "text" },
{ key: "bind_ipv6", kind: "text" },
@@ -67,6 +73,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "blocking",
title: "Blocking",
description: "What a blocked query is answered with, and for how long clients may keep that answer.",
fields: [
{ key: "response", kind: ["zero", "nxdomain"] },
{ key: "ttl", kind: "number" },
@@ -75,6 +82,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "cache",
title: "Cache",
description: "How many answers are kept, and how long a negative answer stays valid.",
fields: [
{ key: "size", kind: "number" },
{ key: "negative_ttl_max", kind: "number" },
@@ -83,6 +91,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "web",
title: "Web",
description: "Where this admin interface listens, how long a login lasts, and its request limits.",
fields: [
{ key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" },
@@ -94,12 +103,29 @@ export const SECTIONS: readonly AnySectionDef[] = [
{ key: "trusted_proxies", kind: "text" },
],
}),
defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }),
defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }),
defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }),
defineSection({
section: "doh_server",
title: "DoH Server",
description: "DNS over HTTPS for clients that speak it: the listener and its certificate.",
fields: TLS_FIELDS,
}),
defineSection({
section: "dot_server",
title: "DoT Server",
description: "DNS over TLS for clients that speak it: the listener and its certificate.",
fields: TLS_FIELDS,
}),
defineSection({
section: "edns",
title: "EDNS",
description: "Whether the client's subnet is passed on to upstreams or stripped from the query.",
fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }],
}),
defineSection({
section: "logging",
title: "Logging",
description:
"What the process log records and where it goes; how query history is buffered, flushed and kept, and which of its fields are hidden.",
fields: [
{ key: "level", kind: ["error", "warn", "info", "debug"] },
{ key: "retention_days", kind: "number" },
@@ -116,6 +142,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "disk",
title: "Disk",
description: "The free space below which nxdns warns, and below which it stops writing history.",
fields: [
{ key: "min_free_mb", kind: "number" },
{ key: "warn_free_mb", kind: "number" },
@@ -124,6 +151,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "blocklist_update",
title: "Blocklist Update",
description: "Whether the blocklists are fetched again on their own, and how often.",
fields: [
{ key: "enabled", kind: "boolean" },
{ key: "interval_hours", kind: "number" },
+23 -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,32 @@ 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,
fontSize: "1.4rem",
lineHeight: 1.2,
fontWeight: 650,
letterSpacing: "-0.015em",
},
/** The card head's one-line description (`ui/Card.tsx`), under a panel heading. */
panelDescription: {
margin: 0,
marginTop: "0.25rem",
marginBottom: "1.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
textWrap: "pretty",
},
/** The collection's own key in the configuration file, beside its heading. */
panelKey: {
@@ -50,7 +64,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 +104,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 +133,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,
@@ -93,7 +93,8 @@ test("an open episode shows its facts, its copy and the error the server sent",
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
expect(screen.getByText("Warning")).toBeTruthy();
expect(screen.getByText("StevenBlack")).toBeTruthy();
expect(screen.getByText("Active for 2h")).toBeTruthy();
// The seconds since the fixture was anchored are the render's to add.
expect(screen.getByText(/^Active for 2h(?: \d+s)?$/)).toBeTruthy();
expect(screen.getByText("Not yet — still failing")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy();
expect(screen.getByText("blocklist.refresh")).toBeTruthy();
@@ -4,11 +4,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { formatDuration, formatTime } from "@/lib/format";
import { formatCount, formatDuration, formatTime } from "@/lib/format";
import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Card from "@/ui/Card";
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 +50,6 @@ const styles = stylex.create({
panel: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
},
facts: {
display: "grid",
@@ -75,9 +70,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 +85,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,
@@ -185,7 +181,11 @@ export default function DiagnosticDetailPage() {
<p {...stylex.props(styles.subject)}>{data.subject}</p>
<InlineError error={purge.error} />
<div {...stylex.props(styles.panel)}>
<Card
title="Details"
description="How long this has been failing, how often it recurred, and whether it has cleared."
style={styles.panel}
>
<dl {...stylex.props(styles.facts)}>
<dt {...stylex.props(styles.term)}>State</dt>
<dd {...stylex.props(styles.value)}>
@@ -198,7 +198,7 @@ export default function DiagnosticDetailPage() {
<dt {...stylex.props(styles.term)}>Last seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(data.last_seen)}</dd>
<dt {...stylex.props(styles.term)}>Occurrences</dt>
<dd {...stylex.props(styles.value, shared.tabularNums)}>{data.occurrences}</dd>
<dd {...stylex.props(styles.value, shared.tabularNums)}>{formatCount(data.occurrences)}</dd>
<dt {...stylex.props(styles.term)}>Resolved</dt>
<dd {...stylex.props(styles.value)}>
{data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)}
@@ -208,7 +208,7 @@ export default function DiagnosticDetailPage() {
<dt {...stylex.props(styles.term)}>Code</dt>
<dd {...stylex.props(styles.value, shared.mono)}>{data.code}</dd>
</dl>
</div>
</Card>
<h2 {...stylex.props(styles.sectionHeading)}>Impact</h2>
<p {...stylex.props(styles.prose)}>{copy.impact}</p>
@@ -153,7 +153,7 @@ test("active episodes come first, each with its title, subject, age and count",
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
expect(within(active).getByText("Warning")).toBeTruthy();
expect(within(active).getByText("StevenBlack")).toBeTruthy();
expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy();
expect(within(active).getByText(/Active for 1h(?: \d+s)? · 3 occurrences/)).toBeTruthy();
const failing = screen.getByText("Upstream failing").closest("li")!;
expect(within(failing).getByText("Error")).toBeTruthy();
@@ -10,13 +10,13 @@ import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api";
import InlineError from "@/lib/InlineError";
import { formatDuration, formatTime } from "@/lib/format";
import { formatCount, formatDuration, formatTime } from "@/lib/format";
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
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,
@@ -235,7 +236,7 @@ function errorMessage(error: unknown): string {
}
function occurrenceText(count: number): string {
return `${count} ${count === 1 ? "occurrence" : "occurrences"}`;
return `${formatCount(count)} ${count === 1 ? "occurrence" : "occurrences"}`;
}
function rowsOf(section: Section): DiagnosticEvent[] {
@@ -356,7 +357,7 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatCount(event.occurrences)}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<button
type="button"
@@ -494,7 +495,7 @@ export default function DiagnosticsPage() {
<p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<thead {...stylex.props(styles.head)}>
<tr>
@@ -522,8 +523,9 @@ export default function DiagnosticsPage() {
</table>
</div>
<p {...stylex.props(styles.footer, styles.note)}>
Showing <span {...stylex.props(shared.tabularNums)}>{historyRows.length}</span> resolved{" "}
{historyRows.length === 1 ? "entry" : "entries"}
Showing{" "}
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "}
resolved {historyRows.length === 1 ? "entry" : "entries"}
{hasMore(history) ? "" : " — end of history"}
</p>
<MoreButton section={history} />
@@ -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)}`;
}
@@ -103,7 +101,7 @@ export function healthFacts(health: Health, locale?: string, timeZone?: string):
label: "Upstreams",
tone: upstreams.state === "unavailable" ? "danger" : "ok",
value: upstreams.state === "unavailable" ? "None reachable" : "Available",
detail: `${upstreams.available} of ${upstreams.total} enabled`,
detail: `${formatCount(upstreams.available)} of ${formatCount(upstreams.total)} enabled`,
...(upstreams.state === "unavailable"
? { link: { kind: "route", to: "/configuration/resolution", label: "Upstreams" } as FactLink }
: {}),
+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,52 @@ 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",
"Other",
"All clients",
]);
expect(screen.getByRole("table").querySelectorAll("th[scope=col]")).toHaveLength(8);
});
test("leaving the chart takes the tooltip and the dimming with it", () => {
const { container } = render(TWO_BUCKETS);
@@ -252,26 +304,25 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
* appear in the legend, the stack, the tooltip and the table saying only that it
* is empty. The named clients stay at zero: a client that went quiet is a fact.
*/
test("a window where Other counted nothing drops it from every surface", () => {
test("a window where Other counted nothing keeps it in the legend and the table, at zero", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 0]));
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual(["192.0.2.30"]);
expect(
within(screen.getByRole("table"))
.getAllByRole("columnheader")
.map((cell) => cell.textContent),
).toEqual(["Time", "192.0.2.30"]);
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"]);
});
test("one query outside the named clients is enough to keep Other", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 1]));
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
"192.0.2.30",
"Other",
]);
expect(
within(screen.getByRole("table"))
.getAllByRole("columnheader")
.map((cell) => cell.textContent),
).toEqual(["Time", "192.0.2.30", "Other"]);
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.30", "Other", "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();
});
+47 -73
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;
@@ -74,23 +49,20 @@ interface Series {
}
/**
* "Other" last, so it sits at the top of every column rather than under a client,
* and dropped entirely when it counted nothing across the window: an aggregation
* bucket that aggregated nothing is a legend entry, a stack key, a tooltip row
* and a table column all saying zero. The named clients stay at zero, because a
* client that went quiet is something the reader wants to see.
* "Other" last, so it sits at the top of every column rather than under a client.
* It is always a series, even at zero across the window (ui-visual-redesign.md:
* eight named clients plus Other): a reader comparing two scopes sees the same
* legend in both, and a zero says the named clients were the whole story.
*/
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;
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
}
@@ -129,7 +101,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
const timestamps = columns.map((column) => column.ts);
const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0));
const plot = plotArea(width);
const plot = plotArea(width, Math.max(...totals));
const xScale = bandScale(timestamps, plot);
// Every series here is a disjoint part of the whole rather than a highlighted
// subset of a separately reported total, so the tallest column's own sum is
@@ -137,19 +109,30 @@ 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 Other, then the total — eight named
* rows would be a table, and the reader pointing at a spike wants to know
* who made it. Other is always there, at zero when the named clients were
* the whole bucket, so the rows read the same from bucket to bucket.
*/
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 ? [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]) }],
};
}
@@ -157,7 +140,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
<ChartRoot containerRef={containerRef}>
<svg
role="img"
aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
@@ -187,6 +170,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 +179,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
width={bar.width}
height={bar.height}
fill={bar.color}
bleed={restsOnAnother ? STACK_BLEED : 0}
/>
);
})}
@@ -202,23 +187,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 +211,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>
+145 -86
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
@@ -8,19 +9,37 @@
* the frame would drift. Nothing here imports a chart.
*/
import { useCallback } from "react";
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 +49,43 @@ 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%" },
},
/**
* 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: {
control: {
minWidth: { default: "11rem", [NARROW]: 0 },
flex: { default: null, [NARROW]: 1 },
},
/** Under the Device control: the list behind it did not load, so the control offers the household only. */
devicesFailed: {
margin: 0,
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
retry: {
padding: 0,
borderWidth: 0,
backgroundColor: "transparent",
font: "inherit",
color: colors.primaryOnSurface,
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 +95,72 @@ 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. A
* list that failed to load is said so under the control, with a retry: the
* household-only list is a failure, not the answer.
*/
function useDeviceOptions(client: string | undefined) {
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 });
}
const { refetch } = clients;
const retry = useCallback(() => void refetch(), [refetch]);
return { options, failed: clients.isError, retry };
}
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.options}
value={scope.client ?? ALL_DEVICES}
onChange={(value) => onChange({ ...scope, client: value === ALL_DEVICES ? undefined : value })}
/>
{devices.failed && (
<p role="alert" {...stylex.props(styles.devicesFailed)}>
Device list unavailable.{" "}
<button type="button" onClick={devices.retry} {...stylex.props(styles.retry, shared.focusRing)}>
Retry
</button>
</p>
)}
</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 +173,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 +193,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>
);
+142 -47
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,13 +268,13 @@ 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 () => {
// The fixture's other series is all zeroes. An aggregation bucket that
// aggregated nothing is a legend entry and a table column that say only that
// they are empty; the named clients stay, because a quiet client is a fact.
test("the client chart keeps Other in a period where it counted nothing", async () => {
// The fixture's other series is all zeroes. Other is still a series, so the
// legend reads the same in every scope and its zero says the named clients
// were the whole story.
renderApp();
await screen.findByRole("heading", { name: "Client activity over time" });
@@ -259,17 +283,27 @@ test("the client chart drops Other in a period where it counted nothing", async
// Twice each: the legend swatch and the column header of the table a screen
// reader gets instead of the graphic.
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2));
expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
expect(within(chart as HTMLElement).getAllByText("Other")).toHaveLength(2);
});
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"]);
});
+97 -105
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) },
],
};
}
@@ -125,18 +112,20 @@ 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 yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
const peak = Math.max(...columns.map((column) => column.queries));
const plot = plotArea(width, peak);
const xScale = pointScale(timestamps, plot);
const yScale = valueScale(peak, [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, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
@@ -150,54 +139,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 +201,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>
@@ -1,7 +1,12 @@
import { render } from "@testing-library/react";
import { formatCount } from "@/lib/format";
import {
CHART_HEIGHT,
ChartFrame,
ChartTooltip,
MARGIN,
TICK_GLYPH_PX,
TICK_LABEL_GAP_PX,
bandPaddingInner,
bandScale,
labelTickValues,
@@ -33,6 +38,91 @@ describe("valueScale", () => {
expect(scale.ticks(5)).toEqual([0, 500, 1000, 1500]);
expect(valueTicks(scale)).toEqual([0, 500, 1000, 1500]);
});
/**
* A query count is a whole number. d3 answers a domain of [0, 1] with fifths,
* and "0.5" queries is a quantity the data cannot hold, so the fractional
* ticks are dropped from the grid and the axis alike.
*/
test("a single-digit peak is ticked in whole queries", () => {
expect(valueTicks(valueScale(1, [240, 0]))).toEqual([0, 1]);
expect(valueTicks(valueScale(2, [240, 0]))).toEqual([0, 1, 2]);
});
});
describe("plotArea", () => {
/**
* The value labels are grouped counts, so a chart that peaks in the millions
* needs more room to its left than the default margin: "1,250,000" does not
* fit where "120" does. The width is measured on a quarter above the peak,
* because the tick above it is the widest label drawn.
*/
test("the left margin grows with the widest value label", () => {
expect(plotArea(640, 0).x).toBe(MARGIN.left);
const widest = formatCount(Math.ceil(1_000_000 * 1.25));
expect(plotArea(640, 1_000_000).x).toBe(widest.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
expect(plotArea(640, 1_000_000).x).toBeGreaterThan(MARGIN.left);
});
});
/**
* `TooltipWithBounds` writes its own inline `transform` to place itself, and it
* decides whether to flip above the anchor from that same number. Centring a
* beside tooltip therefore has to reach the library as an `offsetTop`, measured
* from the box, and not as a CSS transform the library cannot see.
*/
describe("ChartTooltip", () => {
const BOX_HEIGHT = 40;
function withMeasuredBox(run: () => void) {
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => BOX_HEIGHT,
});
try {
run();
} finally {
if (original) Object.defineProperty(HTMLElement.prototype, "offsetHeight", original);
else Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
}
}
function renderTooltip(beside: boolean) {
const { container } = render(
<ChartTooltip
content={{ title: "12:00", rows: [{ key: "queries", label: "Total queries", value: "7" }] }}
index={0}
left={100}
top={100}
beside={beside}
/>,
);
return container.querySelector(".visx-tooltip") as HTMLElement;
}
/**
* jsdom measures nothing, so both rects are zero-sized: the library takes its
* window branch, nothing is clipped at 100px into a 1024x768 window, and the
* unflipped `translate(left + offsetLeft, top + offsetTop)` is what it writes.
*/
test("a beside tooltip is offset by half the height it measures", () => {
withMeasuredBox(() => {
expect(renderTooltip(true).style.transform).toBe(`translate(116px, ${100 - BOX_HEIGHT / 2}px)`);
});
});
test("a hung tooltip clears its anchor by the corner offset", () => {
withMeasuredBox(() => {
expect(renderTooltip(false).style.transform).toBe("translate(108px, 108px)");
});
});
/** An unmeasured box is not centred at all, rather than centred on a guess. */
test("no measurable height leaves the tooltip on the anchor", () => {
expect(renderTooltip(true).style.transform).toBe("translate(116px, 100px)");
});
});
describe("bandPaddingInner", () => {
+210 -84
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,14 +13,20 @@ 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, formatCount } 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;
/** The 10px tick label's glyph width; digits and the comma in a grouped count are all about this wide. */
export const TICK_GLYPH_PX = 6;
/** The tick label's gap to the axis (`dx` below) plus a little air. */
export const TICK_LABEL_GAP_PX = 10;
/** The width a chart draws at before a measurement exists — jsdom, and the first paint. */
const FALLBACK_WIDTH = 640;
@@ -41,12 +47,20 @@ export interface Plot {
bottom: number;
}
export function plotArea(width: number): Plot {
/**
* The plot inside the axes. The left margin grows with the widest y label the
* chart can show: counts are thousands-grouped everywhere, ticks included, so
* "120,000" needs the room "120" does not. The tick above `maxValue` can add a
* digit or a comma, so the width is measured on a quarter more.
*/
export function plotArea(width: number, maxValue = 0): Plot {
const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom);
const label = formatCount(Math.ceil(maxValue * 1.25));
const left = Math.max(MARGIN.left, label.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
return {
x: MARGIN.left,
x: left,
y: MARGIN.top,
width: Math.max(0, width - MARGIN.left - MARGIN.right),
width: Math.max(0, width - left - MARGIN.right),
height,
bottom: MARGIN.top + height,
};
@@ -86,9 +100,13 @@ export function valueScale(max: number, range: [number, number]) {
export type ValueScale = ReturnType<typeof valueScale>;
/** The tick values the grid and the value axis both draw. */
/**
* The tick values the grid and the value axis both draw. A query count is a
* whole number, so d3's fractional ticks 0.5 of a query at a peak of 1 are
* dropped rather than labelled.
*/
export function valueTicks(scale: ValueScale): number[] {
return scale.ticks(Y_TICK_COUNT);
return scale.ticks(Y_TICK_COUNT).filter(Number.isInteger);
}
/**
@@ -102,7 +120,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 +131,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 +155,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 +200,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 +215,8 @@ 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 +227,12 @@ 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)",
},
/** The positioning shell: `TooltipWithBounds` owns its inline transform, so nothing visual may sit here. */
tooltipShell: {
pointerEvents: "none",
position: "absolute",
zIndex: layers.tooltip,
},
tooltipTitle: {
fontWeight: 500,
},
@@ -201,7 +260,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 +342,7 @@ export function ChartFrame({
plot: Plot;
yScale: ValueScale;
yTicks: number[];
xScale: BandScale;
xScale: TimeScale;
xTickValues: number[];
bucketSeconds: number;
}) {
@@ -298,7 +357,7 @@ export function ChartFrame({
hideAxisLine
hideTicks
tickLength={0}
tickFormat={(value) => compact.format(Number(value))}
tickFormat={(value) => formatCount(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 +379,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 +389,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 +447,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
@@ -444,24 +498,41 @@ export interface TooltipContent {
/**
* `TooltipWithBounds` positions itself with an inline transform and drops it
* when `unstyled` is set, so the default look is replaced by handing it an empty
* style object rather than by turning styling off.
* style object rather than by turning styling off. What it keeps is the
* positioning; the box the reader sees is the element inside it.
*/
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. The centring is a
* negative half-height `offsetTop`, not a CSS transform, so that the flip
* `TooltipWithBounds` computes from its own rect sees where the box really
* lands. It flips the box to the anchor's left when it would run past the
* right edge, and above the anchor when it would run past the bottom.
*/
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.
@@ -469,33 +540,88 @@ export function ChartTooltip({
// a digit boundary, or resolve a client's name, and the stale width would place
// it wrongly at the right edge.
const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|");
const box = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
// The centring offset is half the box's own height, so it cannot be known
// before the box exists. `measureKey` remounts the whole tooltip per content
// change, so one measurement per mount covers every size the box takes.
useLayoutEffect(() => {
if (box.current) setHeight(box.current.offsetHeight);
}, []);
return (
<TooltipWithBounds
key={measureKey}
left={left}
top={top}
offsetLeft={TOOLTIP_OFFSET}
offsetTop={TOOLTIP_OFFSET}
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
style={NO_INLINE_STYLE}
className={stylex.props(styles.tooltip).className}
className={stylex.props(styles.tooltipShell).className}
>
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
<dl {...stylex.props(styles.tooltipList)}>
{content.rows.map((row) => (
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
{row.color !== undefined && (
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
/>
)}
{row.label}
</dt>
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
</div>
))}
</dl>
<div ref={box} {...stylex.props(styles.tooltip)}>
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
<dl {...stylex.props(styles.tooltipList)}>
{content.rows.map((row) => (
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
{row.color !== undefined && (
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
/>
)}
{row.label}
</dt>
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
</div>
))}
</dl>
</div>
</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,44 @@
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("::1")).toBe("::1");
expect(parseClient("::ffff:192.0.2.30")).toBe("192.0.2.30");
expect(parseClient("fe80::1%25eth0")).toBeUndefined();
// An IPv4 tail is the last 32 bits, so nothing may follow it.
expect(parseClient("192.0.2.30::")).toBeUndefined();
expect(parseClient("::192.0.2.30:1")).toBeUndefined();
expect(parseClient("2001:db8:0:0:0:0:0:0:1")).toBeUndefined();
expect(parseClient("2001:db8::1::2")).toBeUndefined();
expect(parseClient("2001:db8::1:2:3:4:5:6:7")).toBeUndefined();
expect(parseClient("256.0.0.1")).toBeUndefined();
expect(parseClient("192.0.2")).toBeUndefined();
expect(parseClient(" 192.0.2.30")).toBeUndefined();
expect(parseClient("abc")).toBeUndefined();
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();
});
test("an IPv6 address is reduced to the spelling the logger stores", () => {
expect(parseClient("2001:DB8::1")).toBe("2001:db8::1");
expect(parseClient("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe("2001:db8::1");
expect(parseClient("2001:db8:0:0:1:0:0:1")).toBe("2001:db8::1:0:0:1");
expect(parseClient("1:0:0:1:0:0:0:1")).toBe("1:0:0:1::1");
expect(parseClient("0:0:0:0:0:0:0:0")).toBe("::");
expect(parseClient("2001:db8:0:1:1:1:1:1")).toBe("2001:db8:0:1:1:1:1:1");
});
/**
* `src/platform/address.zig` prints hex groups with RFC 5952 compression and
* nothing else, and it normalizes an IPv4-mapped address to the plain IPv4, so
* a dotted tail in a pasted link has to be folded the same way to match.
*/
test("an IPv4 tail is folded into hextets, and a mapped address into its IPv4", () => {
expect(parseClient("2001:db8::192.0.2.30")).toBe("2001:db8::c000:21e");
expect(parseClient("::ffff:192.0.2.30")).toBe("192.0.2.30");
expect(parseClient("::FFFF:C000:021E")).toBe("192.0.2.30");
});
@@ -0,0 +1,96 @@
/**
* The device Overview is scoped to, as URL state beside the period.
*
* The API takes one exact address (no list) and matches the text the logger
* stored, so that is what the route lets through: a value that is not an IPv4
* or IPv6 address is dropped from the URL rather than sent on to a 400, and an
* IPv6 address is reduced to the RFC 5952 spelling the logger writes, so an
* uppercase or expanded form in a pasted link still finds its client. The
* server writes hex groups only, so an IPv4 tail is folded into the two hextets
* it names and an IPv4-mapped address is reduced to its plain IPv4. An absent
* scope is the whole household.
*/
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
const HEXTET = /^[0-9a-f]{1,4}$/i;
/** The two hextets an IPv4 tail names: `192.0.2.30` is `c000:21e`. */
function ipv4Hextets(dotted: string): string[] {
const octets = dotted.split(".").map(Number);
return [((octets[0] << 8) | octets[1]).toString(16), ((octets[2] << 8) | octets[3]).toString(16)];
}
/**
* One side of a `::` as hextets, or null. An IPv4 tail is folded into the two
* hextets it names the server prints an IPv6 address as hex groups only,
* never with a dotted tail and it is read only where the address ends, since
* the tail is the last 32 bits and nothing may follow it.
*/
function sideHextets(parts: string[], dottedAllowed: boolean): string[] | null {
const last = parts[parts.length - 1];
const hextets =
last !== undefined && last.includes(".")
? dottedAllowed && IPV4.test(last)
? [...parts.slice(0, -1), ...ipv4Hextets(last)]
: null
: parts;
if (hextets === null) return null;
return hextets.every((group) => HEXTET.test(group)) ? hextets : null;
}
/**
* RFC 4291 text up to eight hextets, one `::` at most, an IPv4 tail allowed
* as the last 32 bits parsed to its eight hextets, or null.
*/
function ipv6Groups(value: string): string[] | null {
const halves = value.split("::");
if (halves.length > 2) return null;
const split = halves.length === 2;
const head = sideHextets(halves[0] === "" ? [] : (halves[0] as string).split(":"), !split);
const tail = sideHextets(split && halves[1] !== "" ? (halves[1] as string).split(":") : [], split);
if (head === null || tail === null) return null;
const width = head.length + tail.length;
if (split ? width >= 8 : width !== 8) return null;
const zeros = Array.from({ length: 8 - width }, () => "0");
const expanded = split ? [...head, ...zeros, ...tail] : head;
return expanded.map((group) => group.replace(/^0+(?=.)/, "").toLowerCase());
}
/** RFC 5952: lowercase, no leading zeros, the longest run of two or more zero groups as `::` (the first on a tie). */
function compressIpv6(groups: string[]): string {
let best = { start: -1, length: 0 };
for (let i = 0; i < groups.length;) {
if (groups[i] !== "0") {
i += 1;
continue;
}
let j = i;
while (j < groups.length && groups[j] === "0") j += 1;
if (j - i >= 2 && j - i > best.length) best = { start: i, length: j - i };
i = j;
}
if (best.start < 0) return groups.join(":");
const head = groups.slice(0, best.start).join(":");
const tail = groups.slice(best.start + best.length).join(":");
return `${head}::${tail}`;
}
/**
* The dotted IPv4 an IPv4-mapped address stands for, or null. The server
* normalizes `::ffff:a.b.c.d` to the plain `a.b.c.d`, so a link that spells the
* mapped form has to be reduced the same way to find its client.
*/
function mappedIpv4(groups: string[]): string | null {
if (groups.slice(0, 5).some((group) => group !== "0") || groups[5] !== "ffff") return null;
const high = Number.parseInt(groups[6] as string, 16);
const low = Number.parseInt(groups[7] as string, 16);
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
}
export function parseClient(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
if (IPV4.test(value)) return value;
const groups = ipv6Groups(value);
if (groups === null) return undefined;
return mappedIpv4(groups) ?? compressIpv6(groups);
}
@@ -6,8 +6,8 @@
* completion. One request cannot disagree with itself, so those behaviours have
* no subject left and are gone rather than ported. What survived the collapse is
* pinned below: the three states, and the one rule a single request still does
* not settle that a `keepPreviousData` body from the period the reader left
* must never render under the new period's label.
* not settle that the body of the scope the reader just left, period or
* device, must never render under the new scope's label.
*/
import { render, screen, waitFor } from "@testing-library/react";
@@ -63,8 +63,8 @@ afterEach(() => vi.unstubAllGlobals());
/** The last retry the hook handed out, so a test can spend it. */
let lastRetry: () => void;
function Probe({ period }: { period: Period }) {
const panel = useOverviewWindow(period);
function Probe({ period, client }: { period: Period; client?: string }) {
const panel = useOverviewWindow(period, client);
if (panel.status === "error") lastRetry = panel.retry;
const detail =
panel.status === "ready"
@@ -75,18 +75,18 @@ function Probe({ period }: { period: Period }) {
return <p>{`${panel.status}:${detail}`}</p>;
}
function renderProbe(period: Period = "24h") {
function renderProbe(period: Period = "24h", scope?: string) {
const client = createQueryClient();
const view = render(
<QueryClientProvider client={client}>
<Probe period={period} />
<Probe period={period} client={scope} />
</QueryClientProvider>,
);
return {
rerenderWith: (next: Period) =>
rerenderWith: (next: Period, nextScope?: string) =>
view.rerender(
<QueryClientProvider client={client}>
<Probe period={next} />
<Probe period={next} client={nextScope} />
</QueryClientProvider>,
),
};
@@ -115,13 +115,24 @@ test("a failed request is one error for the whole page, with a retry that refetc
expect(calls).toBeGreaterThan(spent);
});
test("a retained previous-period body never renders under the new period's label", async () => {
test("a previous period's body never renders under the new period's label", async () => {
const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
rerenderWith("1h");
// `keepPreviousData` is holding the 24h body. It is a complete answer and
// still the wrong one to draw under "1h", so the page waits.
// The 24h body is a complete answer and still the wrong one to draw under
// "1h", so the page waits for its own.
expect(line()).toBe("loading:");
await waitFor(() => expect(line()).toBe(`ready:1h@${UNTIL}`));
});
test("rescoping to a device under the same period waits for that device's body", async () => {
const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
rerenderWith("24h", "192.0.2.30");
// Same period, so the household body would pass a period check; it is
// still the wrong scope to draw under the device's name.
expect(line()).toBe("loading:");
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
});
@@ -8,27 +8,27 @@
* breakdowns describe the same span and the same database state by construction,
* and none of that reconciliation has anything left to reconcile.
*
* What remains is the one rule a single request does not settle by itself.
* `keepPreviousData` holds the body of the period the reader just left a
* complete, self-consistent answer, and still the wrong one to draw under the
* new label so a body is a member of this window only while its own `period`
* is the selected one. Until then the page is loading.
* The query key carries the period and the client, so the body the hook
* returns is always the body of the scope the toolbar names. A rescope shows
* the loading state until its own answer lands rather than the previous
* scope's charts under the new label: a complete, self-consistent body for the
* wrong device is still the wrong body.
*/
import { useCallback } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { overviewQuery } from "@/lib/queries";
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));
const { refetch } = query;
const retry = useCallback(() => void refetch(), [refetch]);
if (query.isError) return { status: "error", error: query.error, retry };
if (query.data !== undefined && query.data.period === period) return { status: "ready", data: query.data };
if (query.data !== undefined) return { status: "ready", data: query.data };
return { status: "loading" };
}
@@ -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,
+2 -2
View File
@@ -1,12 +1,12 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Coverage } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
const styles = stylex.create({
notice: {
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
+2 -1
View File
@@ -3,6 +3,7 @@ import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { formatDuration } from "@/lib/format";
const styles = stylex.create({
message: {
@@ -45,7 +46,7 @@ export default function InlineError({ error, onRetry }: { error: unknown; onRetr
if (error.status === 429) {
message =
remaining !== null && remaining > 0
? `Rate limited. Try again in ${remaining}s.`
? `Rate limited. Try again in ${formatDuration(remaining)}.`
: "Rate limited. Try again.";
} else if (error.status === 503) {
message = "The server is starting or degraded. Try again shortly.";
+3 -2
View File
@@ -119,8 +119,9 @@ export const getQueryDetail = (id: number): Promise<QueryDetail> => request(`/ap
/** `EventSource` URL for the live stream; not a fetch route. */
export const liveQueriesUrl = "/api/queries/live";
/** Every Overview panel for one window, from one read transaction. */
export const getOverview = (period?: Period): Promise<Overview> => request(`/api/overview${qs({ period })}`);
/** Every Overview panel for one window, from one read transaction; `client` scopes it to one device. */
export const getOverview = (period?: Period, client?: string): Promise<Overview> =>
request(`/api/overview${qs({ period, client })}`);
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
+77 -13
View File
@@ -1,8 +1,17 @@
import { formatBytes, formatClock, formatDuration, formatMicros, formatTime } from "@/lib/format";
import {
formatBytes,
formatClock,
formatCount,
formatDuration,
formatMicros,
formatPercent,
formatRate,
formatTime,
} from "@/lib/format";
test("formatTime renders unix seconds in the given locale and zone", () => {
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
expect(formatTime(1704067200, "en-US", "UTC").replace(//g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
expect(formatTime(1704067200, "en-US", "UTC").replace(/\u202f/g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
});
test("formatClock states the time of day alone, for a stamp read against now", () => {
@@ -12,6 +21,33 @@ test("formatClock states the time of day alone, for a stamp read against now", (
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).not.toMatch(/2026/);
});
test("formatCount groups thousands", () => {
expect(formatCount(0)).toBe("0");
expect(formatCount(999)).toBe("999");
expect(formatCount(18432)).toBe("18,432");
expect(formatCount(1_234_567)).toBe("1,234,567");
});
test("formatPercent always carries two decimals, rounded half up", () => {
expect(formatPercent(0)).toBe("0.00%");
expect(formatPercent(1)).toBe("100.00%");
expect(formatPercent(0.1544)).toBe("15.44%");
expect(formatPercent(0.154)).toBe("15.40%");
// A stored 15.435 sits just under the half; it still rounds up.
expect(formatPercent(0.15435)).toBe("15.44%");
expect(formatPercent(0.15434)).toBe("15.43%");
expect(formatPercent(0.005)).toBe("0.50%");
expect(formatPercent(0.00005)).toBe("0.01%");
expect(formatPercent(0.00004)).toBe("0.00%");
});
test("formatRate carries one decimal, rounded half up", () => {
expect(formatRate(12.84)).toBe("12.8");
expect(formatRate(12.85)).toBe("12.9");
expect(formatRate(5)).toBe("5.0");
expect(formatRate(0)).toBe("0.0");
});
test("formatBytes humanizes with binary units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(1023)).toBe("1023 B");
@@ -22,21 +58,25 @@ test("formatBytes humanizes with binary units", () => {
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
});
test("formatDuration steps up a unit at each boundary and truncates", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(59)).toBe("59s");
test("formatDuration is the two largest nonzero units, unpadded", () => {
expect(formatDuration(45)).toBe("45s");
expect(formatDuration(60)).toBe("1m");
expect(formatDuration(3599)).toBe("59m");
expect(formatDuration(725)).toBe("12m 5s");
expect(formatDuration(3600)).toBe("1h");
expect(formatDuration(10800)).toBe("3h");
expect(formatDuration(86399)).toBe("23h");
expect(formatDuration(86400)).toBe("1d");
expect(formatDuration(400000)).toBe("4d");
expect(formatDuration(3600 * 4 + 60 * 12 + 30)).toBe("4h 12m");
expect(formatDuration(86400 * 6 + 3600 * 4)).toBe("6d 4h");
// The two largest that are nonzero, whatever sits between them.
expect(formatDuration(86400 * 6 + 5)).toBe("6d 5s");
expect(formatDuration(86400 * 6)).toBe("6d");
expect(formatDuration(86400 * 400)).toBe("400d");
// Never "06d 4h" or "6d 04h".
expect(formatDuration(86400 * 6 + 3600 * 4)).not.toMatch(/0\d/);
});
test("formatDuration is never negative", () => {
// Clock skew between the server's timestamps and the browser's clock.
expect(formatDuration(-5)).toBe("0s");
test("formatDuration reads under a second as such, skew included", () => {
expect(formatDuration(0)).toBe("<1s");
expect(formatDuration(0.9)).toBe("<1s");
expect(formatDuration(-5)).toBe("<1s");
});
test("formatMicros renders milliseconds with one decimal", () => {
@@ -45,3 +85,27 @@ test("formatMicros renders milliseconds with one decimal", () => {
expect(formatMicros(999)).toBe("1.0 ms");
expect(formatMicros(2_500_000)).toBe("2500.0 ms");
});
/**
* The contract's teeth, as far as a text scan can bite: no source outside this
* module calls a number-formatting API. A bare `{count}` in JSX is invisible to
* this scan and is caught in review; the scan closes the door on the four ways
* a competing rule would be written. `datetime-local` zero-padding in
* `activity/datetime.ts` is a wire grammar, not a display rule, and is the one
* allowed `padStart`. Identifiers, configured values and preset labels are not
* quantities at all and are exempt by the header rule, so the scan never sees
* them.
*/
test("no source file outside format.ts calls a number-formatting API", () => {
const sources = import.meta.glob<string>("../**/*.{ts,tsx}", { query: "?raw", import: "default", eager: true });
const offenders: string[] = [];
for (const [path, text] of Object.entries(sources)) {
if (/\.test\.tsx?$/.test(path) || /(^|\/)format\.ts$/.test(path)) continue;
for (const pattern of [/\.toFixed\(/, /\.toLocaleString\(/, /Intl\.NumberFormat/]) {
if (pattern.test(text)) offenders.push(`${path}: ${pattern.source}`);
}
if (/\.padStart\(/.test(text) && !path.endsWith("/activity/datetime.ts")) offenders.push(`${path}: padStart`);
}
expect(Object.keys(sources).length).toBeGreaterThan(50);
expect(offenders).toEqual([]);
});
+80 -9
View File
@@ -1,3 +1,23 @@
/**
* Every number the reader sees, formatted in one place (milestone 39).
*
* The rules are fixed so that two surfaces never spell one quantity two ways:
* counts are grouped, percentages always carry two decimals, rates one, and a
* duration is its two largest nonzero units with no zero padding. A call site
* that formats inline is a defect; the sweep test in `format.test.ts` and the
* review both hunt for one.
*
* What the contract covers is a measured quantity the reader compares: a count,
* a share, a rate, a duration, a size in bytes, a time. Three kinds of number
* are not quantities and render as written. An identifier or a protocol code
* a group or source id, an RCODE, a QCLASS, an unknown QTYPE's number names a
* thing rather than measuring one, though a row's ordinal is a count. A
* configured value shown next to or inside the input that edits it a TTL of
* 3600, a cache size of 10000, a priority must read back exactly as the
* operator typed it. And the fixed label of a preset in a menu "Past 24
* hours", "5 minutes" is copy, not a measurement.
*/
/** Unix seconds → localized date-time. `locale`/`timeZone` exist for deterministic tests. */
export function formatTime(unixSeconds: number, locale?: string, timeZone?: string): string {
return new Intl.DateTimeFormat(locale, {
@@ -18,6 +38,49 @@ export function formatClock(unixSeconds: number, locale?: string, timeZone?: str
);
}
/**
* The label on a chart's time axis: the day for buckets a day or wider, the
* time of day otherwise.
*/
export function formatBucketTime(unixSeconds: number, bucketSeconds: number): string {
const date = new Date(unixSeconds * 1000);
if (bucketSeconds >= 86_400) {
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
}
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
}
const grouped = new Intl.NumberFormat("en-US");
/** A count, thousands-grouped: 18432 → "18,432". */
export function formatCount(count: number): string {
return grouped.format(count);
}
/**
* Round half up at `decimals` places. `toFixed` rounds the binary value, so
* 15.435 (stored just under) would print 15.43; the nudge lifts an exact half
* over the edge without moving anything else.
*/
function roundHalfUp(value: number, decimals: number): string {
const scale = 10 ** decimals;
return (Math.round(value * scale + 1e-9) / scale).toFixed(decimals);
}
/**
* A share as a percentage, always two decimals: 0.1544 "15.44%", 0
* "0.00%", 1 "100.00%". The caller decides what a share of nothing means; a
* total of zero is not this function's to guess.
*/
export function formatPercent(fraction: number): string {
return `${roundHalfUp(fraction * 100, 2)}%`;
}
/** A rate, one decimal: 12.84 → "12.8", 5 → "5.0". */
export function formatRate(value: number): string {
return roundHalfUp(value, 1);
}
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
export function formatBytes(bytes: number): string {
@@ -29,28 +92,36 @@ export function formatBytes(bytes: number): string {
value /= 1024;
if (value < 1024) break;
}
return `${value.toFixed(1)} ${unit}`;
return `${roundHalfUp(value, 1)} ${unit}`;
}
const AGE_UNITS = [
const DURATION_UNITS = [
{ seconds: 86400, suffix: "d" },
{ seconds: 3600, suffix: "h" },
{ seconds: 60, suffix: "m" },
{ seconds: 1, suffix: "s" },
] as const;
/**
* Seconds of elapsed time a coarse "3h". Truncating and single-unit on
* purpose, for a span the caller labels itself, as in "active for 3h". A
* negative span reads "0s": clock skew is not a duration.
* Seconds of elapsed time its two largest nonzero units, unpadded: "6d 4h",
* "4h 12m", "12m 5s", "6d 5s", "45s". Two units, because "6d" alone hides
* four hours and "6d 4h 12m 5s" is a stopwatch; nonzero, because "6d 0h" says
* nothing "6d 5s" does not. Anything under a second, a negative span included
* clock skew is not a duration reads "<1s".
*/
export function formatDuration(seconds: number): string {
for (const unit of AGE_UNITS) {
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix}`;
let rest = Math.floor(seconds);
if (rest < 1) return "<1s";
const parts: string[] = [];
for (const unit of DURATION_UNITS) {
const amount = Math.floor(rest / unit.seconds);
rest -= amount * unit.seconds;
if (amount > 0) parts.push(`${amount}${unit.suffix}`);
}
return `${Math.max(0, Math.floor(seconds))}s`;
return parts.slice(0, 2).join(" ");
}
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
export function formatMicros(micros: number): string {
return `${(micros / 1000).toFixed(1)} ms`;
return `${formatRate(micros / 1000)} ms`;
}
+5 -5
View File
@@ -21,7 +21,7 @@ import type {
export const queryKeys = {
health: ["health"] as const,
version: ["version"] as const,
overview: (period: Period) => ["overview", period] as const,
overview: (period: Period, client: string | undefined) => ["overview", period, client ?? ""] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
queryDetail: (id: number) => ["queries", "detail", id] as const,
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
@@ -48,12 +48,12 @@ export const healthQuery = () =>
queryOptions({ queryKey: queryKeys.health, queryFn: api.getHealth, refetchInterval: 10_000 });
export const versionQuery = () =>
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, refetchInterval: 60_000 });
export const overviewQuery = (period: Period = "24h") =>
export const overviewQuery = (period: Period = "24h", client?: string) =>
queryOptions({
queryKey: queryKeys.overview(period),
queryFn: () => api.getOverview(period),
queryKey: queryKeys.overview(period, client),
queryFn: () => api.getOverview(period, client),
refetchInterval: 30_000,
});
+16 -28
View File
@@ -40,6 +40,7 @@ import {
upstreamsQuery,
} from "@/lib/queries";
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
import { parseClient } from "@/features/overview/clientScope";
import { OverviewPending } from "@/features/overview/OverviewFrame";
import {
validateGroupId,
@@ -51,6 +52,7 @@ import {
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { formatDuration } from "@/lib/format";
export interface RouterContext {
queryClient: QueryClient;
@@ -104,7 +106,10 @@ function RouteError({ error }: ErrorComponentProps) {
detail = error.message;
} else if (error.status === 429) {
title = "Rate limited";
detail = error.retryAfter !== undefined ? `Try again in ${error.retryAfter}s.` : "Try again shortly.";
detail =
error.retryAfter !== undefined
? `Try again in ${formatDuration(error.retryAfter)}.`
: "Try again shortly.";
} else if (error.status >= 500) {
title = "Internal error";
} else {
@@ -156,17 +161,21 @@ const indexRoute = createRoute({
});
/**
* Overview. The period is the whole of its applied state, so a view of the page
* is a link: a hand-typed or stale value falls back to the default rather than
* reaching the API as a parameter it answers 400 to.
* Overview. The period and the device scope are the whole of its applied state,
* so a view of the page is a link: a hand-typed or stale value falls back to
* the default rather than reaching the API as a parameter it answers 400 to.
*/
const overviewRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/overview",
validateSearch: (search: Record<string, unknown>): { period?: Period } => ({
validateSearch: (search: Record<string, unknown>): { period?: Period; client?: string } => ({
period: parsePeriod(search["period"]),
client: parseClient(search["client"]),
}),
loaderDeps: ({ search }): { period: Period; client: string | undefined } => ({
period: search.period ?? DEFAULT_PERIOD,
client: search.client,
}),
loaderDeps: ({ search }): { period: Period } => ({ period: search.period ?? DEFAULT_PERIOD }),
/**
* Started here, awaited nowhere. The page reads these with `useQuery` and owns
* its own loading and error surface, so awaiting would trade that contract for
@@ -177,7 +186,7 @@ const overviewRoute = createRoute({
loader: ({ context, deps }) => {
const start = (promise: Promise<unknown>) => void promise.catch(() => {});
start(context.queryClient.ensureQueryData(healthQuery()));
start(context.queryClient.ensureQueryData(overviewQuery(deps.period)));
start(context.queryClient.ensureQueryData(overviewQuery(deps.period, deps.client)));
// The registered names the client chart labels its series with. Started here
// so the lookup is not a second round trip after the page chunk lands.
start(context.queryClient.ensureQueryData(clientsQuery()));
@@ -402,28 +411,7 @@ const systemRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/configuration/SystemPage")),
});
// PROTO-OVERVIEW fence start (throwaway — delete with admin/src/proto/)
// A dev-only design-exploration route: four full-page Overview variants behind a
// floating picker. It hangs off the root rather than the shell, so a variant is
// judged as a page and not as the nav around it. `import.meta.env.DEV` is a
// literal `false` in a production build, so the array folds to empty and the
// dynamic import below is dead code Rollup drops — the proto bytes never reach
// dist.
const protoRoutes = import.meta.env.DEV
? [
createRoute({
getParentRoute: () => rootRoute,
path: "/proto/overview",
// No `validateSearch`: the picker owns `?v=` with history.replaceState,
// and this route never navigates, so the parameter survives untouched.
component: lazyRouteComponent(() => import("@/proto/ProtoOverview")),
}),
]
: [];
// PROTO-OVERVIEW fence end
const routeTree = rootRoute.addChildren([
...protoRoutes,
loginRoute,
shellRoute.addChildren([
indexRoute,
+24 -14
View File
@@ -9,7 +9,7 @@ import { health } from "@/lib/healthFixture";
import type { ConfigStatus, Health } from "@/lib/types";
const NAV_LABELS = ["Overview", "Activity", "Clients", "Diagnostics"];
const CONFIGURATION_LABELS = ["Protection", "Resolution", "System"];
const SYSTEM_LABELS = ["Protection", "Resolution", "System", "Diagnostics"];
/** The pages the redesign folded into the three configuration ones. */
const GONE_LABELS = ["Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", "Settings"];
@@ -110,7 +110,7 @@ test("shell renders the overview route with all nav links", async () => {
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav).toBeTruthy();
for (const label of [...NAV_LABELS, ...CONFIGURATION_LABELS]) {
for (const label of [...NAV_LABELS, ...SYSTEM_LABELS]) {
expect(screen.getByRole("link", { name: label })).toBeTruthy();
}
for (const label of GONE_LABELS) {
@@ -127,16 +127,16 @@ test("main carries the ids the router scrolls and restores", async () => {
expect(main.getAttribute("data-scroll-restoration-id")).toBe("main");
});
test("the three configuration pages sit under a labelled group, after the rest", async () => {
test("the configuration pages and Diagnostics sit under the System group, after Monitoring", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const group = screen.getByRole("list", { name: "Configuration" });
const group = screen.getByRole("list", { name: "System" });
expect(
within(group)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual(CONFIGURATION_LABELS);
).toEqual(SYSTEM_LABELS);
// The group is a section of Main, not a nav of its own.
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav.contains(group)).toBe(true);
@@ -156,14 +156,14 @@ test("under file authority the nav states the file and when it was loaded", asyn
const line = await screen.findByText(/^File-managed ·/);
expect(line.textContent).toBe(`File-managed · ${CONFIG_PATH} · loaded ${formatTime(RECONCILED_AT)}`);
const group = screen.getByRole("list", { name: "Configuration" });
const group = screen.getByRole("list", { name: "System" });
expect(group.parentElement?.contains(line)).toBe(true);
});
test("under database authority there is no authority line to read", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await screen.findByRole("list", { name: "Configuration" });
await screen.findByRole("list", { name: "System" });
expect(screen.queryByText(/File-managed/)).toBeNull();
});
@@ -253,16 +253,26 @@ test("Log out sits in the sidebar on wide, and only in the header below it", asy
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
});
test("the header carries no protection display at all any more", async () => {
test("the header carries the protection state as a reading, never as a control", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
// Scoped to the header: "Protection" is a nav destination now, and that is
// not the status pill this test buried.
const header = within(document.querySelector("header") as HTMLElement);
for (const gone of [/^Protection/, /^Paused/]) {
expect(header.queryByRole("link", { name: gone })).toBeNull();
expect(header.queryByText(gone)).toBeNull();
}
await waitFor(() => expect(header.getByText("Protection active")).toBeTruthy());
expect(header.queryByRole("link", { name: /^Protection/ })).toBeNull();
expect(header.queryByRole("button", { name: /^Pause/ })).toBeNull();
});
test("the sidebar's foot states protection and uptime, and queries/min only on Overview", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const status = within(aside).getByLabelText("Status");
await waitFor(() => expect(within(status).getByText("Active")).toBeTruthy());
// 1 second of uptime from the fixture, aged by however long the test took.
expect(within(status).getByText("Uptime").nextElementSibling?.textContent).toMatch(/^\d+s$/);
// The fixture's overview body: no queries over a 24h window, stated as a rate.
await waitFor(() => expect(within(status).getByText("Queries/min")).toBeTruthy());
expect(within(status).getByText("Queries/min").nextElementSibling?.textContent).toBe("0.0");
});
test("Pause sits at the foot of the sidebar, above the version label", async () => {
+67 -44
View File
@@ -9,30 +9,31 @@ import { healthQuery, versionQuery } from "@/lib/queries";
import PauseControl from "@/features/pause/PauseControl";
import { diagnosticsBadge } from "./diagnosticsBadge";
import ConfigStatusNotices from "./ConfigStatusNotices";
import SidebarStatus, { ProtectionDot } from "./SidebarStatus";
import AuthorityLine from "@/features/configuration/AuthorityLine";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
import { colors, layers, metrics } from "@/ui/tokens.stylex";
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
const WIDE = "@media (min-width: 768px)";
const DARK = "@media (prefers-color-scheme: dark)";
const WIDE = "@media (min-width: 801px)";
/**
* The four operational surfaces, then the configuration group. Configuration
* is a labelled group rather than a collapsible tree: three items do not earn
* a disclosure, and a tree would hide the authority line under it.
* Two labelled groups (ui-visual-redesign.md): MONITORING is what the resolver
* is doing, SYSTEM is what it is told to do and how the box is faring. The
* three configuration pages sit flat in SYSTEM "Configuration" is not one
* page with the authority line under them.
*/
const NAV_ITEMS = [
const MONITORING_ITEMS = [
{ to: "/overview", label: "Overview" },
{ to: "/activity", label: "Activity" },
{ to: "/clients", label: "Clients" },
{ to: "/diagnostics", label: "Diagnostics" },
] as const;
const CONFIGURATION_ITEMS = [
const SYSTEM_ITEMS = [
{ to: "/configuration/protection", label: "Protection" },
{ to: "/configuration/resolution", label: "Resolution" },
{ to: "/configuration/system", label: "System" },
{ to: "/diagnostics", label: "Diagnostics" },
] as const;
const styles = stylex.create({
@@ -45,7 +46,8 @@ const styles = stylex.create({
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
minHeight: metrics.hitTarget,
borderRadius: metrics.radius,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
// Carried by every item, active or not: a weight that changes on
@@ -57,16 +59,17 @@ const styles = stylex.create({
flex: 1,
},
navGroup: {
marginTop: "1rem",
marginTop: "1.125rem",
},
navGroupLabel: {
display: "block",
paddingInline: "0.75rem",
paddingBlock: "0.25rem",
fontSize: "0.75rem",
paddingBottom: "0.375rem",
fontSize: "0.6875rem",
lineHeight: "1rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
letterSpacing: "0.09em",
color: colors.textMuted,
},
/**
@@ -90,12 +93,12 @@ const styles = stylex.create({
/** The control sits with the footer, not in the scrolling nav list above it. */
sidebarFooter: {
paddingInline: "1rem",
paddingTop: "0.75rem",
paddingBlock: "0.75rem",
},
/** The current page reads as a filled chip, heavier than the hover fill. */
/** The current page is a soft accent-tinted block, in the accent's own colour. */
navActive: {
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: colors.text,
backgroundColor: colors.primarySurface,
color: colors.primaryOnSurface,
},
navIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
@@ -140,16 +143,18 @@ const styles = stylex.create({
flexDirection: { default: null, [WIDE]: "column" },
minHeight: { default: null, [WIDE]: 0 },
overflow: { default: null, [WIDE]: "hidden" },
backgroundColor: colors.surfaceRaised,
borderRightWidth: 1,
borderRightStyle: "solid",
borderRightColor: colors.border,
},
brand: {
paddingInline: "1rem",
paddingBlock: "1rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
paddingBlock: "1.25rem",
fontSize: "1rem",
lineHeight: "1.5rem",
fontWeight: 650,
letterSpacing: "-0.015em",
},
sidebarNav: {
flex: 1,
@@ -163,11 +168,19 @@ const styles = stylex.create({
minWidth: { default: null, [WIDE]: 0 },
flexDirection: "column",
},
/** Narrow only: on WIDE the sidebar carries everything this row held. */
/**
* Narrow only: on WIDE the sidebar carries everything this row held. Sticky,
* so the brand, the protection state and the menu stay in reach down a long
* page.
*/
header: {
display: { default: "flex", [WIDE]: "none" },
position: "sticky",
top: 0,
zIndex: layers.tooltip,
alignItems: "center",
gap: "0.75rem",
backgroundColor: colors.surfaceRaised,
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
@@ -178,9 +191,10 @@ const styles = stylex.create({
display: { default: null, [WIDE]: "none" },
},
narrowBrand: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
fontSize: "1rem",
lineHeight: "1.5rem",
fontWeight: 650,
letterSpacing: "-0.015em",
display: { default: null, [WIDE]: "none" },
},
headerRight: {
@@ -245,31 +259,37 @@ function NavItem({
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const health = useQuery(healthQuery());
const badge = diagnosticsBadge(health.data, health.isError);
const groupHeadingId = useId();
const monitoringId = useId();
const systemId = useId();
return (
<>
<ul {...stylex.props(styles.navList)}>
{NAV_ITEMS.map((item) => (
<NavItem
key={item.to}
to={item.to}
label={item.label}
onNavigate={onNavigate}
badge={item.to === "/diagnostics" ? badge : undefined}
/>
))}
</ul>
<div {...stylex.props(styles.navGroup)}>
<div>
{/* A span, not a heading: the sidebar label is not a section of the
page, and an h2 here lands in the middle of the page's own outline. */}
<span id={groupHeadingId} {...stylex.props(styles.navGroupLabel)}>
Configuration
<span id={monitoringId} {...stylex.props(styles.navGroupLabel)}>
Monitoring
</span>
<ul aria-labelledby={groupHeadingId} {...stylex.props(styles.navList)}>
{CONFIGURATION_ITEMS.map((item) => (
<ul aria-labelledby={monitoringId} {...stylex.props(styles.navList)}>
{MONITORING_ITEMS.map((item) => (
<NavItem key={item.to} to={item.to} label={item.label} onNavigate={onNavigate} />
))}
</ul>
</div>
<div {...stylex.props(styles.navGroup)}>
<span id={systemId} {...stylex.props(styles.navGroupLabel)}>
System
</span>
<ul aria-labelledby={systemId} {...stylex.props(styles.navList)}>
{SYSTEM_ITEMS.map((item) => (
<NavItem
key={item.to}
to={item.to}
label={item.label}
onNavigate={onNavigate}
badge={item.to === "/diagnostics" ? badge : undefined}
/>
))}
</ul>
<AuthorityLine />
</div>
</>
@@ -279,7 +299,8 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
/**
* The sidebar's foot, in both renderings. Pause is a runtime action on the whole
* resolver rather than on the page in front of the reader, which is why it sits
* with the version label instead of in the header of every route.
* with the status block and the version label instead of in the header of every
* route.
*/
function SidebarFooter() {
return (
@@ -287,6 +308,7 @@ function SidebarFooter() {
<div {...stylex.props(styles.sidebarFooter)}>
<PauseControl />
</div>
<SidebarStatus />
<VersionFooter />
</>
);
@@ -353,6 +375,7 @@ export default function AppShell() {
</Button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}>
<ProtectionDot />
<LogoutButton />
</div>
</header>
+160
View File
@@ -0,0 +1,160 @@
/**
* The compact status block at the sidebar's foot (milestone 39): whether
* filtering is in force, how fast queries are arriving, and how long the
* process has been up. Three readings the operator glances at from any page,
* each from data the shell already holds no request of its own.
*
* Queries/min derives from the Overview body the page in front of the reader
* is drawing, so it appears only while that page is open and its window has
* landed; on every other page the row is absent rather than stale.
*/
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMatch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { formatDuration, formatRate } from "@/lib/format";
import { overviewQuery, versionQuery } from "@/lib/queries";
import { useProtection } from "@/features/pause/protection";
import { DEFAULT_PERIOD } from "@/features/overview/period";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
const styles = stylex.create({
block: {
display: "flex",
flexDirection: "column",
gap: "0.4375rem",
marginInline: "1rem",
marginBottom: "0.75rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surface,
paddingInline: "0.75rem",
paddingBlock: "0.875rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
line: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.625rem",
margin: 0,
},
key: {
display: "inline-flex",
alignItems: "center",
gap: "0.4375rem",
},
value: {
fontWeight: 550,
color: colors.text,
},
dot: {
flexShrink: 0,
width: "7px",
height: "7px",
borderRadius: "999px",
},
dotActive: { backgroundColor: colors.chartGreen },
dotPaused: { backgroundColor: colors.warnBorderStrong },
dotUnavailable: { backgroundColor: colors.chartRed },
dotUnknown: { backgroundColor: colors.borderStrong },
/** The narrow header's rendering: the dot and the state alone, inline. */
inline: {
display: "inline-flex",
alignItems: "center",
gap: "0.4375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
});
const PROTECTION_TEXT = {
active: "Active",
paused: "Paused",
unavailable: "Unavailable",
unknown: "Unknown",
} as const;
const DOT_STYLE = {
active: styles.dotActive,
paused: styles.dotPaused,
unavailable: styles.dotUnavailable,
unknown: styles.dotUnknown,
} as const;
/** The dot and the word, for the narrow header where there is room for nothing more. */
export function ProtectionDot() {
const { state } = useProtection();
return (
<span {...stylex.props(styles.inline)}>
<span aria-hidden="true" {...stylex.props(styles.dot, DOT_STYLE[state])} />
<span>Protection {PROTECTION_TEXT[state].toLowerCase()}</span>
</span>
);
}
/**
* Uptime as of now, not as of the fetch: the version answer is a minute old at
* most, so the seconds it reported are aged by the time since it landed. A
* restart shows within that minute. Ticks every second, because the first
* minute reads in seconds.
*/
function useUptimeSeconds(): number | null {
const version = useQuery(versionQuery());
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
if (version.data === undefined) return null;
return version.data.uptime_seconds + Math.max(0, now - version.dataUpdatedAt) / 1000;
}
function useQueriesPerMinute(): number | null {
const match = useMatch({ from: "/shell/overview", shouldThrow: false });
const period = match?.search.period ?? DEFAULT_PERIOD;
// Reads the body the page fetched; never fetches one of its own. A failed
// refetch leaves the last body in the cache, and the page is then showing
// its error, not that body: the row goes with it.
const overview = useQuery({ ...overviewQuery(period, match?.search.client), enabled: false });
if (match === undefined || overview.isError || overview.data === undefined) return null;
if (overview.data.period !== period) return null;
const minutes = (overview.data.until - overview.data.since) / 60;
return minutes > 0 ? overview.data.totals.queries / minutes : null;
}
export default function SidebarStatus() {
const { state } = useProtection();
const rate = useQueriesPerMinute();
const uptime = useUptimeSeconds();
return (
<dl aria-label="Status" {...stylex.props(styles.block)}>
<div {...stylex.props(styles.line)}>
<dt {...stylex.props(styles.key)}>
<span aria-hidden="true" {...stylex.props(styles.dot, DOT_STYLE[state])} />
Protection
</dt>
<dd {...stylex.props(styles.value)}>{PROTECTION_TEXT[state]}</dd>
</div>
{rate !== null && (
<div {...stylex.props(styles.line)}>
<dt>Queries/min</dt>
<dd {...stylex.props(styles.value, shared.tabularNums)}>{formatRate(rate)}</dd>
</div>
)}
<div {...stylex.props(styles.line)}>
<dt>Uptime</dt>
<dd {...stylex.props(styles.value, shared.tabularNums)}>
{uptime === null ? "—" : formatDuration(uptime)}
</dd>
</div>
</dl>
);
}
+9
View File
@@ -13,6 +13,15 @@ test("open episodes are the count, warnings and errors together", () => {
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" });
});
/** Four digits of open episodes read as one number, grouped like every other count. */
test("a four-figure count is grouped", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 1200, active_errors: 34 } }),
false,
);
expect(badge).toEqual({ text: "1,234", label: "1,234 active diagnostic events" });
});
test("one open episode is counted in the singular", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }),
+5 -1
View File
@@ -14,6 +14,7 @@
*/
import type { Health } from "@/lib/types";
import { formatCount } from "@/lib/format";
export interface NavBadge {
/** What the badge shows. Shape and text, never colour alone. */
@@ -30,7 +31,10 @@ export function diagnosticsBadge(health: Health | undefined, pollFailed: boolean
if (health === undefined) return null;
const open = health.diagnostics.active_warnings + health.diagnostics.active_errors;
if (open > 0) {
return { text: String(open), label: `${open} active diagnostic ${open === 1 ? "event" : "events"}` };
return {
text: formatCount(open),
label: `${formatCount(open)} active diagnostic ${open === 1 ? "event" : "events"}`,
};
}
if (health.status === "degraded") return { text: "!", label: "Health degraded" };
return null;
+79
View File
@@ -0,0 +1,79 @@
/**
* The one panel chrome (milestone 39): a white hairline-bordered card with a
* prominent title and a one-line description under it. Every panel on every
* page is one of these, so the pages agree by construction rather than by
* copying a `panel` style object around.
*
* The title is an `h2` because a card is a section of the page's outline; a
* page that needs a different level passes `as`.
*/
import { useId } from "react";
import * as stylex from "@stylexjs/stylex";
import { colors, metrics } from "./tokens.stylex";
export const cardStyles = stylex.create({
card: {
minWidth: 0,
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: metrics.cardPadding,
},
head: {
marginBottom: "1.25rem",
},
/**
* The card's title is the loudest text on the page after the stat numerals:
* the references (Pi-hole, NextDNS) both lead each panel with a heading the
* eye lands on first. Kept in one place so no heading reset outranks it.
*/
title: {
margin: 0,
fontSize: "1.4rem",
lineHeight: 1.2,
fontWeight: 650,
letterSpacing: "-0.015em",
color: colors.text,
textWrap: "balance",
},
description: {
margin: 0,
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
textWrap: "pretty",
},
});
export default function Card({
title,
description,
as: Heading = "h2",
style,
children,
}: {
title: string;
/** One sentence saying what the panel shows; every card carries one (the decision record's card head). */
description: string;
as?: "h2" | "h3";
/** Extra styles for the card box itself: a grid placement, a clipped list variant. */
style?: stylex.StyleXStyles;
children: React.ReactNode;
}) {
const titleId = useId();
return (
<section aria-labelledby={titleId} {...stylex.props(cardStyles.card, style)}>
<div {...stylex.props(cardStyles.head)}>
<Heading id={titleId} {...stylex.props(cardStyles.title)}>
{title}
</Heading>
<p {...stylex.props(cardStyles.description)}>{description}</p>
</div>
{children}
</section>
);
}
+2 -2
View File
@@ -11,7 +11,7 @@
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors, layers } from "./tokens.stylex";
import { colors, layers, metrics } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
@@ -77,7 +77,7 @@ const styles = stylex.create({
},
dangerButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderStyle: "none",
backgroundColor: colors.danger,
color: colors.primaryText,
+22 -5
View File
@@ -22,7 +22,7 @@ import {
SelectValue,
Text,
} from "react-aria-components";
import { colors } from "./tokens.stylex";
import { colors, metrics } from "./tokens.stylex";
import { styles as shared } from "./styles";
export interface SelectOption {
@@ -45,9 +45,10 @@ interface Props {
description?: string;
/**
* `field` matches a full-width form input, `compactField` the smaller one a
* dialog uses, `inline` a control sitting in a row of other controls.
* dialog uses, `inline` a control sitting in a row of other controls, and
* `toolbar` the 44px hairline dropdown a page's scope picker is.
*/
variant?: "field" | "compactField" | "inline";
variant?: "field" | "compactField" | "inline" | "toolbar";
/** Visible but inert, keeping its value on screen; RAC also drops it from the tab order. */
isDisabled?: boolean;
}
@@ -77,6 +78,22 @@ const styles = stylex.create({
marginTop: "0.25rem",
width: "100%",
},
/** Rectangular, hairline, full hit height: the decision record's toolbar selector. */
toolbar: {
minHeight: metrics.hitTarget,
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: { default: colors.border, ":hover": colors.borderStrong },
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.9375rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
transitionProperty: "border-color",
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
},
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
value: {
overflow: "hidden",
@@ -97,7 +114,7 @@ const styles = stylex.create({
width: "var(--trigger-width)",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -143,7 +160,7 @@ export default function Select({
variant = "field",
isDisabled = false,
}: Props) {
const base = variant === "field" ? shared.input : shared.smallInput;
const base = variant === "field" ? shared.input : variant === "toolbar" ? styles.toolbar : shared.smallInput;
const block = variant === "compactField" ? styles.compact : null;
return (
<AriaSelect
+12 -11
View File
@@ -47,7 +47,7 @@ export const styles = stylex.create({
input: {
marginTop: "0.25rem",
width: "100%",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -57,7 +57,7 @@ export const styles = stylex.create({
paddingBlock: "0.5rem",
},
smallInput: {
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -73,7 +73,7 @@ export const styles = stylex.create({
minHeight: 40,
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -90,7 +90,7 @@ export const styles = stylex.create({
smallButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -102,7 +102,7 @@ export const styles = stylex.create({
largeButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -114,7 +114,7 @@ export const styles = stylex.create({
primaryButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderStyle: "none",
backgroundColor: colors.primary,
color: colors.primaryText,
@@ -128,7 +128,7 @@ export const styles = stylex.create({
largePrimaryButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderStyle: "none",
backgroundColor: colors.primary,
color: colors.primaryText,
@@ -141,7 +141,7 @@ export const styles = stylex.create({
rowButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderStyle: "none",
backgroundColor: "transparent",
paddingInline: "0.5rem",
@@ -177,7 +177,7 @@ export const styles = stylex.create({
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
@@ -216,7 +216,7 @@ export 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",
@@ -264,6 +264,7 @@ export const styles = stylex.create({
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
/** Focusable: a table wider than a phone scrolls sideways, and a keyboard must be able to reach the scroll. */
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
@@ -281,7 +282,7 @@ export const styles = stylex.create({
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "32rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
+55 -5
View File
@@ -41,11 +41,21 @@ export const colors = stylex.defineVars({
* chart legend where that loss shows.
*/
textSecondary: { default: "oklch(44.2% 0.017 285.786)", [DARK]: "oklch(70.5% 0.015 286.067)" },
/** Primary actions. Identical in both schemes, as before the conversion. */
primary: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
/**
* The accent (ui-visual-redesign.md): blue at hue 258 for nav, links, primary
* actions and the total-queries series. Identical in both schemes.
*/
primary: { default: "oklch(55% 0.16 258)", [DARK]: "oklch(55% 0.16 258)" },
primaryText: { default: "#fff", [DARK]: "#fff" },
/** Primary as foreground: lightened in dark so it clears the ground. */
primaryOnSurface: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(70.7% 0.165 254.624)" },
/**
* Primary as foreground text. Darker than `primary` in light because the
* active nav label sits on `primarySurface`, where the accent itself
* measures 4.39:1; this value measures 5.43:1 there. Lightened in dark so it
* clears the ground (5.87:1 on its wash).
*/
primaryOnSurface: { default: "oklch(50% 0.16 258)", [DARK]: "oklch(72% 0.13 258)" },
/** The accent as a wash: the active nav item's fill. */
primarySurface: { default: "oklch(96% 0.02 258)", [DARK]: "oklch(28% 0.05 258)" },
/**
* Warnings: a degraded condition the operator can still act on, as against
* `danger`, which is a failure or a destructive action. The amber ramp.
@@ -59,8 +69,44 @@ export const colors = stylex.defineVars({
dangerSurface: { default: "oklch(97.1% 0.013 17.38)", [DARK]: "oklch(25.8% 0.092 26.042)" },
dangerBorder: { default: "oklch(80.8% 0.114 19.571)", [DARK]: "oklch(44.4% 0.177 26.899)" },
dangerText: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
/**
* The softer red the charts and the blocked stat numeral wear: 3.54:1 on
* white, which clears the 3:1 floor for non-text and large text and nothing
* else. Small text keeps `dangerText`.
*/
chartRed: { default: "oklch(65% 0.19 25)", [DARK]: "oklch(65% 0.19 25)" },
/** Cache and success: fixed semantics, never derived from the accent. */
chartGreen: { default: "oklch(62% 0.14 150)", [DARK]: "oklch(62% 0.14 150)" },
chartGreenSurface: { default: "oklch(93% 0.05 150)", [DARK]: "oklch(30% 0.06 150)" },
/**
* The categorical series palette (ui-visual-redesign.md): eight hues spread
* round the wheel, clear of the reserved red at hue 25, the same in both
* schemes. `seriesOther` is the aggregated tail, a flat gray that never
* competes with a named client. SVG attributes take these vars directly.
*/
seriesBlue: { default: "oklch(60% 0.14 258)", [DARK]: "oklch(60% 0.14 258)" },
seriesTeal: { default: "oklch(68% 0.12 190)", [DARK]: "oklch(68% 0.12 190)" },
seriesViolet: { default: "oklch(62% 0.15 300)", [DARK]: "oklch(62% 0.15 300)" },
seriesAmber: { default: "oklch(75% 0.13 80)", [DARK]: "oklch(75% 0.13 80)" },
seriesGreen: { default: "oklch(65% 0.13 150)", [DARK]: "oklch(65% 0.13 150)" },
seriesMagenta: { default: "oklch(66% 0.14 340)", [DARK]: "oklch(66% 0.14 340)" },
seriesOrange: { default: "oklch(70% 0.14 55)", [DARK]: "oklch(70% 0.14 55)" },
seriesOlive: { default: "oklch(72% 0.11 120)", [DARK]: "oklch(72% 0.11 120)" },
seriesOther: { default: "oklch(80% 0.01 260)", [DARK]: "oklch(80% 0.01 260)" },
/**
* The query-types ring: the accent's hue stepped in lightness and chroma from
* the busiest type outward. Six steps cover every ring the API answers with
* in practice; in dark the ramp runs the other way so the busiest stays the
* most saturated against the ground.
*/
ramp1: { default: "oklch(55% 0.15 258)", [DARK]: "oklch(72% 0.14 258)" },
ramp2: { default: "oklch(63% 0.13 258)", [DARK]: "oklch(64% 0.12 258)" },
ramp3: { default: "oklch(71% 0.10 258)", [DARK]: "oklch(56% 0.10 258)" },
ramp4: { default: "oklch(78% 0.075 258)", [DARK]: "oklch(48% 0.08 258)" },
ramp5: { default: "oklch(85% 0.05 258)", [DARK]: "oklch(41% 0.06 258)" },
ramp6: { default: "oklch(91% 0.035 258)", [DARK]: "oklch(35% 0.04 258)" },
/** The focus ring colour. The ring itself is a floor, not a variant. */
focus: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
focus: { default: "oklch(55% 0.16 258)", [DARK]: "oklch(55% 0.16 258)" },
});
/**
@@ -90,6 +136,10 @@ export const layers = stylex.defineVars({
export const metrics = stylex.defineConsts({
/** WCAG 2.5.5's enhanced 44px target, applied where layout permits; some inline controls stop at 40px or above 2.5.8's 24px minimum. */
hitTarget: "44px",
/** One corner radius for every surface: cards, controls, tooltips, nav items. */
radius: "4px",
/** The inset of a card's content from its border. */
cardPadding: "1.75rem",
/** The press and hover settle shared by every control that styles its own states. */
transitionProperty: "background-color, color, border-color, transform",
transitionDuration: "120ms",