From 85b8be50a0b8280e20039f6dee830fd0d750440e Mon Sep 17 00:00:00 2001 From: m5r Date: Mon, 7 Sep 2026 23:11:50 +0200 Subject: [PATCH] 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 centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only. GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry. All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else. Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash. Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round. Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR --- CHANGELOG.md | 11 + admin/src/auth/LoginPage.tsx | 3 +- .../src/features/activity/ActivityFilters.tsx | 4 +- admin/src/features/activity/ActivityPage.tsx | 4 +- admin/src/features/activity/ClientFilter.tsx | 7 +- .../src/features/activity/HistoryActivity.tsx | 9 +- admin/src/features/activity/LiveActivity.tsx | 23 +- .../src/features/activity/PolicyTestPage.tsx | 7 +- .../features/activity/ProvenanceDetail.tsx | 4 +- admin/src/features/activity/cells.tsx | 4 +- .../src/features/clients/ClientDetailPage.tsx | 22 +- admin/src/features/clients/ClientsPage.tsx | 2 +- .../features/clients/NetworkAssignments.tsx | 13 +- admin/src/features/clients/prefixEditor.ts | 9 +- .../configuration/ConfigLockIndicator.tsx | 4 +- .../features/configuration/LocalReadOnly.tsx | 10 +- .../configuration/ProtectionGroups.tsx | 17 +- .../configuration/ProtectionSources.tsx | 49 ++- .../src/features/configuration/RecordsTab.tsx | 2 +- .../configuration/SettingsDefinitions.tsx | 3 +- .../features/configuration/SettingsForm.tsx | 39 ++- .../features/configuration/UpstreamsTab.tsx | 7 +- admin/src/features/configuration/ZonesTab.tsx | 2 +- .../configuration/settingsSections.ts | 34 +- admin/src/features/configuration/styles.ts | 32 +- .../diagnostics/DiagnosticDetailPage.test.tsx | 3 +- .../diagnostics/DiagnosticDetailPage.tsx | 30 +- .../diagnostics/DiagnosticsPage.test.tsx | 2 +- .../features/diagnostics/DiagnosticsPage.tsx | 28 +- .../src/features/diagnostics/HealthStrip.tsx | 4 +- .../features/diagnostics/SeverityBadge.tsx | 4 +- admin/src/features/diagnostics/healthFacts.ts | 8 +- admin/src/features/overview/CacheCard.tsx | 126 ++++++++ .../features/overview/ClientChart.test.tsx | 105 +++++-- admin/src/features/overview/ClientChart.tsx | 120 +++---- admin/src/features/overview/Donut.test.tsx | 30 +- admin/src/features/overview/Donut.tsx | 124 ++++---- admin/src/features/overview/OverviewFrame.tsx | 231 +++++++++----- .../features/overview/OverviewPage.test.tsx | 189 ++++++++--- admin/src/features/overview/OverviewPage.tsx | 132 ++++---- admin/src/features/overview/StatTiles.tsx | 207 ++++++------ .../overview/TimeseriesChart.test.tsx | 277 +++++++++++------ .../src/features/overview/TimeseriesChart.tsx | 202 ++++++------ admin/src/features/overview/chartKit.test.tsx | 90 ++++++ admin/src/features/overview/chartKit.tsx | 294 +++++++++++++----- .../src/features/overview/clientScope.test.ts | 44 +++ admin/src/features/overview/clientScope.ts | 96 ++++++ .../features/overview/overviewWindow.test.tsx | 33 +- admin/src/features/overview/overviewWindow.ts | 18 +- .../features/overview/seriesColors.test.ts | 47 ++- admin/src/features/overview/seriesColors.ts | 94 +++--- admin/src/features/pause/PauseControl.tsx | 4 +- admin/src/lib/CoverageNotice.tsx | 4 +- admin/src/lib/InlineError.tsx | 3 +- admin/src/lib/api.ts | 5 +- admin/src/lib/format.test.ts | 90 +++++- admin/src/lib/format.ts | 89 +++++- admin/src/lib/queries.ts | 10 +- admin/src/routes.tsx | 44 +-- admin/src/shell/AppShell.test.tsx | 38 ++- admin/src/shell/AppShell.tsx | 111 ++++--- admin/src/shell/SidebarStatus.tsx | 160 ++++++++++ admin/src/shell/diagnosticsBadge.test.ts | 9 + admin/src/shell/diagnosticsBadge.ts | 6 +- admin/src/ui/Card.tsx | 79 +++++ admin/src/ui/ConfirmDialog.tsx | 4 +- admin/src/ui/Select.tsx | 27 +- admin/src/ui/styles.ts | 23 +- admin/src/ui/tokens.stylex.ts | 60 +++- build.zig.zon | 2 +- specs/milestone-39.md | 58 ++++ specs/ui-visual-redesign.md | 35 +++ src/storage/repositories/queries_repo.zig | 85 +++-- src/web/handlers/overview.zig | 90 +++++- src/web/openapi.yaml | 10 + src/web/server.zig | 39 ++- src/web/web_integration_test.zig | 88 ++++++ 77 files changed, 2869 insertions(+), 1163 deletions(-) create mode 100644 admin/src/features/overview/CacheCard.tsx create mode 100644 admin/src/features/overview/clientScope.test.ts create mode 100644 admin/src/features/overview/clientScope.ts create mode 100644 admin/src/shell/SidebarStatus.tsx create mode 100644 admin/src/ui/Card.tsx create mode 100644 specs/milestone-39.md create mode 100644 specs/ui-visual-redesign.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8288be8..a28e00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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. +## [0.0.16] - 2026-09-07 + +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. + +### Changed + +- **Overview reads like an analytics dashboard.** Four centred totals (queries, blocked, active clients, and the blocked share), a smooth area chart of total and blocked queries where hovering marks the point and shows its figures beside it, a stacked client-activity chart in eight distinct hues with everything else as one gray band, and a row of cards under them: the cache hit rate as a progress bar, the query types as a single-hue ring, and the upstream breakdown. +- **Overview can be scoped to one device.** A device selector sits beside the period selector; both are URL state, so `/overview?period=7d&client=192.0.2.30` is a link to exactly that view. The API takes the same `client` parameter on `GET /api/overview`, and the tile links carry the scope into Activity. +- **One formatting contract.** Counts are thousands-grouped, percentages always show two decimals (15.44%, never 15.4%), rates one decimal, and durations their two largest nonzero units ("6d 4h", "12m 5s", "6d 5s"). A test sweeps the sources for any number formatted outside the one module. +- **A quieter, more consistent chrome.** One 4px corner radius everywhere, cards with a prominent title and a one-line description, the sidebar grouped into Monitoring and System with a status block at its foot (protection, queries per minute on Overview, uptime), and a top bar in place of the sidebar on narrow screens. The active navigation item and the accent blue are darkened to clear WCAG AA on their washes. + ## [0.0.15] - 2026-08-31 The Activity page's history filters become a toolbar you can actually use. Filters apply as you set them, the client field stops asking you to remember IP addresses, and the whole admin picks up one coherent icon set. diff --git a/admin/src/auth/LoginPage.tsx b/admin/src/auth/LoginPage.tsx index 5f32232..8101fd9 100644 --- a/admin/src/auth/LoginPage.tsx +++ b/admin/src/auth/LoginPage.tsx @@ -5,6 +5,7 @@ import { ApiError } from "@/lib/api"; import { useAuth } from "@/auth/store"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; +import { formatDuration } from "@/lib/format"; const styles = stylex.create({ /** Login renders outside AppShell, so it paints the page ground itself. */ @@ -65,7 +66,7 @@ function errorMessage(error: unknown, remaining: number | null): string { if (error.status === 401) return "Incorrect password."; if (error.status === 429) { return remaining !== null && remaining > 0 - ? `Too many attempts. Try again in ${remaining}s.` + ? `Too many attempts. Try again in ${formatDuration(remaining)}.` : "Too many attempts. Try again shortly."; } if (error.status === 503) return "The server is starting or degraded. Try again shortly."; diff --git a/admin/src/features/activity/ActivityFilters.tsx b/admin/src/features/activity/ActivityFilters.tsx index e90759f..7ba7423 100644 --- a/admin/src/features/activity/ActivityFilters.tsx +++ b/admin/src/features/activity/ActivityFilters.tsx @@ -106,7 +106,7 @@ const styles = stylex.create({ segment: { cursor: "pointer", borderStyle: "none", - borderRadius: "0.25rem", + borderRadius: metrics.radius, paddingInline: "0.625rem", fontSize: "0.875rem", lineHeight: "1.25rem", @@ -139,7 +139,7 @@ const styles = stylex.create({ color: colors.textSecondary, }, popover: { - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, diff --git a/admin/src/features/activity/ActivityPage.tsx b/admin/src/features/activity/ActivityPage.tsx index ac6e6c8..1a5fdf7 100644 --- a/admin/src/features/activity/ActivityPage.tsx +++ b/admin/src/features/activity/ActivityPage.tsx @@ -13,7 +13,7 @@ import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import { Tab, TabList, TabPanel, Tabs } from "react-aria-components"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters"; import HistoryActivity from "./HistoryActivity"; import LiveActivity from "./LiveActivity"; @@ -40,7 +40,7 @@ const styles = stylex.create({ switch: { display: "flex", gap: "0.25rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, diff --git a/admin/src/features/activity/ClientFilter.tsx b/admin/src/features/activity/ClientFilter.tsx index dc9a7cb..15a1865 100644 --- a/admin/src/features/activity/ClientFilter.tsx +++ b/admin/src/features/activity/ClientFilter.tsx @@ -29,6 +29,7 @@ import { useEffect, useMemo, useRef } from "react"; import * as stylex from "@stylexjs/stylex"; import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components"; import { clientLabel, useClientNames } from "@/features/clients/clientNames"; +import { formatCount } from "@/lib/format"; import { styles as shared } from "@/ui/styles"; import { colors, metrics } from "@/ui/tokens.stylex"; import { MAX_CLIENTS } from "./search"; @@ -79,7 +80,7 @@ const styles = stylex.create({ popover: { maxHeight: "16rem", overflowY: "auto", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -220,7 +221,7 @@ export function joinClients(ips: readonly string[]): string | undefined { function triggerLabel(selected: readonly string[], options: readonly ClientOption[]): string { if (selected.length === 0) return "Clients"; if (selected.length === 1) return displayFor(selected[0] as string, options); - return `${selected.length} clients`; + return `${formatCount(selected.length)} clients`; } interface Props { @@ -373,7 +374,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) { ))} {selected.length > MAX_CHIPS && ( - +{selected.length - MAX_CHIPS} more + +{formatCount(selected.length - MAX_CHIPS)} more )} ); diff --git a/admin/src/features/activity/HistoryActivity.tsx b/admin/src/features/activity/HistoryActivity.tsx index 272d7f9..4218ea6 100644 --- a/admin/src/features/activity/HistoryActivity.tsx +++ b/admin/src/features/activity/HistoryActivity.tsx @@ -12,12 +12,13 @@ import * as stylex from "@stylexjs/stylex"; import * as api from "@/lib/api"; import CoverageNotice from "@/lib/CoverageNotice"; import InlineError from "@/lib/InlineError"; +import { formatCount } from "@/lib/format"; import { queriesInfiniteQuery } from "@/lib/queries"; import type { QueryRow } from "@/lib/types"; import { useClientNames } from "@/features/clients/clientNames"; import { summarizeRow } from "@/features/provenance/querySummary"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells"; import { queriesFilterOf, type ActivitySearch } from "./search"; @@ -38,7 +39,7 @@ const styles = stylex.create({ tableWrap: { marginTop: "1rem", overflowX: "auto", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -152,7 +153,7 @@ export default function HistoryActivity({ search, onClear }: Props) { ) : ( <> -
+
@@ -184,7 +185,7 @@ export default function HistoryActivity({ search, onClear }: Props) {

{/* The count is the one part of this line that moves as pages load. */} - Showing {rows.length}{" "} + Showing {formatCount(rows.length)}{" "} {rows.length === 1 ? "query" : "queries"} {hasMore ? "" : " — end of log"}

diff --git a/admin/src/features/activity/LiveActivity.tsx b/admin/src/features/activity/LiveActivity.tsx index b808955..e36819f 100644 --- a/admin/src/features/activity/LiveActivity.tsx +++ b/admin/src/features/activity/LiveActivity.tsx @@ -16,10 +16,11 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import { useClientNames } from "@/features/clients/clientNames"; +import { formatCount } from "@/lib/format"; import { summarizeEvent } from "@/features/provenance/querySummary"; import Dialog from "@/ui/Dialog"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells"; import ProvenanceDetail from "./ProvenanceDetail"; import RelatedActions from "./RelatedActions"; @@ -77,7 +78,7 @@ const styles = stylex.create({ display: "flex", alignItems: "center", gap: "0.75rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" }, @@ -106,7 +107,7 @@ const styles = stylex.create({ }, cappedBox: { marginTop: "1rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.dangerBorder, @@ -130,7 +131,7 @@ const styles = stylex.create({ tableWrap: { marginTop: "1rem", overflowX: "auto", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -287,7 +288,8 @@ export default function LiveActivity({ {live.frozen && (

- Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept). + Display frozen — new queries keep buffering ({formatCount(live.liveCount)} in buffer, newest{" "} + {formatCount(capacity)} kept).

)} @@ -295,7 +297,10 @@ export default function LiveActivity({
Stream resumed —{" "} - {live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}. + {live.missed === 0 + ? "no queries missed" + : `${formatCount(live.missed)} missed queries recovered`} + .
@@ -393,8 +398,8 @@ export default function LiveActivity({

- Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, - last {capacity} kept). + Showing {formatCount(live.rows.length)} {live.rows.length === 1 ? "query" : "queries"}{" "} + (newest first, last {formatCount(capacity)} kept).

)} diff --git a/admin/src/features/activity/PolicyTestPage.tsx b/admin/src/features/activity/PolicyTestPage.tsx index b70897e..598ea2d 100644 --- a/admin/src/features/activity/PolicyTestPage.tsx +++ b/admin/src/features/activity/PolicyTestPage.tsx @@ -8,7 +8,8 @@ import type { Group, LookupResult } from "@/lib/types"; import { defaultGroupId } from "@/lib/defaultGroup"; import Select from "@/ui/Select"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; +import { formatDuration } from "@/lib/format"; const DARK = "@media (prefers-color-scheme: dark)"; @@ -64,7 +65,7 @@ const styles = stylex.create({ }, card: { marginTop: "1.5rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -178,7 +179,7 @@ function errorMessage(error: unknown): string { } if (error.status === 429) { return error.retryAfter !== undefined - ? `Rate limited. Try again in ${error.retryAfter}s.` + ? `Rate limited. Try again in ${formatDuration(error.retryAfter)}.` : "Rate limited. Try again shortly."; } return error.message; diff --git a/admin/src/features/activity/ProvenanceDetail.tsx b/admin/src/features/activity/ProvenanceDetail.tsx index a9a50a6..4c3c9f6 100644 --- a/admin/src/features/activity/ProvenanceDetail.tsx +++ b/admin/src/features/activity/ProvenanceDetail.tsx @@ -22,7 +22,7 @@ import { } from "@/features/provenance/provenanceCopy"; import { qtypeName } from "@/features/provenance/qtype"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; const styles = stylex.create({ heading: { @@ -40,7 +40,7 @@ const styles = stylex.create({ record: { marginTop: "1rem", maxWidth: "48rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, diff --git a/admin/src/features/activity/cells.tsx b/admin/src/features/activity/cells.tsx index 91805bb..fe58b83 100644 --- a/admin/src/features/activity/cells.tsx +++ b/admin/src/features/activity/cells.tsx @@ -21,7 +21,7 @@ import { rcodeShortName } from "@/features/provenance/provenanceCopy"; import { qtypeName } from "@/features/provenance/qtype"; import type { QuerySummary } from "@/features/provenance/querySummary"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; const DARK = "@media (prefers-color-scheme: dark)"; @@ -93,7 +93,7 @@ const styles = stylex.create({ */ badge: { display: "inline-block", - borderRadius: "0.25rem", + borderRadius: metrics.radius, paddingInline: "0.375rem", paddingBlock: "0.125rem", fontSize: "0.75rem", diff --git a/admin/src/features/clients/ClientDetailPage.tsx b/admin/src/features/clients/ClientDetailPage.tsx index 6c219a0..8413370 100644 --- a/admin/src/features/clients/ClientDetailPage.tsx +++ b/admin/src/features/clients/ClientDetailPage.tsx @@ -7,6 +7,7 @@ import InlineError from "@/lib/InlineError"; import { formatTime } from "@/lib/format"; import { clientsQuery } from "@/lib/queries"; import { useAuthority } from "@/features/configuration/authority"; +import Card from "@/ui/Card"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; import { ClientDisplayName, provenanceOf } from "./clientIdentity"; @@ -43,12 +44,6 @@ const styles = stylex.create({ panel: { marginTop: "1rem", maxWidth: "48rem", - borderRadius: "0.25rem", - borderWidth: 1, - borderStyle: "solid", - borderColor: colors.border, - backgroundColor: colors.surfaceRaised, - padding: "1rem", }, facts: { display: "grid", @@ -73,9 +68,10 @@ const styles = stylex.create({ }, sectionHeading: { marginTop: "1.5rem", - fontSize: "1.125rem", - lineHeight: "1.75rem", - fontWeight: 600, + fontSize: "1.4rem", + lineHeight: 1.2, + fontWeight: 650, + letterSpacing: "-0.015em", }, prose: { marginTop: "0.5rem", @@ -181,7 +177,11 @@ export default function ClientDetailPage() {

{client.ip}

-
+
Name
@@ -197,7 +197,7 @@ export default function ClientDetailPage() {
Last seen
{formatTime(client.last_seen)}
-
+

Policy

diff --git a/admin/src/features/clients/ClientsPage.tsx b/admin/src/features/clients/ClientsPage.tsx index cd63dfb..e82f40b 100644 --- a/admin/src/features/clients/ClientsPage.tsx +++ b/admin/src/features/clients/ClientsPage.tsx @@ -185,7 +185,7 @@ export default function ClientsPage() { ) : rows.length === 0 ? (

No clients match this filter.

) : ( -
+
diff --git a/admin/src/features/clients/NetworkAssignments.tsx b/admin/src/features/clients/NetworkAssignments.tsx index 1aeb142..5c37624 100644 --- a/admin/src/features/clients/NetworkAssignments.tsx +++ b/admin/src/features/clients/NetworkAssignments.tsx @@ -4,6 +4,7 @@ import * as stylex from "@stylexjs/stylex"; import { clientPrefixesPutMutation } from "@/lib/queries"; import type { ClientPrefix, Group } from "@/lib/types"; import { defaultGroupId } from "@/lib/defaultGroup"; +import { formatCount } from "@/lib/format"; import { firstProblem, initPrefixEditor, @@ -17,7 +18,7 @@ import InlineError from "@/lib/InlineError"; import AuthorityGate from "@/features/configuration/AuthorityGate"; import Select from "@/ui/Select"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; interface Props { prefixes: ClientPrefix[]; @@ -77,7 +78,7 @@ const styles = stylex.create({ }, removeButton: { cursor: { default: "pointer", ":disabled": "not-allowed" }, - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.borderStrong, @@ -142,7 +143,7 @@ export default function NetworkAssignments({ prefixes, groups }: Props) { function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) { if (prefixes.length === 0) return

The file declares no network assignments.

; return ( -
+
@@ -210,7 +211,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
  • local_records +

    + Names this resolver answers by itself, without asking an upstream. +

    {records.length === 0 ? (

    The file declares no local records.

    ) : ( -
    +
  • @@ -52,10 +55,13 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) { Forward zones forward_zones +

    + Domains whose queries go to a resolver of their own instead of the upstream pool. +

    {zones.length === 0 ? (

    The file declares no forward zones.

    ) : ( -
    +
    diff --git a/admin/src/features/configuration/ProtectionGroups.tsx b/admin/src/features/configuration/ProtectionGroups.tsx index dbc04eb..5be4edd 100644 --- a/admin/src/features/configuration/ProtectionGroups.tsx +++ b/admin/src/features/configuration/ProtectionGroups.tsx @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup"; -import { formatTime } from "@/lib/format"; +import { formatCount, formatTime } from "@/lib/format"; import InlineError from "@/lib/InlineError"; import { blocklistsQuery, @@ -225,7 +225,7 @@ function ClientCountLink({ group }: { group: Group }) { {count === undefined ? "Clients in this group" - : `${count} client${count === 1 ? "" : "s"} in this group`} + : `${formatCount(count)} client${count === 1 ? "" : "s"} in this group`}

    ); @@ -236,6 +236,10 @@ function GroupDetailReadOnly({ group }: { group: Group }) {

    {group.name}

    +

    Settings

    +

    + The group's name and whether its clients get safe search. +

    group_sources +

    The blocklists that apply to clients in this group.

    {(sourceIds) => ( @@ -274,7 +279,7 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo const assigned = catalogue.filter((source) => sourceIds.includes(source.id)); if (assigned.length === 0) return

    This group is assigned no sources.

    ; return ( -
    +
    @@ -391,6 +396,7 @@ function GroupDetailEditable({ group }: { group: Group }) {

    Assigned sources

    +

    The blocklists that apply to clients in this group.

    {(catalogue) => } @@ -428,6 +434,9 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) { Rules {!editable && rules} +

    + This group's own block and allow patterns, applied before any list. +

    {(all) => { const scoped = all.filter((rule) => rule.group_id === group.id); @@ -457,7 +466,7 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) { return ( <> -
    +
    diff --git a/admin/src/features/configuration/ProtectionSources.tsx b/admin/src/features/configuration/ProtectionSources.tsx index 83c28e0..fbf3876 100644 --- a/admin/src/features/configuration/ProtectionSources.tsx +++ b/admin/src/features/configuration/ProtectionSources.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as stylex from "@stylexjs/stylex"; -import { formatTime } from "@/lib/format"; +import { formatCount, formatTime } from "@/lib/format"; import InlineError from "@/lib/InlineError"; import { blocklistCreateMutation, @@ -13,7 +13,7 @@ import { import type { Blocklist, BlocklistInput } from "@/lib/types"; import ConfirmDialog from "@/ui/ConfirmDialog"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import Switch from "@/ui/Switch"; import AuthorityGate from "./AuthorityGate"; import BlocklistForm from "./BlocklistForm"; @@ -34,7 +34,7 @@ const styles = stylex.create({ }, badge: { marginLeft: "0.5rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, backgroundColor: colors.border, paddingInline: "0.375rem", paddingBlock: "0.125rem", @@ -131,10 +131,13 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) { Blocklist sources blocklist_sources +

    + The lists nxdns downloads, and what each one contributed at its last refresh. +

    {blocklists.length === 0 ? (

    The file declares no blocklist sources.

    ) : ( -
    +
    @@ -158,12 +161,20 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) { - - - - + + + +
    {b.url} {String(b.enabled)}{b.domain_count}{b.wildcard_count}{b.exception_count}{b.skipped_regex_count} - {b.skipped_unsupported_count} + {formatCount(b.domain_count)} + + {formatCount(b.wildcard_count)} + + {formatCount(b.exception_count)} + + {formatCount(b.skipped_regex_count)} + + {formatCount(b.skipped_unsupported_count)} {b.last_updated === null ? "never" : formatTime(b.last_updated)} @@ -219,7 +230,7 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) { {blocklists.length === 0 ? (

    No blocklist sources yet. Add one below.

    ) : ( -
    +
    @@ -257,12 +268,20 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) { onChange={() => toggleEnabled(b)} /> - - - - + + + +
    {b.domain_count}{b.wildcard_count}{b.exception_count}{b.skipped_regex_count} - {b.skipped_unsupported_count} + {formatCount(b.domain_count)} + + {formatCount(b.wildcard_count)} + + {formatCount(b.exception_count)} + + {formatCount(b.skipped_regex_count)} + + {formatCount(b.skipped_unsupported_count)} {b.last_updated === null ? "never" : formatTime(b.last_updated)} diff --git a/admin/src/features/configuration/RecordsTab.tsx b/admin/src/features/configuration/RecordsTab.tsx index b816749..f24a3f3 100644 --- a/admin/src/features/configuration/RecordsTab.tsx +++ b/admin/src/features/configuration/RecordsTab.tsx @@ -223,7 +223,7 @@ export default function RecordsTab() { )} {(records) => ( -
    +
    diff --git a/admin/src/features/configuration/SettingsDefinitions.tsx b/admin/src/features/configuration/SettingsDefinitions.tsx index eeece98..f8cb5b8 100644 --- a/admin/src/features/configuration/SettingsDefinitions.tsx +++ b/admin/src/features/configuration/SettingsDefinitions.tsx @@ -37,7 +37,7 @@ function renderValue(value: unknown) { export default function SettingsDefinitions({ settings }: { settings: Settings }) { return ( <> - {SECTIONS.map(({ section, title, fields }) => { + {SECTIONS.map(({ section, title, description, fields }) => { const values = sectionValues(settings, section); const items: Definition[] = (fields as readonly AnyFieldDef[]).map((def) => ({ label: humanize(def.key), @@ -56,6 +56,7 @@ export default function SettingsDefinitions({ settings }: { settings: Settings } return (

    {title}

    +

    {description}

    diff --git a/admin/src/features/configuration/SettingsForm.tsx b/admin/src/features/configuration/SettingsForm.tsx index ff6e8af..a1ebca1 100644 --- a/admin/src/features/configuration/SettingsForm.tsx +++ b/admin/src/features/configuration/SettingsForm.tsx @@ -5,9 +5,10 @@ import InlineError from "@/lib/InlineError"; import { settingsPutMutation } from "@/lib/queries"; import { buildSettingsPatch } from "@/lib/settingsDiff"; import type { Settings, SettingsEnvelope } from "@/lib/types"; +import { cardStyles } from "@/ui/Card"; import Select from "@/ui/Select"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import { SECTIONS, sectionValues, type AnyFieldDef } from "./settingsSections"; import { styles as config } from "./styles"; @@ -30,18 +31,25 @@ const styles = stylex.create({ margin: 0, padding: 0, }, + /** A `fieldset` shrinks to its content by default, which would undo the card's own `minWidth: 0`. */ section: { - borderRadius: "0.25rem", - borderWidth: 1, - borderStyle: "solid", - borderColor: colors.border, - padding: "1rem", + minInlineSize: 0, }, + /** + * A `legend` is otherwise drawn through the fieldset's top border, which cuts + * the hairline and lifts the title away from its description. A floated + * legend is not the fieldset's rendered legend (HTML rendering, "The fieldset + * and legend elements"), so it lays out as ordinary content inside the + * padding; the full width, and the `clear` on the description, keep the two + * stacked. + */ legend: { - paddingInline: "0.25rem", - fontSize: "0.875rem", - lineHeight: "1.25rem", - fontWeight: 600, + float: "left", + width: "100%", + padding: 0, + }, + description: { + clear: "both", }, /** One column on a phone, two from `sm`. */ fieldGrid: { @@ -71,7 +79,7 @@ const styles = stylex.create({ gap: "0.25rem", }, fieldInput: { - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.borderStrong, @@ -105,7 +113,7 @@ const styles = stylex.create({ save: { cursor: { default: "pointer", ":disabled": "not-allowed" }, borderStyle: "none", - borderRadius: "0.25rem", + borderRadius: metrics.radius, paddingInline: "1rem", paddingBlock: "0.375rem", fontSize: "0.875rem", @@ -261,9 +269,10 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope return (
    - {SECTIONS.map(({ section, title, fields }) => ( -
    - {title} + {SECTIONS.map(({ section, title, description, fields }) => ( +
    + {title} +

    {description}

    {(fields as readonly AnyFieldDef[]).map((def) => ( upstreams +

    + The resolvers nxdns forwards to when neither a local record nor the cache has the answer. +

    {upstreams.length === 0 ? (

    The file declares no upstreams.

    ) : ( -
    +
    @@ -143,7 +146,7 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) { {upstreams.length === 0 ? (

    No upstreams yet. Add one below.

    ) : ( -
    +
    diff --git a/admin/src/features/configuration/ZonesTab.tsx b/admin/src/features/configuration/ZonesTab.tsx index 515fbfe..38c0ce9 100644 --- a/admin/src/features/configuration/ZonesTab.tsx +++ b/admin/src/features/configuration/ZonesTab.tsx @@ -193,7 +193,7 @@ export default function ZonesTab() { )} {(zones) => ( -
    +
    diff --git a/admin/src/features/configuration/settingsSections.ts b/admin/src/features/configuration/settingsSections.ts index fe86a47..bc4498d 100644 --- a/admin/src/features/configuration/settingsSections.ts +++ b/admin/src/features/configuration/settingsSections.ts @@ -14,6 +14,8 @@ export interface FieldDef { export interface SectionDef { section: S; title: string; + /** The card head's one line: what the section governs, in the reader's words. */ + description: string; fields: readonly FieldDef[]; } @@ -47,6 +49,8 @@ export const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "upstream", title: "Upstream", + description: + "How long nxdns waits on the upstream pool, per attempt and in total, and on a forward zone's resolver per read.", fields: [ { key: "attempt_timeout_ms", kind: "number" }, { key: "read_timeout_ms", kind: "number" }, @@ -56,6 +60,8 @@ export const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "dns", title: "DNS", + description: + "The addresses and port the resolver listens on, and how many queries one client may send within the rate window.", fields: [ { key: "bind_ipv4", kind: "text" }, { key: "bind_ipv6", kind: "text" }, @@ -67,6 +73,7 @@ export const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "blocking", title: "Blocking", + description: "What a blocked query is answered with, and for how long clients may keep that answer.", fields: [ { key: "response", kind: ["zero", "nxdomain"] }, { key: "ttl", kind: "number" }, @@ -75,6 +82,7 @@ export const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "cache", title: "Cache", + description: "How many answers are kept, and how long a negative answer stays valid.", fields: [ { key: "size", kind: "number" }, { key: "negative_ttl_max", kind: "number" }, @@ -83,6 +91,7 @@ export const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "web", title: "Web", + description: "Where this admin interface listens, how long a login lasts, and its request limits.", fields: [ { key: "enabled", kind: "boolean" }, { key: "bind", kind: "text" }, @@ -94,12 +103,29 @@ export const SECTIONS: readonly AnySectionDef[] = [ { key: "trusted_proxies", kind: "text" }, ], }), - defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }), - defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }), - defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }), + defineSection({ + section: "doh_server", + title: "DoH Server", + description: "DNS over HTTPS for clients that speak it: the listener and its certificate.", + fields: TLS_FIELDS, + }), + defineSection({ + section: "dot_server", + title: "DoT Server", + description: "DNS over TLS for clients that speak it: the listener and its certificate.", + fields: TLS_FIELDS, + }), + defineSection({ + section: "edns", + title: "EDNS", + description: "Whether the client's subnet is passed on to upstreams or stripped from the query.", + fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }], + }), defineSection({ section: "logging", title: "Logging", + description: + "What the process log records and where it goes; how query history is buffered, flushed and kept, and which of its fields are hidden.", fields: [ { key: "level", kind: ["error", "warn", "info", "debug"] }, { key: "retention_days", kind: "number" }, @@ -116,6 +142,7 @@ export const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "disk", title: "Disk", + description: "The free space below which nxdns warns, and below which it stops writing history.", fields: [ { key: "min_free_mb", kind: "number" }, { key: "warn_free_mb", kind: "number" }, @@ -124,6 +151,7 @@ export const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "blocklist_update", title: "Blocklist Update", + description: "Whether the blocklists are fetched again on their own, and how often.", fields: [ { key: "enabled", kind: "boolean" }, { key: "interval_hours", kind: "number" }, diff --git a/admin/src/features/configuration/styles.ts b/admin/src/features/configuration/styles.ts index 572d2f5..aff53e4 100644 --- a/admin/src/features/configuration/styles.ts +++ b/admin/src/features/configuration/styles.ts @@ -5,7 +5,7 @@ */ import * as stylex from "@stylexjs/stylex"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; export const styles = stylex.create({ heading: { @@ -20,18 +20,32 @@ export const styles = stylex.create({ lineHeight: "1.25rem", color: colors.textMuted, }, + /** The card chrome (`ui/Card.tsx`), on panels whose heading carries a key or a control the Card head cannot. */ panel: { marginTop: "1rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, - padding: "1rem", + backgroundColor: colors.surfaceRaised, + padding: metrics.cardPadding, }, panelHeading: { - fontSize: "1.125rem", - lineHeight: "1.75rem", - fontWeight: 500, + margin: 0, + fontSize: "1.4rem", + lineHeight: 1.2, + fontWeight: 650, + letterSpacing: "-0.015em", + }, + /** The card head's one-line description (`ui/Card.tsx`), under a panel heading. */ + panelDescription: { + margin: 0, + marginTop: "0.25rem", + marginBottom: "1.25rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textMuted, + textWrap: "pretty", }, /** The collection's own key in the configuration file, beside its heading. */ panelKey: { @@ -50,7 +64,7 @@ export const styles = stylex.create({ /** The file-mode page note: where edits happen, and what applies them. */ fileNote: { marginTop: "1rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -90,7 +104,7 @@ export const styles = stylex.create({ }, masterLink: { display: "block", - borderRadius: "0.25rem", + borderRadius: metrics.radius, paddingInline: "0.75rem", paddingBlock: "0.375rem", fontSize: "0.875rem", @@ -119,7 +133,7 @@ export const styles = stylex.create({ /** The authority error state: no forms, no definition list, one way forward. */ blocked: { marginTop: "1rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.warnBorder, diff --git a/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx b/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx index d0e1053..35fac64 100644 --- a/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx +++ b/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx @@ -93,7 +93,8 @@ test("an open episode shows its facts, its copy and the error the server sent", await screen.findByRole("heading", { name: "Blocklist source failed to update" }); expect(screen.getByText("Warning")).toBeTruthy(); expect(screen.getByText("StevenBlack")).toBeTruthy(); - expect(screen.getByText("Active for 2h")).toBeTruthy(); + // The seconds since the fixture was anchored are the render's to add. + expect(screen.getByText(/^Active for 2h(?: \d+s)?$/)).toBeTruthy(); expect(screen.getByText("Not yet — still failing")).toBeTruthy(); expect(screen.getByText("4")).toBeTruthy(); expect(screen.getByText("blocklist.refresh")).toBeTruthy(); diff --git a/admin/src/features/diagnostics/DiagnosticDetailPage.tsx b/admin/src/features/diagnostics/DiagnosticDetailPage.tsx index c564257..ea6829b 100644 --- a/admin/src/features/diagnostics/DiagnosticDetailPage.tsx +++ b/admin/src/features/diagnostics/DiagnosticDetailPage.tsx @@ -4,11 +4,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useParams } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import InlineError from "@/lib/InlineError"; -import { formatDuration, formatTime } from "@/lib/format"; +import { formatCount, formatDuration, formatTime } from "@/lib/format"; import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries"; import ConfirmDialog from "@/ui/ConfirmDialog"; +import Card from "@/ui/Card"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import SeverityBadge from "./SeverityBadge"; import { componentLabel, copyFor } from "./eventCopy"; @@ -49,12 +50,6 @@ const styles = stylex.create({ panel: { marginTop: "1rem", maxWidth: "48rem", - borderRadius: "0.25rem", - borderWidth: 1, - borderStyle: "solid", - borderColor: colors.border, - backgroundColor: colors.surfaceRaised, - padding: "1rem", }, facts: { display: "grid", @@ -75,9 +70,10 @@ const styles = stylex.create({ }, sectionHeading: { marginTop: "1.5rem", - fontSize: "1.125rem", - lineHeight: "1.75rem", - fontWeight: 600, + fontSize: "1.4rem", + lineHeight: 1.2, + fontWeight: 650, + letterSpacing: "-0.015em", }, prose: { marginTop: "0.5rem", @@ -89,7 +85,7 @@ const styles = stylex.create({ marginTop: "0.5rem", maxWidth: "48rem", overflowX: "auto", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -185,7 +181,11 @@ export default function DiagnosticDetailPage() {

    {data.subject}

    -
    +
    State
    @@ -198,7 +198,7 @@ export default function DiagnosticDetailPage() {
    Last seen
    {formatTime(data.last_seen)}
    Occurrences
    -
    {data.occurrences}
    +
    {formatCount(data.occurrences)}
    Resolved
    {data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)} @@ -208,7 +208,7 @@ export default function DiagnosticDetailPage() {
    Code
    {data.code}
    -
    +

    Impact

    {copy.impact}

    diff --git a/admin/src/features/diagnostics/DiagnosticsPage.test.tsx b/admin/src/features/diagnostics/DiagnosticsPage.test.tsx index c6ce2a0..cd378f4 100644 --- a/admin/src/features/diagnostics/DiagnosticsPage.test.tsx +++ b/admin/src/features/diagnostics/DiagnosticsPage.test.tsx @@ -153,7 +153,7 @@ test("active episodes come first, each with its title, subject, age and count", const active = screen.getByText("Blocklist source failed to update").closest("li")!; expect(within(active).getByText("Warning")).toBeTruthy(); expect(within(active).getByText("StevenBlack")).toBeTruthy(); - expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy(); + expect(within(active).getByText(/Active for 1h(?: \d+s)? · 3 occurrences/)).toBeTruthy(); const failing = screen.getByText("Upstream failing").closest("li")!; expect(within(failing).getByText("Error")).toBeTruthy(); diff --git a/admin/src/features/diagnostics/DiagnosticsPage.tsx b/admin/src/features/diagnostics/DiagnosticsPage.tsx index 2b13cc9..0117c6d 100644 --- a/admin/src/features/diagnostics/DiagnosticsPage.tsx +++ b/admin/src/features/diagnostics/DiagnosticsPage.tsx @@ -10,13 +10,13 @@ import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import * as api from "@/lib/api"; import InlineError from "@/lib/InlineError"; -import { formatDuration, formatTime } from "@/lib/format"; +import { formatCount, formatDuration, formatTime } from "@/lib/format"; import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries"; import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types"; import ConfirmDialog from "@/ui/ConfirmDialog"; import Select from "@/ui/Select"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import HealthStrip from "./HealthStrip"; import SeverityBadge from "./SeverityBadge"; import { diagnosticsFilterOf } from "./filter"; @@ -67,9 +67,10 @@ const styles = stylex.create({ }, sectionHeading: { marginTop: "1.5rem", - fontSize: "1.125rem", - lineHeight: "1.75rem", - fontWeight: 600, + fontSize: "1.4rem", + lineHeight: 1.2, + fontWeight: 650, + letterSpacing: "-0.015em", }, sectionHeadingRow: { display: "flex", @@ -116,7 +117,7 @@ const styles = stylex.create({ padding: 0, }, card: { - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -149,7 +150,7 @@ const styles = stylex.create({ }, rangeNotice: { marginTop: "0.75rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -163,7 +164,7 @@ const styles = stylex.create({ tableWrap: { marginTop: "0.75rem", overflowX: "auto", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -235,7 +236,7 @@ function errorMessage(error: unknown): string { } function occurrenceText(count: number): string { - return `${count} ${count === 1 ? "occurrence" : "occurrences"}`; + return `${formatCount(count)} ${count === 1 ? "occurrence" : "occurrences"}`; } function rowsOf(section: Section): DiagnosticEvent[] { @@ -356,7 +357,7 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
    - +
    {event.resolved_at === null ? "—" : formatTime(event.resolved_at)} {event.occurrences}{formatCount(event.occurrences)}