2 Commits
Author SHA1 Message Date
mokhtar 94496b303a build: bump version to 0.0.16
Gates / frontend (push) Failing after 2m5s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test (push) Successful in 2m39s
Gates / test-aarch64 (push) Canceled after 3m46s
CI / gates (push) Canceled after 6m7s
2026-09-03 17:17:03 +02:00
mokhtar 209c1c77a9 admin: overview redesign, device scope, one formatting contract (milestone 39)
The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip beside the point, a stacked client chart in eight distinct hues plus one Other band, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown.

GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity.

All numbers go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else.

Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and description, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), and the accent darkened to 5.43:1 on its wash.

Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex could not review sessions B to D, its endpoint returned 404 for every call on 2026-09-03; the owner accepted the smoke-server screenshots.
2026-09-03 17:16:24 +02:00
46 changed files with 216 additions and 602 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does. Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [0.0.16] - 2026-09-07 ## [0.0.16] - 2026-09-03
The Overview page is redesigned around the two dashboards people already know — Pi-hole's layout, NextDNS's charts — and every number in the admin is spelled one way. The Overview page is redesigned around the two dashboards people already know — Pi-hole's layout, NextDNS's charts — and every number in the admin is spelled one way.
+1 -2
View File
@@ -5,7 +5,6 @@ import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store"; import { useAuth } from "@/auth/store";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import { formatDuration } from "@/lib/format";
const styles = stylex.create({ const styles = stylex.create({
/** Login renders outside AppShell, so it paints the page ground itself. */ /** Login renders outside AppShell, so it paints the page ground itself. */
@@ -66,7 +65,7 @@ function errorMessage(error: unknown, remaining: number | null): string {
if (error.status === 401) return "Incorrect password."; if (error.status === 401) return "Incorrect password.";
if (error.status === 429) { if (error.status === 429) {
return remaining !== null && remaining > 0 return remaining !== null && remaining > 0
? `Too many attempts. Try again in ${formatDuration(remaining)}.` ? `Too many attempts. Try again in ${remaining}s.`
: "Too many attempts. Try again shortly."; : "Too many attempts. Try again shortly.";
} }
if (error.status === 503) return "The server is starting or degraded. Try again shortly."; if (error.status === 503) return "The server is starting or degraded. Try again shortly.";
+2 -3
View File
@@ -29,7 +29,6 @@ import { useEffect, useMemo, useRef } from "react";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components"; import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
import { clientLabel, useClientNames } from "@/features/clients/clientNames"; import { clientLabel, useClientNames } from "@/features/clients/clientNames";
import { formatCount } from "@/lib/format";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex"; import { colors, metrics } from "@/ui/tokens.stylex";
import { MAX_CLIENTS } from "./search"; import { MAX_CLIENTS } from "./search";
@@ -221,7 +220,7 @@ export function joinClients(ips: readonly string[]): string | undefined {
function triggerLabel(selected: readonly string[], options: readonly ClientOption[]): string { function triggerLabel(selected: readonly string[], options: readonly ClientOption[]): string {
if (selected.length === 0) return "Clients"; if (selected.length === 0) return "Clients";
if (selected.length === 1) return displayFor(selected[0] as string, options); if (selected.length === 1) return displayFor(selected[0] as string, options);
return `${formatCount(selected.length)} clients`; return `${selected.length} clients`;
} }
interface Props { interface Props {
@@ -374,7 +373,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
</button> </button>
))} ))}
{selected.length > MAX_CHIPS && ( {selected.length > MAX_CHIPS && (
<span {...stylex.props(styles.chipMore)}>+{formatCount(selected.length - MAX_CHIPS)} more</span> <span {...stylex.props(styles.chipMore)}>+{selected.length - MAX_CHIPS} more</span>
)} )}
</div> </div>
); );
@@ -12,7 +12,6 @@ import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api"; import * as api from "@/lib/api";
import CoverageNotice from "@/lib/CoverageNotice"; import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { formatCount } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries"; import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueryRow } from "@/lib/types"; import type { QueryRow } from "@/lib/types";
import { useClientNames } from "@/features/clients/clientNames"; import { useClientNames } from "@/features/clients/clientNames";
@@ -153,7 +152,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
</div> </div>
) : ( ) : (
<> <>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}> <div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}> <table {...stylex.props(styles.table)}>
<ActivityTableHead /> <ActivityTableHead />
<tbody> <tbody>
@@ -185,7 +184,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
<div {...stylex.props(styles.footer)}> <div {...stylex.props(styles.footer)}>
<p {...stylex.props(styles.note)}> <p {...stylex.props(styles.note)}>
{/* The count is the one part of this line that moves as pages load. */} {/* The count is the one part of this line that moves as pages load. */}
Showing <span {...stylex.props(shared.tabularNums)}>{formatCount(rows.length)}</span>{" "} Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
{rows.length === 1 ? "query" : "queries"} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"} {hasMore ? "" : " — end of log"}
</p> </p>
+5 -10
View File
@@ -16,7 +16,6 @@ import { useEffect, useRef, useState } from "react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames"; import { useClientNames } from "@/features/clients/clientNames";
import { formatCount } from "@/lib/format";
import { summarizeEvent } from "@/features/provenance/querySummary"; import { summarizeEvent } from "@/features/provenance/querySummary";
import Dialog from "@/ui/Dialog"; import Dialog from "@/ui/Dialog";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
@@ -288,8 +287,7 @@ export default function LiveActivity({
{live.frozen && ( {live.frozen && (
<p {...stylex.props(styles.note)} role="status"> <p {...stylex.props(styles.note)} role="status">
Display frozen — new queries keep buffering ({formatCount(live.liveCount)} in buffer, newest{" "} Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).
{formatCount(capacity)} kept).
</p> </p>
)} )}
@@ -297,10 +295,7 @@ export default function LiveActivity({
<div role="status" {...stylex.props(styles.resumed)}> <div role="status" {...stylex.props(styles.resumed)}>
<span> <span>
Stream resumed —{" "} Stream resumed —{" "}
{live.missed === 0 {live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
? "no queries missed"
: `${formatCount(live.missed)} missed queries recovered`}
.
</span> </span>
<button <button
type="button" type="button"
@@ -351,7 +346,7 @@ export default function LiveActivity({
) )
) : ( ) : (
<> <>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}> <div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}> <table {...stylex.props(styles.table)}>
<ActivityTableHead /> <ActivityTableHead />
<tbody> <tbody>
@@ -398,8 +393,8 @@ export default function LiveActivity({
</table> </table>
</div> </div>
<p {...stylex.props(styles.footnote)}> <p {...stylex.props(styles.footnote)}>
Showing {formatCount(live.rows.length)} {live.rows.length === 1 ? "query" : "queries"}{" "} Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
(newest first, last {formatCount(capacity)} kept). last {capacity} kept).
</p> </p>
</> </>
)} )}
@@ -9,7 +9,6 @@ import { defaultGroupId } from "@/lib/defaultGroup";
import Select from "@/ui/Select"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex"; import { colors, metrics } from "@/ui/tokens.stylex";
import { formatDuration } from "@/lib/format";
const DARK = "@media (prefers-color-scheme: dark)"; const DARK = "@media (prefers-color-scheme: dark)";
@@ -179,7 +178,7 @@ function errorMessage(error: unknown): string {
} }
if (error.status === 429) { if (error.status === 429) {
return error.retryAfter !== undefined return error.retryAfter !== undefined
? `Rate limited. Try again in ${formatDuration(error.retryAfter)}.` ? `Rate limited. Try again in ${error.retryAfter}s.`
: "Rate limited. Try again shortly."; : "Rate limited. Try again shortly.";
} }
return error.message; return error.message;
@@ -7,9 +7,8 @@ import InlineError from "@/lib/InlineError";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import { clientsQuery } from "@/lib/queries"; import { clientsQuery } from "@/lib/queries";
import { useAuthority } from "@/features/configuration/authority"; import { useAuthority } from "@/features/configuration/authority";
import Card from "@/ui/Card";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors, metrics } from "@/ui/tokens.stylex";
import { ClientDisplayName, provenanceOf } from "./clientIdentity"; import { ClientDisplayName, provenanceOf } from "./clientIdentity";
/** The Activity window this page offers: the last day, in seconds. */ /** The Activity window this page offers: the last day, in seconds. */
@@ -44,6 +43,12 @@ const styles = stylex.create({
panel: { panel: {
marginTop: "1rem", marginTop: "1rem",
maxWidth: "48rem", maxWidth: "48rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: metrics.cardPadding,
}, },
facts: { facts: {
display: "grid", display: "grid",
@@ -177,11 +182,7 @@ export default function ClientDetailPage() {
</h1> </h1>
<p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p> <p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p>
<Card <div {...stylex.props(styles.panel)}>
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)}> <dl {...stylex.props(styles.facts)}>
<dt {...stylex.props(styles.term)}>Name</dt> <dt {...stylex.props(styles.term)}>Name</dt>
<dd {...stylex.props(styles.value)}> <dd {...stylex.props(styles.value)}>
@@ -197,7 +198,7 @@ export default function ClientDetailPage() {
<dt {...stylex.props(styles.term)}>Last seen</dt> <dt {...stylex.props(styles.term)}>Last seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd> <dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd>
</dl> </dl>
</Card> </div>
<h2 {...stylex.props(styles.sectionHeading)}>Policy</h2> <h2 {...stylex.props(styles.sectionHeading)}>Policy</h2>
<p {...stylex.props(styles.prose)}> <p {...stylex.props(styles.prose)}>
+1 -1
View File
@@ -185,7 +185,7 @@ export default function ClientsPage() {
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>No clients match this filter.</p> <p {...stylex.props(styles.empty)}>No clients match this filter.</p>
) : ( ) : (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr {...stylex.props(styles.headRow)}> <tr {...stylex.props(styles.headRow)}>
@@ -4,7 +4,6 @@ import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries"; import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types"; import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { formatCount } from "@/lib/format";
import { import {
firstProblem, firstProblem,
initPrefixEditor, initPrefixEditor,
@@ -143,7 +142,7 @@ export default function NetworkAssignments({ prefixes, groups }: Props) {
function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) { function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
if (prefixes.length === 0) return <p {...stylex.props(styles.empty)}>The file declares no network assignments.</p>; if (prefixes.length === 0) return <p {...stylex.props(styles.empty)}>The file declares no network assignments.</p>;
return ( return (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
@@ -211,7 +210,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<li key={index} {...stylex.props(styles.row)}> <li key={index} {...stylex.props(styles.row)}>
<input <input
type="text" type="text"
aria-label={`Range ${formatCount(index + 1)}`} aria-label={`Range ${index + 1}`}
aria-invalid={invalid(index, "prefix")} aria-invalid={invalid(index, "prefix")}
aria-describedby={invalid(index, "prefix") && VALIDATION_ID} aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
placeholder="192.168.1.0/24" placeholder="192.168.1.0/24"
@@ -222,7 +221,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)} {...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/> />
<Select <Select
aria-label={`Group for range ${formatCount(index + 1)}`} aria-label={`Group for range ${index + 1}`}
variant="inline" variant="inline"
value={String(row.group_id)} value={String(row.group_id)}
onChange={(value) => onChange={(value) =>
@@ -233,7 +232,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
aria-label={`Priority for range ${formatCount(index + 1)}`} aria-label={`Priority for range ${index + 1}`}
aria-invalid={invalid(index, "priority")} aria-invalid={invalid(index, "priority")}
aria-describedby={invalid(index, "priority") && VALIDATION_ID} aria-describedby={invalid(index, "priority") && VALIDATION_ID}
placeholder="100" placeholder="100"
+2 -7
View File
@@ -1,4 +1,3 @@
import { formatCount } from "@/lib/format";
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types"; import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
export interface PrefixRow { export interface PrefixRow {
@@ -66,14 +65,10 @@ export interface PrefixProblem {
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null { export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
if (row.prefix.trim() === "") if (row.prefix.trim() === "")
return { index, field: "prefix", message: `Row ${formatCount(index + 1)}: prefix is required.` }; return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
const priority = row.priority.trim(); const priority = row.priority.trim();
if (priority !== "" && !/^\d+$/.test(priority)) if (priority !== "" && !/^\d+$/.test(priority))
return { return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
index,
field: "priority",
message: `Row ${formatCount(index + 1)}: priority must be a whole number.`,
};
} }
return null; return null;
} }
@@ -15,13 +15,10 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
Local records Local records
<code {...stylex.props(shared.mono, config.panelKey)}>local_records</code> <code {...stylex.props(shared.mono, config.panelKey)}>local_records</code>
</h2> </h2>
<p {...stylex.props(config.panelDescription)}>
Names this resolver answers by itself, without asking an upstream.
</p>
{records.length === 0 ? ( {records.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no local records.</p> <p {...stylex.props(config.empty)}>The file declares no local records.</p>
) : ( ) : (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}> <table {...stylex.props(config.table)}>
<thead> <thead>
<tr> <tr>
@@ -55,13 +52,10 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
Forward zones Forward zones
<code {...stylex.props(shared.mono, config.panelKey)}>forward_zones</code> <code {...stylex.props(shared.mono, config.panelKey)}>forward_zones</code>
</h2> </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 ? ( {zones.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no forward zones.</p> <p {...stylex.props(config.empty)}>The file declares no forward zones.</p>
) : ( ) : (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}> <table {...stylex.props(config.table)}>
<thead> <thead>
<tr> <tr>
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup"; import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { formatCount, formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { import {
blocklistsQuery, blocklistsQuery,
@@ -225,7 +225,7 @@ function ClientCountLink({ group }: { group: Group }) {
<Link to="/clients" search={{ group: group.id }} {...stylex.props(shared.focusRing)}> <Link to="/clients" search={{ group: group.id }} {...stylex.props(shared.focusRing)}>
{count === undefined {count === undefined
? "Clients in this group" ? "Clients in this group"
: `${formatCount(count)} client${count === 1 ? "" : "s"} in this group`} : `${count} client${count === 1 ? "" : "s"} in this group`}
</Link> </Link>
</p> </p>
); );
@@ -236,10 +236,6 @@ function GroupDetailReadOnly({ group }: { group: Group }) {
<div> <div>
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2> <h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
<section {...stylex.props(config.panel)}> <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 <DefinitionList
items={[ items={[
{ label: "Name", zonKey: "groups[].name", value: group.name }, { label: "Name", zonKey: "groups[].name", value: group.name },
@@ -263,7 +259,6 @@ function GroupSourcesReadOnly({ group }: { group: Group }) {
Assigned sources Assigned sources
<code {...stylex.props(shared.mono, config.panelKey)}>group_sources</code> <code {...stylex.props(shared.mono, config.panelKey)}>group_sources</code>
</h3> </h3>
<p {...stylex.props(config.panelDescription)}>The blocklists that apply to clients in this group.</p>
<QueryPanel query={assigned}> <QueryPanel query={assigned}>
{(sourceIds) => ( {(sourceIds) => (
<QueryPanel query={blocklists}> <QueryPanel query={blocklists}>
@@ -279,7 +274,7 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo
const assigned = catalogue.filter((source) => sourceIds.includes(source.id)); 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>; if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
return ( return (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}> <table {...stylex.props(config.table)}>
<thead> <thead>
<tr> <tr>
@@ -396,7 +391,6 @@ function GroupDetailEditable({ group }: { group: Group }) {
<section {...stylex.props(config.panel)}> <section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>Assigned sources</h3> <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}> <QueryPanel query={blocklists}>
{(catalogue) => <GroupSourcesEditor groupId={group.id} blocklists={catalogue} />} {(catalogue) => <GroupSourcesEditor groupId={group.id} blocklists={catalogue} />}
</QueryPanel> </QueryPanel>
@@ -434,9 +428,6 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
Rules Rules
{!editable && <code {...stylex.props(shared.mono, config.panelKey)}>rules</code>} {!editable && <code {...stylex.props(shared.mono, config.panelKey)}>rules</code>}
</h3> </h3>
<p {...stylex.props(config.panelDescription)}>
This group's own block and allow patterns, applied before any list.
</p>
<QueryPanel query={rules}> <QueryPanel query={rules}>
{(all) => { {(all) => {
const scoped = all.filter((rule) => rule.group_id === group.id); const scoped = all.filter((rule) => rule.group_id === group.id);
@@ -466,7 +457,7 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
return ( return (
<> <>
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}> <table {...stylex.props(config.table)}>
<thead> <thead>
<tr> <tr>
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { formatCount, formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { import {
blocklistCreateMutation, blocklistCreateMutation,
@@ -131,13 +131,10 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
Blocklist sources Blocklist sources
<code {...stylex.props(shared.mono, config.panelKey)}>blocklist_sources</code> <code {...stylex.props(shared.mono, config.panelKey)}>blocklist_sources</code>
</h2> </h2>
<p {...stylex.props(config.panelDescription)}>
The lists nxdns downloads, and what each one contributed at its last refresh.
</p>
{blocklists.length === 0 ? ( {blocklists.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no blocklist sources.</p> <p {...stylex.props(config.empty)}>The file declares no blocklist sources.</p>
) : ( ) : (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}> <table {...stylex.props(config.table)}>
<thead> <thead>
<tr> <tr>
@@ -161,20 +158,12 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
</td> </td>
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</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)}>{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)}> <td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.domain_count)} {b.skipped_unsupported_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>
<td {...stylex.props(shared.td)}> <td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)} {b.last_updated === null ? "never" : formatTime(b.last_updated)}
@@ -230,7 +219,7 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
{blocklists.length === 0 ? ( {blocklists.length === 0 ? (
<p {...stylex.props(config.empty)}>No blocklist sources yet. Add one below.</p> <p {...stylex.props(config.empty)}>No blocklist sources yet. Add one below.</p>
) : ( ) : (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}> <table {...stylex.props(config.table)}>
<thead> <thead>
<tr> <tr>
@@ -268,20 +257,12 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
onChange={() => toggleEnabled(b)} onChange={() => toggleEnabled(b)}
/> />
</td> </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)}> <td {...stylex.props(shared.td, shared.tabularNums)}>
{formatCount(b.domain_count)} {b.skipped_unsupported_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>
<td {...stylex.props(shared.td)}> <td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)} {b.last_updated === null ? "never" : formatTime(b.last_updated)}
@@ -223,7 +223,7 @@ export default function RecordsTab() {
)} )}
<QueryPanel query={query}> <QueryPanel query={query}>
{(records) => ( {(records) => (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr {...stylex.props(styles.headRow)}> <tr {...stylex.props(styles.headRow)}>
@@ -37,7 +37,7 @@ function renderValue(value: unknown) {
export default function SettingsDefinitions({ settings }: { settings: Settings }) { export default function SettingsDefinitions({ settings }: { settings: Settings }) {
return ( return (
<> <>
{SECTIONS.map(({ section, title, description, fields }) => { {SECTIONS.map(({ section, title, fields }) => {
const values = sectionValues(settings, section); const values = sectionValues(settings, section);
const items: Definition[] = (fields as readonly AnyFieldDef[]).map((def) => ({ const items: Definition[] = (fields as readonly AnyFieldDef[]).map((def) => ({
label: humanize(def.key), label: humanize(def.key),
@@ -56,7 +56,6 @@ export default function SettingsDefinitions({ settings }: { settings: Settings }
return ( return (
<section key={section} {...stylex.props(config.panel)}> <section key={section} {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>{title}</h2> <h2 {...stylex.props(config.panelHeading)}>{title}</h2>
<p {...stylex.props(config.panelDescription)}>{description}</p>
<div {...stylex.props(config.note)}> <div {...stylex.props(config.note)}>
<DefinitionList items={items} /> <DefinitionList items={items} />
</div> </div>
@@ -5,7 +5,6 @@ import InlineError from "@/lib/InlineError";
import { settingsPutMutation } from "@/lib/queries"; import { settingsPutMutation } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff"; import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsEnvelope } from "@/lib/types"; import type { Settings, SettingsEnvelope } from "@/lib/types";
import { cardStyles } from "@/ui/Card";
import Select from "@/ui/Select"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex"; import { colors, metrics } from "@/ui/tokens.stylex";
@@ -31,25 +30,18 @@ const styles = stylex.create({
margin: 0, margin: 0,
padding: 0, padding: 0,
}, },
/** A `fieldset` shrinks to its content by default, which would undo the card's own `minWidth: 0`. */
section: { section: {
minInlineSize: 0, borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
}, },
/**
* 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: { legend: {
float: "left", paddingInline: "0.25rem",
width: "100%", fontSize: "0.875rem",
padding: 0, lineHeight: "1.25rem",
}, fontWeight: 600,
description: {
clear: "both",
}, },
/** One column on a phone, two from `sm`. */ /** One column on a phone, two from `sm`. */
fieldGrid: { fieldGrid: {
@@ -269,10 +261,9 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
return ( return (
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}> <form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending} {...stylex.props(styles.sections)}> <fieldset disabled={mutation.isPending} {...stylex.props(styles.sections)}>
{SECTIONS.map(({ section, title, description, fields }) => ( {SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} {...stylex.props(cardStyles.card, styles.section)}> <fieldset key={section} {...stylex.props(styles.section)}>
<legend {...stylex.props(cardStyles.title, styles.legend)}>{title}</legend> <legend {...stylex.props(styles.legend)}>{title}</legend>
<p {...stylex.props(config.panelDescription, styles.description)}>{description}</p>
<div {...stylex.props(styles.fieldGrid)}> <div {...stylex.props(styles.fieldGrid)}>
{(fields as readonly AnyFieldDef[]).map((def) => ( {(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow <FieldRow
@@ -67,13 +67,10 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
Upstream pool Upstream pool
<code {...stylex.props(shared.mono, config.panelKey)}>upstreams</code> <code {...stylex.props(shared.mono, config.panelKey)}>upstreams</code>
</h2> </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 ? ( {upstreams.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no upstreams.</p> <p {...stylex.props(config.empty)}>The file declares no upstreams.</p>
) : ( ) : (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}> <table {...stylex.props(config.table)}>
<thead> <thead>
<tr> <tr>
@@ -146,7 +143,7 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
{upstreams.length === 0 ? ( {upstreams.length === 0 ? (
<p {...stylex.props(config.empty)}>No upstreams yet. Add one below.</p> <p {...stylex.props(config.empty)}>No upstreams yet. Add one below.</p>
) : ( ) : (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}> <table {...stylex.props(config.table)}>
<thead> <thead>
<tr> <tr>
@@ -193,7 +193,7 @@ export default function ZonesTab() {
)} )}
<QueryPanel query={query}> <QueryPanel query={query}>
{(zones) => ( {(zones) => (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}> <div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr {...stylex.props(styles.headRow)}> <tr {...stylex.props(styles.headRow)}>
@@ -14,8 +14,6 @@ export interface FieldDef<S extends keyof Settings> {
export interface SectionDef<S extends keyof Settings> { export interface SectionDef<S extends keyof Settings> {
section: S; section: S;
title: string; title: string;
/** The card head's one line: what the section governs, in the reader's words. */
description: string;
fields: readonly FieldDef<S>[]; fields: readonly FieldDef<S>[];
} }
@@ -49,8 +47,6 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({ defineSection({
section: "upstream", section: "upstream",
title: "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: [ fields: [
{ key: "attempt_timeout_ms", kind: "number" }, { key: "attempt_timeout_ms", kind: "number" },
{ key: "read_timeout_ms", kind: "number" }, { key: "read_timeout_ms", kind: "number" },
@@ -60,8 +56,6 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({ defineSection({
section: "dns", section: "dns",
title: "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: [ fields: [
{ key: "bind_ipv4", kind: "text" }, { key: "bind_ipv4", kind: "text" },
{ key: "bind_ipv6", kind: "text" }, { key: "bind_ipv6", kind: "text" },
@@ -73,7 +67,6 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({ defineSection({
section: "blocking", section: "blocking",
title: "Blocking", title: "Blocking",
description: "What a blocked query is answered with, and for how long clients may keep that answer.",
fields: [ fields: [
{ key: "response", kind: ["zero", "nxdomain"] }, { key: "response", kind: ["zero", "nxdomain"] },
{ key: "ttl", kind: "number" }, { key: "ttl", kind: "number" },
@@ -82,7 +75,6 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({ defineSection({
section: "cache", section: "cache",
title: "Cache", title: "Cache",
description: "How many answers are kept, and how long a negative answer stays valid.",
fields: [ fields: [
{ key: "size", kind: "number" }, { key: "size", kind: "number" },
{ key: "negative_ttl_max", kind: "number" }, { key: "negative_ttl_max", kind: "number" },
@@ -91,7 +83,6 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({ defineSection({
section: "web", section: "web",
title: "Web", title: "Web",
description: "Where this admin interface listens, how long a login lasts, and its request limits.",
fields: [ fields: [
{ key: "enabled", kind: "boolean" }, { key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" }, { key: "bind", kind: "text" },
@@ -103,29 +94,12 @@ export const SECTIONS: readonly AnySectionDef[] = [
{ key: "trusted_proxies", kind: "text" }, { key: "trusted_proxies", kind: "text" },
], ],
}), }),
defineSection({ defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }),
section: "doh_server", defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }),
title: "DoH Server", defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }),
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({ defineSection({
section: "logging", section: "logging",
title: "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: [ fields: [
{ key: "level", kind: ["error", "warn", "info", "debug"] }, { key: "level", kind: ["error", "warn", "info", "debug"] },
{ key: "retention_days", kind: "number" }, { key: "retention_days", kind: "number" },
@@ -142,7 +116,6 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({ defineSection({
section: "disk", section: "disk",
title: "Disk", title: "Disk",
description: "The free space below which nxdns warns, and below which it stops writing history.",
fields: [ fields: [
{ key: "min_free_mb", kind: "number" }, { key: "min_free_mb", kind: "number" },
{ key: "warn_free_mb", kind: "number" }, { key: "warn_free_mb", kind: "number" },
@@ -151,7 +124,6 @@ export const SECTIONS: readonly AnySectionDef[] = [
defineSection({ defineSection({
section: "blocklist_update", section: "blocklist_update",
title: "Blocklist Update", title: "Blocklist Update",
description: "Whether the blocklists are fetched again on their own, and how often.",
fields: [ fields: [
{ key: "enabled", kind: "boolean" }, { key: "enabled", kind: "boolean" },
{ key: "interval_hours", kind: "number" }, { key: "interval_hours", kind: "number" },
+1 -10
View File
@@ -32,21 +32,12 @@ export const styles = stylex.create({
}, },
panelHeading: { panelHeading: {
margin: 0, margin: 0,
marginBottom: "1rem",
fontSize: "1.4rem", fontSize: "1.4rem",
lineHeight: 1.2, lineHeight: 1.2,
fontWeight: 650, fontWeight: 650,
letterSpacing: "-0.015em", 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. */ /** The collection's own key in the configuration file, beside its heading. */
panelKey: { panelKey: {
marginLeft: "0.5rem", marginLeft: "0.5rem",
@@ -93,8 +93,7 @@ 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" }); await screen.findByRole("heading", { name: "Blocklist source failed to update" });
expect(screen.getByText("Warning")).toBeTruthy(); expect(screen.getByText("Warning")).toBeTruthy();
expect(screen.getByText("StevenBlack")).toBeTruthy(); expect(screen.getByText("StevenBlack")).toBeTruthy();
// The seconds since the fixture was anchored are the render's to add. expect(screen.getByText("Active for 2h")).toBeTruthy();
expect(screen.getByText(/^Active for 2h(?: \d+s)?$/)).toBeTruthy();
expect(screen.getByText("Not yet — still failing")).toBeTruthy(); expect(screen.getByText("Not yet — still failing")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy(); expect(screen.getByText("4")).toBeTruthy();
expect(screen.getByText("blocklist.refresh")).toBeTruthy(); expect(screen.getByText("blocklist.refresh")).toBeTruthy();
@@ -4,10 +4,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { Link, useNavigate, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { formatCount, formatDuration, formatTime } from "@/lib/format"; import { formatDuration, formatTime } from "@/lib/format";
import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries"; import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries";
import ConfirmDialog from "@/ui/ConfirmDialog"; import ConfirmDialog from "@/ui/ConfirmDialog";
import Card from "@/ui/Card";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex"; import { colors, metrics } from "@/ui/tokens.stylex";
import SeverityBadge from "./SeverityBadge"; import SeverityBadge from "./SeverityBadge";
@@ -50,6 +49,12 @@ const styles = stylex.create({
panel: { panel: {
marginTop: "1rem", marginTop: "1rem",
maxWidth: "48rem", maxWidth: "48rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: metrics.cardPadding,
}, },
facts: { facts: {
display: "grid", display: "grid",
@@ -181,11 +186,7 @@ export default function DiagnosticDetailPage() {
<p {...stylex.props(styles.subject)}>{data.subject}</p> <p {...stylex.props(styles.subject)}>{data.subject}</p>
<InlineError error={purge.error} /> <InlineError error={purge.error} />
<Card <div {...stylex.props(styles.panel)}>
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)}> <dl {...stylex.props(styles.facts)}>
<dt {...stylex.props(styles.term)}>State</dt> <dt {...stylex.props(styles.term)}>State</dt>
<dd {...stylex.props(styles.value)}> <dd {...stylex.props(styles.value)}>
@@ -198,7 +199,7 @@ export default function DiagnosticDetailPage() {
<dt {...stylex.props(styles.term)}>Last seen</dt> <dt {...stylex.props(styles.term)}>Last seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(data.last_seen)}</dd> <dd {...stylex.props(styles.value)}>{formatTime(data.last_seen)}</dd>
<dt {...stylex.props(styles.term)}>Occurrences</dt> <dt {...stylex.props(styles.term)}>Occurrences</dt>
<dd {...stylex.props(styles.value, shared.tabularNums)}>{formatCount(data.occurrences)}</dd> <dd {...stylex.props(styles.value, shared.tabularNums)}>{data.occurrences}</dd>
<dt {...stylex.props(styles.term)}>Resolved</dt> <dt {...stylex.props(styles.term)}>Resolved</dt>
<dd {...stylex.props(styles.value)}> <dd {...stylex.props(styles.value)}>
{data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)} {data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)}
@@ -208,7 +209,7 @@ export default function DiagnosticDetailPage() {
<dt {...stylex.props(styles.term)}>Code</dt> <dt {...stylex.props(styles.term)}>Code</dt>
<dd {...stylex.props(styles.value, shared.mono)}>{data.code}</dd> <dd {...stylex.props(styles.value, shared.mono)}>{data.code}</dd>
</dl> </dl>
</Card> </div>
<h2 {...stylex.props(styles.sectionHeading)}>Impact</h2> <h2 {...stylex.props(styles.sectionHeading)}>Impact</h2>
<p {...stylex.props(styles.prose)}>{copy.impact}</p> <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")!; const active = screen.getByText("Blocklist source failed to update").closest("li")!;
expect(within(active).getByText("Warning")).toBeTruthy(); expect(within(active).getByText("Warning")).toBeTruthy();
expect(within(active).getByText("StevenBlack")).toBeTruthy(); expect(within(active).getByText("StevenBlack")).toBeTruthy();
expect(within(active).getByText(/Active for 1h(?: \d+s)? · 3 occurrences/)).toBeTruthy(); expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy();
const failing = screen.getByText("Upstream failing").closest("li")!; const failing = screen.getByText("Upstream failing").closest("li")!;
expect(within(failing).getByText("Error")).toBeTruthy(); expect(within(failing).getByText("Error")).toBeTruthy();
@@ -10,7 +10,7 @@ import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api"; import * as api from "@/lib/api";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { formatCount, formatDuration, formatTime } from "@/lib/format"; import { formatDuration, formatTime } from "@/lib/format";
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries"; import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types"; import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog"; import ConfirmDialog from "@/ui/ConfirmDialog";
@@ -236,7 +236,7 @@ function errorMessage(error: unknown): string {
} }
function occurrenceText(count: number): string { function occurrenceText(count: number): string {
return `${formatCount(count)} ${count === 1 ? "occurrence" : "occurrences"}`; return `${count} ${count === 1 ? "occurrence" : "occurrences"}`;
} }
function rowsOf(section: Section): DiagnosticEvent[] { function rowsOf(section: Section): DiagnosticEvent[] {
@@ -357,7 +357,7 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}> <td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)} {event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
</td> </td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatCount(event.occurrences)}</td> <td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}> <td {...stylex.props(styles.cell, styles.nowrap)}>
<button <button
type="button" type="button"
@@ -495,7 +495,7 @@ export default function DiagnosticsPage() {
<p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p> <p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p>
) : ( ) : (
<> <>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}> <div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}> <table {...stylex.props(styles.table)}>
<thead {...stylex.props(styles.head)}> <thead {...stylex.props(styles.head)}>
<tr> <tr>
@@ -523,9 +523,8 @@ export default function DiagnosticsPage() {
</table> </table>
</div> </div>
<p {...stylex.props(styles.footer, styles.note)}> <p {...stylex.props(styles.footer, styles.note)}>
Showing{" "} Showing <span {...stylex.props(shared.tabularNums)}>{historyRows.length}</span> resolved{" "}
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "} {historyRows.length === 1 ? "entry" : "entries"}
resolved {historyRows.length === 1 ? "entry" : "entries"}
{hasMore(history) ? "" : " — end of history"} {hasMore(history) ? "" : " — end of history"}
</p> </p>
<MoreButton section={history} /> <MoreButton section={history} />
@@ -101,7 +101,7 @@ export function healthFacts(health: Health, locale?: string, timeZone?: string):
label: "Upstreams", label: "Upstreams",
tone: upstreams.state === "unavailable" ? "danger" : "ok", tone: upstreams.state === "unavailable" ? "danger" : "ok",
value: upstreams.state === "unavailable" ? "None reachable" : "Available", value: upstreams.state === "unavailable" ? "None reachable" : "Available",
detail: `${formatCount(upstreams.available)} of ${formatCount(upstreams.total)} enabled`, detail: `${upstreams.available} of ${upstreams.total} enabled`,
...(upstreams.state === "unavailable" ...(upstreams.state === "unavailable"
? { link: { kind: "route", to: "/configuration/resolution", label: "Upstreams" } as FactLink } ? { link: { kind: "route", to: "/configuration/resolution", label: "Upstreams" } as FactLink }
: {}), : {}),
@@ -280,10 +280,9 @@ test("a bucket's tooltip lists at most four named clients, the quiet ones droppe
"192.0.2.44", "192.0.2.44",
"192.0.2.43", "192.0.2.43",
"192.0.2.42", "192.0.2.42",
"Other",
"All clients", "All clients",
]); ]);
expect(screen.getByRole("table").querySelectorAll("th[scope=col]")).toHaveLength(8); expect(screen.getByRole("table").querySelectorAll("th[scope=col]")).toHaveLength(7);
}); });
test("leaving the chart takes the tooltip and the dimming with it", () => { test("leaving the chart takes the tooltip and the dimming with it", () => {
@@ -304,25 +303,31 @@ 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 * 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. * is empty. The named clients stay at zero: a client that went quiet is a fact.
*/ */
test("a window where Other counted nothing keeps it in the legend and the table, at zero", () => { test("a window where Other counted nothing drops it from every surface", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 0])); 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([ expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual(["192.0.2.30"]);
"192.0.2.30",
"Other",
]);
expect( expect(
within(screen.getByRole("table")) within(screen.getByRole("table"))
.getAllByRole("columnheader") .getAllByRole("columnheader")
.map((cell) => cell.textContent), .map((cell) => cell.textContent),
).toEqual(["Time", "192.0.2.30", "Other"]); ).toEqual(["Time", "192.0.2.30"]);
fireEvent.mouseOver(overlayRects(container)[0]); fireEvent.mouseOver(overlayRects(container)[0]);
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt")); 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"]); expect(terms.map((term) => term.textContent)).toEqual(["192.0.2.30", "All clients"]);
}); });
test("the hidden table groups its counts like every other figure on the page", () => { test("the hidden table groups its counts like every other figure on the page", () => {
render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0])); render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0]));
expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy(); expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy();
}); });
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",
]);
});
+12 -11
View File
@@ -49,10 +49,11 @@ interface Series {
} }
/** /**
* "Other" last, so it sits at the top of every column rather than under a client. * "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: * and dropped entirely when it counted nothing across the window: an aggregation
* eight named clients plus Other): a reader comparing two scopes sees the same * bucket that aggregated nothing is a legend entry, a stack key, a tooltip row
* legend in both, and a zero says the named clients were the whole story. * 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.
*/ */
function seriesOf(data: ClientChartData, names: ClientNames): Series[] { function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
const named = data.clients.map((client, rank) => ({ const named = data.clients.map((client, rank) => ({
@@ -63,6 +64,7 @@ function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
color: clientSeriesColor(rank), color: clientSeriesColor(rank),
buckets: client.buckets, 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 }]; return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
} }
@@ -101,7 +103,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
const timestamps = columns.map((column) => column.ts); const timestamps = columns.map((column) => column.ts);
const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0)); const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0));
const plot = plotArea(width, Math.max(...totals)); const plot = plotArea(width);
const xScale = bandScale(timestamps, plot); const xScale = bandScale(timestamps, plot);
// Every series here is a disjoint part of the whole rather than a highlighted // 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 // subset of a separately reported total, so the tallest column's own sum is
@@ -112,10 +114,9 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot)); const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot));
/** /**
* The bucket's busiest clients, then Other, then the total — eight named * The bucket's busiest clients, then the aggregate, then the total — eight
* rows would be a table, and the reader pointing at a spike wants to know * named rows would be a table, and the reader pointing at a spike wants to
* who made it. Other is always there, at zero when the named clients were * know who made it.
* the whole bucket, so the rows read the same from bucket to bucket.
*/ */
function tooltipOf(index: number): TooltipContent { function tooltipOf(index: number): TooltipContent {
const column = columns[index]; const column = columns[index];
@@ -124,7 +125,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
.sort((a, b) => column[b.key] - column[a.key]) .sort((a, b) => column[b.key] - column[a.key])
.slice(0, TOOLTIP_CLIENTS); .slice(0, TOOLTIP_CLIENTS);
const other = series.find((one) => one.key === OTHER_KEY); const other = series.find((one) => one.key === OTHER_KEY);
const rows = [...named, ...(other !== undefined ? [other] : [])].map((one) => ({ const rows = [...named, ...(other !== undefined && column[OTHER_KEY] > 0 ? [other] : [])].map((one) => ({
key: one.key, key: one.key,
label: one.label, label: one.label,
color: one.color, color: one.color,
@@ -140,7 +141,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
<ChartRoot containerRef={containerRef}> <ChartRoot containerRef={containerRef}>
<svg <svg
role="img" role="img"
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series`} aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
width="100%" width="100%"
height={CHART_HEIGHT} height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`} viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
+4 -33
View File
@@ -9,7 +9,6 @@
* the frame would drift. Nothing here imports a chart. * the frame would drift. Nothing here imports a chart.
*/ */
import { useCallback } from "react";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
@@ -66,22 +65,6 @@ const styles = stylex.create({
minWidth: { default: "11rem", [NARROW]: 0 }, minWidth: { default: "11rem", [NARROW]: 0 },
flex: { default: null, [NARROW]: 1 }, 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",
},
/** /**
* The height approximates the filled overview — stat tiles, two 240px charts * 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 * and the row of cards under them — so that the page does not jump when the
@@ -104,11 +87,9 @@ export interface OverviewScope {
* The devices the reader can scope to: the whole household first, then every * 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 * 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 * 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 * the page is actually under rather than silently claiming the household.
* 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) { function useDeviceOptions(client: string | undefined): SelectOption[] {
const clients = useQuery(clientsQuery()); const clients = useQuery(clientsQuery());
const names = useClientNames(); const names = useClientNames();
const known = (clients.data ?? []).map((one) => ({ const known = (clients.data ?? []).map((one) => ({
@@ -119,9 +100,7 @@ function useDeviceOptions(client: string | undefined) {
if (client !== undefined && !known.some((option) => option.value === client)) { if (client !== undefined && !known.some((option) => option.value === client)) {
options.push({ value: client, label: client }); options.push({ value: client, label: client });
} }
const { refetch } = clients; return options;
const retry = useCallback(() => void refetch(), [refetch]);
return { options, failed: clients.isError, retry };
} }
export function OverviewToolbar({ export function OverviewToolbar({
@@ -138,18 +117,10 @@ export function OverviewToolbar({
<Select <Select
aria-label="Device" aria-label="Device"
variant="toolbar" variant="toolbar"
options={devices.options} options={devices}
value={scope.client ?? ALL_DEVICES} value={scope.client ?? ALL_DEVICES}
onChange={(value) => onChange({ ...scope, client: value === ALL_DEVICES ? undefined : value })} 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>
<div {...stylex.props(styles.control)}> <div {...stylex.props(styles.control)}>
<Select <Select
@@ -271,10 +271,10 @@ test("naming a client does not recolour its series: colour goes by rank", async
expect(swatch.getAttribute("style")).toContain(clientSeriesColor(0)); expect(swatch.getAttribute("style")).toContain(clientSeriesColor(0));
}); });
test("the client chart keeps Other in a period where it counted nothing", async () => { test("the client chart drops Other in a period where it counted nothing", async () => {
// The fixture's other series is all zeroes. Other is still a series, so the // The fixture's other series is all zeroes. An aggregation bucket that
// legend reads the same in every scope and its zero says the named clients // aggregated nothing is a legend entry and a table column that say only that
// were the whole story. // they are empty; the named clients stay, because a quiet client is a fact.
renderApp(); renderApp();
await screen.findByRole("heading", { name: "Client activity over time" }); await screen.findByRole("heading", { name: "Client activity over time" });
@@ -283,7 +283,7 @@ test("the client chart keeps Other in a period where it counted nothing", async
// Twice each: the legend swatch and the column header of the table a screen // Twice each: the legend swatch and the column header of the table a screen
// reader gets instead of the graphic. // reader gets instead of the graphic.
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2)); await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2));
expect(within(chart as HTMLElement).getAllByText("Other")).toHaveLength(2); expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
}); });
test("the page is four tiles, two charts, the cache card 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 () => {
@@ -112,10 +112,9 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
const columns = columnsOf(data.buckets); const columns = columnsOf(data.buckets);
const timestamps = columns.map((column) => column.ts); const timestamps = columns.map((column) => column.ts);
const peak = Math.max(...columns.map((column) => column.queries)); const plot = plotArea(width);
const plot = plotArea(width, peak);
const xScale = pointScale(timestamps, plot); const xScale = pointScale(timestamps, plot);
const yScale = valueScale(peak, [plot.bottom, plot.y]); const yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
const yTicks = valueTicks(yScale); const yTicks = valueTicks(yScale);
const centers = timestamps.map((ts) => xScale(ts) ?? plot.x); const centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
const pointsOf = (key: SeriesKey): [number, number][] => const pointsOf = (key: SeriesKey): [number, number][] =>
@@ -125,7 +124,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
<ChartRoot containerRef={containerRef}> <ChartRoot containerRef={containerRef}>
<svg <svg
role="img" role="img"
aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket`} aria-label={`Queries over time, ${data.buckets.length} buckets: total and blocked queries per bucket`}
width="100%" width="100%"
height={CHART_HEIGHT} height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`} viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
@@ -1,12 +1,7 @@
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { formatCount } from "@/lib/format";
import { import {
CHART_HEIGHT, CHART_HEIGHT,
ChartFrame, ChartFrame,
ChartTooltip,
MARGIN,
TICK_GLYPH_PX,
TICK_LABEL_GAP_PX,
bandPaddingInner, bandPaddingInner,
bandScale, bandScale,
labelTickValues, labelTickValues,
@@ -38,91 +33,6 @@ describe("valueScale", () => {
expect(scale.ticks(5)).toEqual([0, 500, 1000, 1500]); expect(scale.ticks(5)).toEqual([0, 500, 1000, 1500]);
expect(valueTicks(scale)).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", () => { describe("bandPaddingInner", () => {
+18 -48
View File
@@ -15,18 +15,13 @@ import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
import { GridRows } from "@visx/grid"; import { GridRows } from "@visx/grid";
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale"; import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
import { TooltipWithBounds } from "@visx/tooltip"; import { TooltipWithBounds } from "@visx/tooltip";
import { formatBucketTime, formatCount } from "@/lib/format"; import { formatBucketTime, formatCompact } from "@/lib/format";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors, layers, metrics } from "@/ui/tokens.stylex"; import { colors, layers, metrics } from "@/ui/tokens.stylex";
export const CHART_HEIGHT = 240; export const CHART_HEIGHT = 240;
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const; 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. */ /** The width a chart draws at before a measurement exists — jsdom, and the first paint. */
const FALLBACK_WIDTH = 640; const FALLBACK_WIDTH = 640;
@@ -47,20 +42,12 @@ export interface Plot {
bottom: number; 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 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 { return {
x: left, x: MARGIN.left,
y: MARGIN.top, y: MARGIN.top,
width: Math.max(0, width - left - MARGIN.right), width: Math.max(0, width - MARGIN.left - MARGIN.right),
height, height,
bottom: MARGIN.top + height, bottom: MARGIN.top + height,
}; };
@@ -100,13 +87,9 @@ export function valueScale(max: number, range: [number, number]) {
export type ValueScale = ReturnType<typeof valueScale>; 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[] { export function valueTicks(scale: ValueScale): number[] {
return scale.ticks(Y_TICK_COUNT).filter(Number.isInteger); return scale.ticks(Y_TICK_COUNT);
} }
/** /**
@@ -216,6 +199,9 @@ const styles = stylex.create({
fontSize: "10px", fontSize: "10px",
}, },
tooltip: { tooltip: {
pointerEvents: "none",
position: "absolute",
zIndex: layers.tooltip,
borderRadius: metrics.radius, borderRadius: metrics.radius,
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
@@ -227,11 +213,9 @@ const styles = stylex.create({
lineHeight: "1rem", lineHeight: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", 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. */ /** Beside a point, the box is centred on the point's height rather than hung from it. */
tooltipShell: { tooltipBeside: {
pointerEvents: "none", transform: "translateY(-50%)",
position: "absolute",
zIndex: layers.tooltip,
}, },
tooltipTitle: { tooltipTitle: {
fontWeight: 500, fontWeight: 500,
@@ -357,7 +341,7 @@ export function ChartFrame({
hideAxisLine hideAxisLine
hideTicks hideTicks
tickLength={0} tickLength={0}
tickFormat={(value) => formatCount(Number(value))} tickFormat={(value) => formatCompact(Number(value))}
// `dy` overrides AxisLeft's own 0.25em nudge, which would double up // `dy` overrides AxisLeft's own 0.25em nudge, which would double up
// with the middle baseline this app centres its value labels on. // with the middle baseline this app centres its value labels on.
tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }} tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }}
@@ -498,8 +482,7 @@ export interface TooltipContent {
/** /**
* `TooltipWithBounds` positions itself with an inline transform and drops it * `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 * when `unstyled` is set, so the default look is replaced by handing it an empty
* style object rather than by turning styling off. What it keeps is the * style object rather than by turning styling off.
* positioning; the box the reader sees is the element inside it.
*/ */
const NO_INLINE_STYLE = {}; const NO_INLINE_STYLE = {};
@@ -526,11 +509,8 @@ export function ChartTooltip({
top?: number; top?: number;
/** /**
* Beside the anchor at its own height rather than hung from the chart's top: * 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 * the area chart's placement, where `top` is the point's y. `TooltipWithBounds`
* negative half-height `offsetTop`, not a CSS transform, so that the flip * flips the box to the anchor's left when it would run past the right edge.
* `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; beside?: boolean;
}) { }) {
@@ -540,25 +520,16 @@ export function ChartTooltip({
// a digit boundary, or resolve a client's name, and the stale width would place // a digit boundary, or resolve a client's name, and the stale width would place
// it wrongly at the right edge. // it wrongly at the right edge.
const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|"); 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 ( return (
<TooltipWithBounds <TooltipWithBounds
key={measureKey} key={measureKey}
left={left} left={left}
top={top} top={top}
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET} offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
offsetTop={beside ? -height / 2 : CORNER_OFFSET} offsetTop={beside ? 0 : CORNER_OFFSET}
style={NO_INLINE_STYLE} style={NO_INLINE_STYLE}
className={stylex.props(styles.tooltipShell).className} className={stylex.props(styles.tooltip, beside && styles.tooltipBeside).className}
> >
<div ref={box} {...stylex.props(styles.tooltip)}>
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div> <div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
<dl {...stylex.props(styles.tooltipList)}> <dl {...stylex.props(styles.tooltipList)}>
{content.rows.map((row) => ( {content.rows.map((row) => (
@@ -576,7 +547,6 @@ export function ChartTooltip({
</div> </div>
))} ))}
</dl> </dl>
</div>
</TooltipWithBounds> </TooltipWithBounds>
); );
} }
@@ -3,42 +3,12 @@ import { parseClient } from "./clientScope";
test("one address passes, and nothing else does", () => { test("one address passes, and nothing else does", () => {
expect(parseClient("192.0.2.30")).toBe("192.0.2.30"); expect(parseClient("192.0.2.30")).toBe("192.0.2.30");
expect(parseClient("2001:db8::1")).toBe("2001:db8::1"); 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(undefined)).toBeUndefined();
expect(parseClient("")).toBeUndefined(); expect(parseClient("")).toBeUndefined();
expect(parseClient(7)).toBeUndefined(); expect(parseClient(7)).toBeUndefined();
// A list is the Activity filter's grammar, not this page's. // A list is the Activity filter's grammar, not this page's.
expect(parseClient("192.0.2.30,192.0.2.31")).toBeUndefined(); expect(parseClient("192.0.2.30,192.0.2.31")).toBeUndefined();
}); // Longer than any address the server stores.
expect(parseClient("1".repeat(46))).toBeUndefined();
test("an IPv6 address is reduced to the spelling the logger stores", () => { expect(parseClient("1".repeat(45))).toBe("1".repeat(45));
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");
}); });
+8 -88
View File
@@ -1,96 +1,16 @@
/** /**
* The device Overview is scoped to, as URL state beside the period. * 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 * The API takes one exact address (`logger.max_client_len` bytes at most, no
* stored, so that is what the route lets through: a value that is not an IPv4 * list), so that is what the route lets through: anything else is dropped from
* or IPv6 address is dropped from the URL rather than sent on to a 400, and an * the URL rather than sent on to a 400. An absent scope is the whole household.
* 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)$/; /** `logger.max_client_len` on the server: the longest textual address it stores. */
const HEXTET = /^[0-9a-f]{1,4}$/i; const MAX_CLIENT_LEN = 45;
/** 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 { export function parseClient(value: unknown): string | undefined {
if (typeof value !== "string") return undefined; if (typeof value !== "string" || value.length === 0 || value.length > MAX_CLIENT_LEN) return undefined;
if (IPV4.test(value)) return value; if (value.includes(",")) return undefined;
const groups = ipv6Groups(value); return 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 * 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 * 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 * pinned below: the three states, and the one rule a single request still does
* not settle — that the body of the scope the reader just left, period or * not settle — that a `keepPreviousData` body from the period the reader left
* device, must never render under the new scope's label. * must never render under the new period's label.
*/ */
import { render, screen, waitFor } from "@testing-library/react"; 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. */ /** The last retry the hook handed out, so a test can spend it. */
let lastRetry: () => void; let lastRetry: () => void;
function Probe({ period, client }: { period: Period; client?: string }) { function Probe({ period }: { period: Period }) {
const panel = useOverviewWindow(period, client); const panel = useOverviewWindow(period, undefined);
if (panel.status === "error") lastRetry = panel.retry; if (panel.status === "error") lastRetry = panel.retry;
const detail = const detail =
panel.status === "ready" panel.status === "ready"
@@ -75,18 +75,18 @@ function Probe({ period, client }: { period: Period; client?: string }) {
return <p>{`${panel.status}:${detail}`}</p>; return <p>{`${panel.status}:${detail}`}</p>;
} }
function renderProbe(period: Period = "24h", scope?: string) { function renderProbe(period: Period = "24h") {
const client = createQueryClient(); const client = createQueryClient();
const view = render( const view = render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<Probe period={period} client={scope} /> <Probe period={period} />
</QueryClientProvider>, </QueryClientProvider>,
); );
return { return {
rerenderWith: (next: Period, nextScope?: string) => rerenderWith: (next: Period) =>
view.rerender( view.rerender(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<Probe period={next} client={nextScope} /> <Probe period={next} />
</QueryClientProvider>, </QueryClientProvider>,
), ),
}; };
@@ -115,24 +115,13 @@ test("a failed request is one error for the whole page, with a retry that refetc
expect(calls).toBeGreaterThan(spent); expect(calls).toBeGreaterThan(spent);
}); });
test("a previous period's body never renders under the new period's label", async () => { test("a retained previous-period body never renders under the new period's label", async () => {
const { rerenderWith } = renderProbe("24h"); const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`)); await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
rerenderWith("1h"); rerenderWith("1h");
// The 24h body is a complete answer and still the wrong one to draw under // `keepPreviousData` is holding the 24h body. It is a complete answer and
// "1h", so the page waits for its own. // still the wrong one to draw under "1h", so the page waits.
expect(line()).toBe("loading:"); expect(line()).toBe("loading:");
await waitFor(() => expect(line()).toBe(`ready:1h@${UNTIL}`)); 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,15 +8,15 @@
* breakdowns describe the same span and the same database state by construction, * breakdowns describe the same span and the same database state by construction,
* and none of that reconciliation has anything left to reconcile. * and none of that reconciliation has anything left to reconcile.
* *
* The query key carries the period and the client, so the body the hook * What remains is the one rule a single request does not settle by itself.
* returns is always the body of the scope the toolbar names. A rescope shows * `keepPreviousData` holds the body of the period the reader just left — a
* the loading state until its own answer lands rather than the previous * complete, self-consistent answer, and still the wrong one to draw under the
* scope's charts under the new label: a complete, self-consistent body for the * new label — so a body is a member of this window only while its own `period`
* wrong device is still the wrong body. * is the selected one. Until then the page is loading.
*/ */
import { useCallback } from "react"; import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { overviewQuery } from "@/lib/queries"; import { overviewQuery } from "@/lib/queries";
import type { Overview, Period } from "@/lib/types"; import type { Overview, Period } from "@/lib/types";
@@ -24,11 +24,11 @@ export type Panel<T> =
{ status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T }; { status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T };
export function useOverviewWindow(period: Period, client: string | undefined): Panel<Overview> { export function useOverviewWindow(period: Period, client: string | undefined): Panel<Overview> {
const query = useQuery(overviewQuery(period, client)); const query = useQuery({ ...overviewQuery(period, client), placeholderData: keepPreviousData });
const { refetch } = query; const { refetch } = query;
const retry = useCallback(() => void refetch(), [refetch]); const retry = useCallback(() => void refetch(), [refetch]);
if (query.isError) return { status: "error", error: query.error, retry }; if (query.isError) return { status: "error", error: query.error, retry };
if (query.data !== undefined) return { status: "ready", data: query.data }; if (query.data !== undefined && query.data.period === period) return { status: "ready", data: query.data };
return { status: "loading" }; return { status: "loading" };
} }
+1 -2
View File
@@ -3,7 +3,6 @@ import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import { formatDuration } from "@/lib/format";
const styles = stylex.create({ const styles = stylex.create({
message: { message: {
@@ -46,7 +45,7 @@ export default function InlineError({ error, onRetry }: { error: unknown; onRetr
if (error.status === 429) { if (error.status === 429) {
message = message =
remaining !== null && remaining > 0 remaining !== null && remaining > 0
? `Rate limited. Try again in ${formatDuration(remaining)}.` ? `Rate limited. Try again in ${remaining}s.`
: "Rate limited. Try again."; : "Rate limited. Try again.";
} else if (error.status === 503) { } else if (error.status === 503) {
message = "The server is starting or degraded. Try again shortly."; message = "The server is starting or degraded. Try again shortly.";
+12 -10
View File
@@ -1,6 +1,7 @@
import { import {
formatBytes, formatBytes,
formatClock, formatClock,
formatCompact,
formatCount, formatCount,
formatDuration, formatDuration,
formatMicros, formatMicros,
@@ -11,7 +12,7 @@ import {
test("formatTime renders unix seconds in the given locale and zone", () => { 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. // 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
expect(formatTime(1704067200, "en-US", "UTC").replace(/\u202f/g, " ")).toBe("Jan 1, 2024, 12:00:00 AM"); expect(formatTime(1704067200, "en-US", "UTC").replace(/ /g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
}); });
test("formatClock states the time of day alone, for a stamp read against now", () => { test("formatClock states the time of day alone, for a stamp read against now", () => {
@@ -28,6 +29,12 @@ test("formatCount groups thousands", () => {
expect(formatCount(1_234_567)).toBe("1,234,567"); expect(formatCount(1_234_567)).toBe("1,234,567");
}); });
test("formatCompact squeezes an axis label", () => {
expect(formatCompact(950)).toBe("950");
expect(formatCompact(18432)).toBe("18K");
expect(formatCompact(2_500_000)).toBe("2.5M");
});
test("formatPercent always carries two decimals, rounded half up", () => { test("formatPercent always carries two decimals, rounded half up", () => {
expect(formatPercent(0)).toBe("0.00%"); expect(formatPercent(0)).toBe("0.00%");
expect(formatPercent(1)).toBe("100.00%"); expect(formatPercent(1)).toBe("100.00%");
@@ -87,16 +94,11 @@ test("formatMicros renders milliseconds with one decimal", () => {
}); });
/** /**
* The contract's teeth, as far as a text scan can bite: no source outside this * The contract's teeth: nothing outside this module formats a number for the
* module calls a number-formatting API. A bare `{count}` in JSX is invisible to * reader. `datetime-local` zero-padding in `activity/datetime.ts` is a wire
* this scan and is caught in review; the scan closes the door on the four ways * grammar, not a display rule, and is the one allowed `padStart`.
* 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", () => { test("no source file outside format.ts formats a number inline", () => {
const sources = import.meta.glob<string>("../**/*.{ts,tsx}", { query: "?raw", import: "default", eager: true }); const sources = import.meta.glob<string>("../**/*.{ts,tsx}", { query: "?raw", import: "default", eager: true });
const offenders: string[] = []; const offenders: string[] = [];
for (const [path, text] of Object.entries(sources)) { for (const [path, text] of Object.entries(sources)) {
+6 -10
View File
@@ -6,16 +6,6 @@
* duration is its two largest nonzero units with no zero padding. A call site * 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 * that formats inline is a defect; the sweep test in `format.test.ts` and the
* review both hunt for one. * 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. */ /** Unix seconds → localized date-time. `locale`/`timeZone` exist for deterministic tests. */
@@ -51,12 +41,18 @@ export function formatBucketTime(unixSeconds: number, bucketSeconds: number): st
} }
const grouped = new Intl.NumberFormat("en-US"); const grouped = new Intl.NumberFormat("en-US");
const compact = new Intl.NumberFormat("en-US", { notation: "compact" });
/** A count, thousands-grouped: 18432 → "18,432". */ /** A count, thousands-grouped: 18432 → "18,432". */
export function formatCount(count: number): string { export function formatCount(count: number): string {
return grouped.format(count); return grouped.format(count);
} }
/** A count squeezed for an axis label: 18432 → "18K". Never for a figure the reader acts on. */
export function formatCompact(count: number): string {
return compact.format(count);
}
/** /**
* Round half up at `decimals` places. `toFixed` rounds the binary value, so * 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 * 15.435 (stored just under) would print 15.43; the nudge lifts an exact half
+1 -1
View File
@@ -48,7 +48,7 @@ export const healthQuery = () =>
queryOptions({ queryKey: queryKeys.health, queryFn: api.getHealth, refetchInterval: 10_000 }); queryOptions({ queryKey: queryKeys.health, queryFn: api.getHealth, refetchInterval: 10_000 });
export const versionQuery = () => export const versionQuery = () =>
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, refetchInterval: 60_000 }); queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
export const overviewQuery = (period: Period = "24h", client?: string) => export const overviewQuery = (period: Period = "24h", client?: string) =>
queryOptions({ queryOptions({
+1 -5
View File
@@ -52,7 +52,6 @@ import {
import type { Period } from "@/lib/types"; import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import { formatDuration } from "@/lib/format";
export interface RouterContext { export interface RouterContext {
queryClient: QueryClient; queryClient: QueryClient;
@@ -106,10 +105,7 @@ function RouteError({ error }: ErrorComponentProps) {
detail = error.message; detail = error.message;
} else if (error.status === 429) { } else if (error.status === 429) {
title = "Rate limited"; title = "Rate limited";
detail = detail = error.retryAfter !== undefined ? `Try again in ${error.retryAfter}s.` : "Try again shortly.";
error.retryAfter !== undefined
? `Try again in ${formatDuration(error.retryAfter)}.`
: "Try again shortly.";
} else if (error.status >= 500) { } else if (error.status >= 500) {
title = "Internal error"; title = "Internal error";
} else { } else {
+3 -4
View File
@@ -101,10 +101,9 @@ export function ProtectionDot() {
} }
/** /**
* Uptime as of now, not as of the fetch: the version answer is a minute old at * Uptime as of now, not as of the fetch: the version answer is cached for the
* most, so the seconds it reported are aged by the time since it landed. A * session, so the seconds it reported are aged by the time since it landed.
* restart shows within that minute. Ticks every second, because the first * Ticks every second, because the first minute reads in seconds.
* minute reads in seconds.
*/ */
function useUptimeSeconds(): number | null { function useUptimeSeconds(): number | null {
const version = useQuery(versionQuery()); const version = useQuery(versionQuery());
-9
View File
@@ -13,15 +13,6 @@ test("open episodes are the count, warnings and errors together", () => {
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" }); 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", () => { test("one open episode is counted in the singular", () => {
const badge = diagnosticsBadge( const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }), health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }),
+1 -5
View File
@@ -14,7 +14,6 @@
*/ */
import type { Health } from "@/lib/types"; import type { Health } from "@/lib/types";
import { formatCount } from "@/lib/format";
export interface NavBadge { export interface NavBadge {
/** What the badge shows. Shape and text, never colour alone. */ /** What the badge shows. Shape and text, never colour alone. */
@@ -31,10 +30,7 @@ export function diagnosticsBadge(health: Health | undefined, pollFailed: boolean
if (health === undefined) return null; if (health === undefined) return null;
const open = health.diagnostics.active_warnings + health.diagnostics.active_errors; const open = health.diagnostics.active_warnings + health.diagnostics.active_errors;
if (open > 0) { if (open > 0) {
return { return { text: String(open), label: `${open} active diagnostic ${open === 1 ? "event" : "events"}` };
text: formatCount(open),
label: `${formatCount(open)} active diagnostic ${open === 1 ? "event" : "events"}`,
};
} }
if (health.status === "degraded") return { text: "!", label: "Health degraded" }; if (health.status === "degraded") return { text: "!", label: "Health degraded" };
return null; return null;
-1
View File
@@ -264,7 +264,6 @@ export const styles = stylex.create({
paddingInline: "0.75rem", paddingInline: "0.75rem",
paddingBlock: "0.5rem", paddingBlock: "0.5rem",
}, },
/** Focusable: a table wider than a phone scrolls sideways, and a keyboard must be able to reach the scroll. */
tableWrap: { tableWrap: {
marginTop: "1rem", marginTop: "1rem",
overflowX: "auto", overflowX: "auto",
+1 -1
View File
@@ -92,7 +92,7 @@ export const colors = stylex.defineVars({
seriesMagenta: { default: "oklch(66% 0.14 340)", [DARK]: "oklch(66% 0.14 340)" }, 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)" }, 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)" }, 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)" }, seriesOther: { default: "oklch(80% 0.01 260)", [DARK]: "oklch(45% 0.01 260)" },
/** /**
* The query-types ring: the accent's hue stepped in lightness and chroma from * 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 * the busiest type outward. Six steps cover every ring the API answers with