Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94496b303a
|
||
|
|
209c1c77a9
|
+1
-1
@@ -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.
|
||||
|
||||
## [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.
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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. */
|
||||
@@ -66,7 +65,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 ${formatDuration(remaining)}.`
|
||||
? `Too many attempts. Try again in ${remaining}s.`
|
||||
: "Too many attempts. Try again shortly.";
|
||||
}
|
||||
if (error.status === 503) return "The server is starting or degraded. Try again shortly.";
|
||||
|
||||
@@ -29,7 +29,6 @@ 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";
|
||||
@@ -221,7 +220,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 `${formatCount(selected.length)} clients`;
|
||||
return `${selected.length} clients`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -374,7 +373,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ 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";
|
||||
@@ -153,7 +152,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
@@ -185,7 +184,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
{/* The count is the one part of this line that moves as pages load. */}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{formatCount(rows.length)}</span>{" "}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
|
||||
{rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
|
||||
@@ -16,7 +16,6 @@ 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";
|
||||
@@ -288,8 +287,7 @@ export default function LiveActivity({
|
||||
|
||||
{live.frozen && (
|
||||
<p {...stylex.props(styles.note)} role="status">
|
||||
Display frozen — new queries keep buffering ({formatCount(live.liveCount)} in buffer, newest{" "}
|
||||
{formatCount(capacity)} kept).
|
||||
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -297,10 +295,7 @@ export default function LiveActivity({
|
||||
<div role="status" {...stylex.props(styles.resumed)}>
|
||||
<span>
|
||||
Stream resumed —{" "}
|
||||
{live.missed === 0
|
||||
? "no queries missed"
|
||||
: `${formatCount(live.missed)} missed queries recovered`}
|
||||
.
|
||||
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
|
||||
</span>
|
||||
<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)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
@@ -398,8 +393,8 @@ export default function LiveActivity({
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footnote)}>
|
||||
Showing {formatCount(live.rows.length)} {live.rows.length === 1 ? "query" : "queries"}{" "}
|
||||
(newest first, last {formatCount(capacity)} kept).
|
||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
|
||||
last {capacity} kept).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
@@ -179,7 +178,7 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
if (error.status === 429) {
|
||||
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.";
|
||||
}
|
||||
return error.message;
|
||||
|
||||
@@ -7,9 +7,8 @@ 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 { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { ClientDisplayName, provenanceOf } from "./clientIdentity";
|
||||
|
||||
/** The Activity window this page offers: the last day, in seconds. */
|
||||
@@ -44,6 +43,12 @@ const styles = stylex.create({
|
||||
panel: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: metrics.cardPadding,
|
||||
},
|
||||
facts: {
|
||||
display: "grid",
|
||||
@@ -177,11 +182,7 @@ export default function ClientDetailPage() {
|
||||
</h1>
|
||||
<p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p>
|
||||
|
||||
<Card
|
||||
title="Details"
|
||||
description="The name nxdns shows for this device, where that name came from, and when the device was last seen."
|
||||
style={styles.panel}
|
||||
>
|
||||
<div {...stylex.props(styles.panel)}>
|
||||
<dl {...stylex.props(styles.facts)}>
|
||||
<dt {...stylex.props(styles.term)}>Name</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
@@ -197,7 +198,7 @@ export default function ClientDetailPage() {
|
||||
<dt {...stylex.props(styles.term)}>Last seen</dt>
|
||||
<dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>Policy</h2>
|
||||
<p {...stylex.props(styles.prose)}>
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function ClientsPage() {
|
||||
) : rows.length === 0 ? (
|
||||
<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)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,
|
||||
@@ -143,7 +142,7 @@ export default function NetworkAssignments({ prefixes, groups }: Props) {
|
||||
function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
||||
if (prefixes.length === 0) return <p {...stylex.props(styles.empty)}>The file declares no network assignments.</p>;
|
||||
return (
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -211,7 +210,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
<li key={index} {...stylex.props(styles.row)}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Range ${formatCount(index + 1)}`}
|
||||
aria-label={`Range ${index + 1}`}
|
||||
aria-invalid={invalid(index, "prefix")}
|
||||
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
|
||||
placeholder="192.168.1.0/24"
|
||||
@@ -222,7 +221,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
|
||||
/>
|
||||
<Select
|
||||
aria-label={`Group for range ${formatCount(index + 1)}`}
|
||||
aria-label={`Group for range ${index + 1}`}
|
||||
variant="inline"
|
||||
value={String(row.group_id)}
|
||||
onChange={(value) =>
|
||||
@@ -233,7 +232,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={`Priority for range ${formatCount(index + 1)}`}
|
||||
aria-label={`Priority for range ${index + 1}`}
|
||||
aria-invalid={invalid(index, "priority")}
|
||||
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
|
||||
placeholder="100"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { formatCount } from "@/lib/format";
|
||||
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
|
||||
|
||||
export interface PrefixRow {
|
||||
@@ -66,14 +65,10 @@ export interface PrefixProblem {
|
||||
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "")
|
||||
return { index, field: "prefix", message: `Row ${formatCount(index + 1)}: prefix is required.` };
|
||||
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "" && !/^\d+$/.test(priority))
|
||||
return {
|
||||
index,
|
||||
field: "priority",
|
||||
message: `Row ${formatCount(index + 1)}: priority must be a whole number.`,
|
||||
};
|
||||
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,13 +15,10 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
|
||||
Local records
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>local_records</code>
|
||||
</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
Names this resolver answers by itself, without asking an upstream.
|
||||
</p>
|
||||
{records.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>The file declares no local records.</p>
|
||||
) : (
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -55,13 +52,10 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
|
||||
Forward zones
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>forward_zones</code>
|
||||
</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
Domains whose queries go to a resolver of their own instead of the upstream pool.
|
||||
</p>
|
||||
{zones.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>The file declares no forward zones.</p>
|
||||
) : (
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import {
|
||||
blocklistsQuery,
|
||||
@@ -225,7 +225,7 @@ function ClientCountLink({ group }: { group: Group }) {
|
||||
<Link to="/clients" search={{ group: group.id }} {...stylex.props(shared.focusRing)}>
|
||||
{count === undefined
|
||||
? "Clients in this group"
|
||||
: `${formatCount(count)} client${count === 1 ? "" : "s"} in this group`}
|
||||
: `${count} client${count === 1 ? "" : "s"} in this group`}
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
@@ -236,10 +236,6 @@ function GroupDetailReadOnly({ group }: { group: Group }) {
|
||||
<div>
|
||||
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
|
||||
<section {...stylex.props(config.panel)}>
|
||||
<h3 {...stylex.props(config.panelHeading)}>Settings</h3>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
The group's name and whether its clients get safe search.
|
||||
</p>
|
||||
<DefinitionList
|
||||
items={[
|
||||
{ label: "Name", zonKey: "groups[].name", value: group.name },
|
||||
@@ -263,7 +259,6 @@ function GroupSourcesReadOnly({ group }: { group: Group }) {
|
||||
Assigned sources
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>group_sources</code>
|
||||
</h3>
|
||||
<p {...stylex.props(config.panelDescription)}>The blocklists that apply to clients in this group.</p>
|
||||
<QueryPanel query={assigned}>
|
||||
{(sourceIds) => (
|
||||
<QueryPanel query={blocklists}>
|
||||
@@ -279,7 +274,7 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo
|
||||
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
|
||||
if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
|
||||
return (
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -396,7 +391,6 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
||||
|
||||
<section {...stylex.props(config.panel)}>
|
||||
<h3 {...stylex.props(config.panelHeading)}>Assigned sources</h3>
|
||||
<p {...stylex.props(config.panelDescription)}>The blocklists that apply to clients in this group.</p>
|
||||
<QueryPanel query={blocklists}>
|
||||
{(catalogue) => <GroupSourcesEditor groupId={group.id} blocklists={catalogue} />}
|
||||
</QueryPanel>
|
||||
@@ -434,9 +428,6 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
|
||||
Rules
|
||||
{!editable && <code {...stylex.props(shared.mono, config.panelKey)}>rules</code>}
|
||||
</h3>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
This group's own block and allow patterns, applied before any list.
|
||||
</p>
|
||||
<QueryPanel query={rules}>
|
||||
{(all) => {
|
||||
const scoped = all.filter((rule) => rule.group_id === group.id);
|
||||
@@ -466,7 +457,7 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import {
|
||||
blocklistCreateMutation,
|
||||
@@ -131,13 +131,10 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
Blocklist sources
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>blocklist_sources</code>
|
||||
</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
The lists nxdns downloads, and what each one contributed at its last refresh.
|
||||
</p>
|
||||
{blocklists.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>The file declares no blocklist sources.</p>
|
||||
) : (
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -161,20 +158,12 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</td>
|
||||
<td {...stylex.props(shared.td)}>{String(b.enabled)}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.domain_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.wildcard_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.exception_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.skipped_regex_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.skipped_unsupported_count)}
|
||||
{b.skipped_unsupported_count}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
@@ -230,7 +219,7 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
{blocklists.length === 0 ? (
|
||||
<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)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -268,20 +257,12 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
onChange={() => toggleEnabled(b)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.domain_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.wildcard_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.exception_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.skipped_regex_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.skipped_unsupported_count)}
|
||||
{b.skipped_unsupported_count}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function RecordsTab() {
|
||||
)}
|
||||
<QueryPanel query={query}>
|
||||
{(records) => (
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
|
||||
@@ -37,7 +37,7 @@ function renderValue(value: unknown) {
|
||||
export default function SettingsDefinitions({ settings }: { settings: Settings }) {
|
||||
return (
|
||||
<>
|
||||
{SECTIONS.map(({ section, title, description, fields }) => {
|
||||
{SECTIONS.map(({ section, title, fields }) => {
|
||||
const values = sectionValues(settings, section);
|
||||
const items: Definition[] = (fields as readonly AnyFieldDef[]).map((def) => ({
|
||||
label: humanize(def.key),
|
||||
@@ -56,7 +56,6 @@ export default function SettingsDefinitions({ settings }: { settings: Settings }
|
||||
return (
|
||||
<section key={section} {...stylex.props(config.panel)}>
|
||||
<h2 {...stylex.props(config.panelHeading)}>{title}</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>{description}</p>
|
||||
<div {...stylex.props(config.note)}>
|
||||
<DefinitionList items={items} />
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ 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, metrics } from "@/ui/tokens.stylex";
|
||||
@@ -31,25 +30,18 @@ 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: {
|
||||
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: {
|
||||
float: "left",
|
||||
width: "100%",
|
||||
padding: 0,
|
||||
},
|
||||
description: {
|
||||
clear: "both",
|
||||
paddingInline: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
/** One column on a phone, two from `sm`. */
|
||||
fieldGrid: {
|
||||
@@ -269,10 +261,9 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
return (
|
||||
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||
<fieldset disabled={mutation.isPending} {...stylex.props(styles.sections)}>
|
||||
{SECTIONS.map(({ section, title, description, fields }) => (
|
||||
<fieldset key={section} {...stylex.props(cardStyles.card, styles.section)}>
|
||||
<legend {...stylex.props(cardStyles.title, styles.legend)}>{title}</legend>
|
||||
<p {...stylex.props(config.panelDescription, styles.description)}>{description}</p>
|
||||
{SECTIONS.map(({ section, title, fields }) => (
|
||||
<fieldset key={section} {...stylex.props(styles.section)}>
|
||||
<legend {...stylex.props(styles.legend)}>{title}</legend>
|
||||
<div {...stylex.props(styles.fieldGrid)}>
|
||||
{(fields as readonly AnyFieldDef[]).map((def) => (
|
||||
<FieldRow
|
||||
|
||||
@@ -67,13 +67,10 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
|
||||
Upstream pool
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>upstreams</code>
|
||||
</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
The resolvers nxdns forwards to when neither a local record nor the cache has the answer.
|
||||
</p>
|
||||
{upstreams.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>The file declares no upstreams.</p>
|
||||
) : (
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -146,7 +143,7 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
||||
{upstreams.length === 0 ? (
|
||||
<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)}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function ZonesTab() {
|
||||
)}
|
||||
<QueryPanel query={query}>
|
||||
{(zones) => (
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
|
||||
@@ -14,8 +14,6 @@ export interface FieldDef<S extends keyof Settings> {
|
||||
export interface SectionDef<S extends keyof Settings> {
|
||||
section: S;
|
||||
title: string;
|
||||
/** The card head's one line: what the section governs, in the reader's words. */
|
||||
description: string;
|
||||
fields: readonly FieldDef<S>[];
|
||||
}
|
||||
|
||||
@@ -49,8 +47,6 @@ 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" },
|
||||
@@ -60,8 +56,6 @@ 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" },
|
||||
@@ -73,7 +67,6 @@ 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" },
|
||||
@@ -82,7 +75,6 @@ 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" },
|
||||
@@ -91,7 +83,6 @@ 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" },
|
||||
@@ -103,29 +94,12 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
{ key: "trusted_proxies", kind: "text" },
|
||||
],
|
||||
}),
|
||||
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: "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: "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" },
|
||||
@@ -142,7 +116,6 @@ 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" },
|
||||
@@ -151,7 +124,6 @@ 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" },
|
||||
|
||||
@@ -32,21 +32,12 @@ export const styles = stylex.create({
|
||||
},
|
||||
panelHeading: {
|
||||
margin: 0,
|
||||
marginBottom: "1rem",
|
||||
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: {
|
||||
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" });
|
||||
expect(screen.getByText("Warning")).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(?: \d+s)?$/)).toBeTruthy();
|
||||
expect(screen.getByText("Active for 2h")).toBeTruthy();
|
||||
expect(screen.getByText("Not yet — still failing")).toBeTruthy();
|
||||
expect(screen.getByText("4")).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 * as stylex from "@stylexjs/stylex";
|
||||
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 ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Card from "@/ui/Card";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import SeverityBadge from "./SeverityBadge";
|
||||
@@ -50,6 +49,12 @@ const styles = stylex.create({
|
||||
panel: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: metrics.cardPadding,
|
||||
},
|
||||
facts: {
|
||||
display: "grid",
|
||||
@@ -181,11 +186,7 @@ export default function DiagnosticDetailPage() {
|
||||
<p {...stylex.props(styles.subject)}>{data.subject}</p>
|
||||
<InlineError error={purge.error} />
|
||||
|
||||
<Card
|
||||
title="Details"
|
||||
description="How long this has been failing, how often it recurred, and whether it has cleared."
|
||||
style={styles.panel}
|
||||
>
|
||||
<div {...stylex.props(styles.panel)}>
|
||||
<dl {...stylex.props(styles.facts)}>
|
||||
<dt {...stylex.props(styles.term)}>State</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
@@ -198,7 +199,7 @@ export default function DiagnosticDetailPage() {
|
||||
<dt {...stylex.props(styles.term)}>Last seen</dt>
|
||||
<dd {...stylex.props(styles.value)}>{formatTime(data.last_seen)}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Occurrences</dt>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>{formatCount(data.occurrences)}</dd>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>{data.occurrences}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Resolved</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
{data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)}
|
||||
@@ -208,7 +209,7 @@ export default function DiagnosticDetailPage() {
|
||||
<dt {...stylex.props(styles.term)}>Code</dt>
|
||||
<dd {...stylex.props(styles.value, shared.mono)}>{data.code}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>Impact</h2>
|
||||
<p {...stylex.props(styles.prose)}>{copy.impact}</p>
|
||||
|
||||
@@ -153,7 +153,7 @@ test("active episodes come first, each with its title, subject, age and count",
|
||||
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
|
||||
expect(within(active).getByText("Warning")).toBeTruthy();
|
||||
expect(within(active).getByText("StevenBlack")).toBeTruthy();
|
||||
expect(within(active).getByText(/Active for 1h(?: \d+s)? · 3 occurrences/)).toBeTruthy();
|
||||
expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy();
|
||||
|
||||
const failing = screen.getByText("Upstream failing").closest("li")!;
|
||||
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 api from "@/lib/api";
|
||||
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 type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
@@ -236,7 +236,7 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
function occurrenceText(count: number): string {
|
||||
return `${formatCount(count)} ${count === 1 ? "occurrence" : "occurrences"}`;
|
||||
return `${count} ${count === 1 ? "occurrence" : "occurrences"}`;
|
||||
}
|
||||
|
||||
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)}>
|
||||
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
||||
</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)}>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
@@ -523,9 +523,8 @@ export default function DiagnosticsPage() {
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footer, styles.note)}>
|
||||
Showing{" "}
|
||||
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "}
|
||||
resolved {historyRows.length === 1 ? "entry" : "entries"}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{historyRows.length}</span> resolved{" "}
|
||||
{historyRows.length === 1 ? "entry" : "entries"}
|
||||
{hasMore(history) ? "" : " — end of history"}
|
||||
</p>
|
||||
<MoreButton section={history} />
|
||||
|
||||
@@ -101,7 +101,7 @@ export function healthFacts(health: Health, locale?: string, timeZone?: string):
|
||||
label: "Upstreams",
|
||||
tone: upstreams.state === "unavailable" ? "danger" : "ok",
|
||||
value: upstreams.state === "unavailable" ? "None reachable" : "Available",
|
||||
detail: `${formatCount(upstreams.available)} of ${formatCount(upstreams.total)} enabled`,
|
||||
detail: `${upstreams.available} of ${upstreams.total} enabled`,
|
||||
...(upstreams.state === "unavailable"
|
||||
? { 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.43",
|
||||
"192.0.2.42",
|
||||
"Other",
|
||||
"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", () => {
|
||||
@@ -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
|
||||
* 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]));
|
||||
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
]);
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual(["192.0.2.30"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "192.0.2.30", "Other"]);
|
||||
).toEqual(["Time", "192.0.2.30"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual(["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", () => {
|
||||
render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0]));
|
||||
expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("one query outside the named clients is enough to keep Other", () => {
|
||||
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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -49,10 +49,11 @@ interface Series {
|
||||
}
|
||||
|
||||
/**
|
||||
* "Other" last, so it sits at the top of every column rather than under a client.
|
||||
* It is always a series, even at zero across the window (ui-visual-redesign.md:
|
||||
* eight named clients plus Other): a reader comparing two scopes sees the same
|
||||
* legend in both, and a zero says the named clients were the whole story.
|
||||
* "Other" last, so it sits at the top of every column rather than under a client,
|
||||
* and dropped entirely when it counted nothing across the window: an aggregation
|
||||
* bucket that aggregated nothing is a legend entry, a stack key, a tooltip row
|
||||
* and a table column all saying zero. The named clients stay at zero, because a
|
||||
* client that went quiet is something the reader wants to see.
|
||||
*/
|
||||
function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
|
||||
const named = data.clients.map((client, rank) => ({
|
||||
@@ -63,6 +64,7 @@ function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
|
||||
color: clientSeriesColor(rank),
|
||||
buckets: client.buckets,
|
||||
}));
|
||||
if (data.other.every((count) => count === 0)) return named;
|
||||
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
|
||||
}
|
||||
|
||||
@@ -101,7 +103,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
|
||||
const timestamps = columns.map((column) => column.ts);
|
||||
const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0));
|
||||
const plot = plotArea(width, Math.max(...totals));
|
||||
const plot = plotArea(width);
|
||||
const xScale = bandScale(timestamps, plot);
|
||||
// Every series here is a disjoint part of the whole rather than a highlighted
|
||||
// subset of a separately reported total, so the tallest column's own sum is
|
||||
@@ -112,10 +114,9 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot));
|
||||
|
||||
/**
|
||||
* The bucket's busiest clients, then Other, then the total — eight named
|
||||
* rows would be a table, and the reader pointing at a spike wants to know
|
||||
* who made it. Other is always there, at zero when the named clients were
|
||||
* the whole bucket, so the rows read the same from bucket to bucket.
|
||||
* The bucket's busiest clients, then the aggregate, then the total — eight
|
||||
* named rows would be a table, and the reader pointing at a spike wants to
|
||||
* know who made it.
|
||||
*/
|
||||
function tooltipOf(index: number): TooltipContent {
|
||||
const column = columns[index];
|
||||
@@ -124,7 +125,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
.sort((a, b) => column[b.key] - column[a.key])
|
||||
.slice(0, TOOLTIP_CLIENTS);
|
||||
const other = series.find((one) => one.key === OTHER_KEY);
|
||||
const rows = [...named, ...(other !== undefined ? [other] : [])].map((one) => ({
|
||||
const rows = [...named, ...(other !== undefined && column[OTHER_KEY] > 0 ? [other] : [])].map((one) => ({
|
||||
key: one.key,
|
||||
label: one.label,
|
||||
color: one.color,
|
||||
@@ -140,7 +141,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
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%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* the frame would drift. Nothing here imports a chart.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
@@ -66,22 +65,6 @@ const styles = stylex.create({
|
||||
minWidth: { default: "11rem", [NARROW]: 0 },
|
||||
flex: { default: null, [NARROW]: 1 },
|
||||
},
|
||||
/** Under the Device control: the list behind it did not load, so the control offers the household only. */
|
||||
devicesFailed: {
|
||||
margin: 0,
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
retry: {
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
backgroundColor: "transparent",
|
||||
font: "inherit",
|
||||
color: colors.primaryOnSurface,
|
||||
cursor: "pointer",
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
@@ -104,11 +87,9 @@ export interface OverviewScope {
|
||||
* The devices the reader can scope to: the whole household first, then every
|
||||
* registered client under its name. A scope the URL carries that the list has
|
||||
* never seen is still offered, as its address, so the control shows the scope
|
||||
* the page is actually under rather than silently claiming the household. A
|
||||
* list that failed to load is said so under the control, with a retry: the
|
||||
* household-only list is a failure, not the answer.
|
||||
* the page is actually under rather than silently claiming the household.
|
||||
*/
|
||||
function useDeviceOptions(client: string | undefined) {
|
||||
function useDeviceOptions(client: string | undefined): SelectOption[] {
|
||||
const clients = useQuery(clientsQuery());
|
||||
const names = useClientNames();
|
||||
const known = (clients.data ?? []).map((one) => ({
|
||||
@@ -119,9 +100,7 @@ function useDeviceOptions(client: string | undefined) {
|
||||
if (client !== undefined && !known.some((option) => option.value === client)) {
|
||||
options.push({ value: client, label: client });
|
||||
}
|
||||
const { refetch } = clients;
|
||||
const retry = useCallback(() => void refetch(), [refetch]);
|
||||
return { options, failed: clients.isError, retry };
|
||||
return options;
|
||||
}
|
||||
|
||||
export function OverviewToolbar({
|
||||
@@ -138,18 +117,10 @@ export function OverviewToolbar({
|
||||
<Select
|
||||
aria-label="Device"
|
||||
variant="toolbar"
|
||||
options={devices.options}
|
||||
options={devices}
|
||||
value={scope.client ?? ALL_DEVICES}
|
||||
onChange={(value) => onChange({ ...scope, client: value === ALL_DEVICES ? undefined : value })}
|
||||
/>
|
||||
{devices.failed && (
|
||||
<p role="alert" {...stylex.props(styles.devicesFailed)}>
|
||||
Device list unavailable.{" "}
|
||||
<button type="button" onClick={devices.retry} {...stylex.props(styles.retry, shared.focusRing)}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div {...stylex.props(styles.control)}>
|
||||
<Select
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
test("the client chart keeps Other in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. Other is still a series, so the
|
||||
// legend reads the same in every scope and its zero says the named clients
|
||||
// were the whole story.
|
||||
test("the client chart drops Other in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. An aggregation bucket that
|
||||
// aggregated nothing is a legend entry and a table column that say only that
|
||||
// they are empty; the named clients stay, because a quiet client is a fact.
|
||||
renderApp();
|
||||
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
|
||||
// reader gets instead of the graphic.
|
||||
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2));
|
||||
expect(within(chart as HTMLElement).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 () => {
|
||||
|
||||
@@ -112,10 +112,9 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
|
||||
const columns = columnsOf(data.buckets);
|
||||
const timestamps = columns.map((column) => column.ts);
|
||||
const peak = Math.max(...columns.map((column) => column.queries));
|
||||
const plot = plotArea(width, peak);
|
||||
const plot = plotArea(width);
|
||||
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 centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
|
||||
const pointsOf = (key: SeriesKey): [number, number][] =>
|
||||
@@ -125,7 +124,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
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%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import {
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartTooltip,
|
||||
MARGIN,
|
||||
TICK_GLYPH_PX,
|
||||
TICK_LABEL_GAP_PX,
|
||||
bandPaddingInner,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
@@ -38,91 +33,6 @@ describe("valueScale", () => {
|
||||
expect(scale.ticks(5)).toEqual([0, 500, 1000, 1500]);
|
||||
expect(valueTicks(scale)).toEqual([0, 500, 1000, 1500]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A query count is a whole number. d3 answers a domain of [0, 1] with fifths,
|
||||
* and "0.5" queries is a quantity the data cannot hold, so the fractional
|
||||
* ticks are dropped from the grid and the axis alike.
|
||||
*/
|
||||
test("a single-digit peak is ticked in whole queries", () => {
|
||||
expect(valueTicks(valueScale(1, [240, 0]))).toEqual([0, 1]);
|
||||
expect(valueTicks(valueScale(2, [240, 0]))).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plotArea", () => {
|
||||
/**
|
||||
* The value labels are grouped counts, so a chart that peaks in the millions
|
||||
* needs more room to its left than the default margin: "1,250,000" does not
|
||||
* fit where "120" does. The width is measured on a quarter above the peak,
|
||||
* because the tick above it is the widest label drawn.
|
||||
*/
|
||||
test("the left margin grows with the widest value label", () => {
|
||||
expect(plotArea(640, 0).x).toBe(MARGIN.left);
|
||||
|
||||
const widest = formatCount(Math.ceil(1_000_000 * 1.25));
|
||||
expect(plotArea(640, 1_000_000).x).toBe(widest.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
|
||||
expect(plotArea(640, 1_000_000).x).toBeGreaterThan(MARGIN.left);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `TooltipWithBounds` writes its own inline `transform` to place itself, and it
|
||||
* decides whether to flip above the anchor from that same number. Centring a
|
||||
* beside tooltip therefore has to reach the library as an `offsetTop`, measured
|
||||
* from the box, and not as a CSS transform the library cannot see.
|
||||
*/
|
||||
describe("ChartTooltip", () => {
|
||||
const BOX_HEIGHT = 40;
|
||||
|
||||
function withMeasuredBox(run: () => void) {
|
||||
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: () => BOX_HEIGHT,
|
||||
});
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, "offsetHeight", original);
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTooltip(beside: boolean) {
|
||||
const { container } = render(
|
||||
<ChartTooltip
|
||||
content={{ title: "12:00", rows: [{ key: "queries", label: "Total queries", value: "7" }] }}
|
||||
index={0}
|
||||
left={100}
|
||||
top={100}
|
||||
beside={beside}
|
||||
/>,
|
||||
);
|
||||
return container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom measures nothing, so both rects are zero-sized: the library takes its
|
||||
* window branch, nothing is clipped at 100px into a 1024x768 window, and the
|
||||
* unflipped `translate(left + offsetLeft, top + offsetTop)` is what it writes.
|
||||
*/
|
||||
test("a beside tooltip is offset by half the height it measures", () => {
|
||||
withMeasuredBox(() => {
|
||||
expect(renderTooltip(true).style.transform).toBe(`translate(116px, ${100 - BOX_HEIGHT / 2}px)`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a hung tooltip clears its anchor by the corner offset", () => {
|
||||
withMeasuredBox(() => {
|
||||
expect(renderTooltip(false).style.transform).toBe("translate(108px, 108px)");
|
||||
});
|
||||
});
|
||||
|
||||
/** An unmeasured box is not centred at all, rather than centred on a guess. */
|
||||
test("no measurable height leaves the tooltip on the anchor", () => {
|
||||
expect(renderTooltip(true).style.transform).toBe("translate(116px, 100px)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bandPaddingInner", () => {
|
||||
|
||||
@@ -15,18 +15,13 @@ import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
||||
import { GridRows } from "@visx/grid";
|
||||
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
|
||||
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 { colors, layers, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
export const CHART_HEIGHT = 240;
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
|
||||
/** The 10px tick label's glyph width; digits and the comma in a grouped count are all about this wide. */
|
||||
export const TICK_GLYPH_PX = 6;
|
||||
/** The tick label's gap to the axis (`dx` below) plus a little air. */
|
||||
export const TICK_LABEL_GAP_PX = 10;
|
||||
|
||||
/** The width a chart draws at before a measurement exists — jsdom, and the first paint. */
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
@@ -47,20 +42,12 @@ export interface Plot {
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
export function plotArea(width: number): Plot {
|
||||
const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom);
|
||||
const label = formatCount(Math.ceil(maxValue * 1.25));
|
||||
const left = Math.max(MARGIN.left, label.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
|
||||
return {
|
||||
x: left,
|
||||
x: MARGIN.left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - left - MARGIN.right),
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
height,
|
||||
bottom: MARGIN.top + height,
|
||||
};
|
||||
@@ -100,13 +87,9 @@ export function valueScale(max: number, range: [number, number]) {
|
||||
|
||||
export type ValueScale = ReturnType<typeof valueScale>;
|
||||
|
||||
/**
|
||||
* The tick values the grid and the value axis both draw. 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.
|
||||
*/
|
||||
/** The tick values the grid and the value axis both draw. */
|
||||
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",
|
||||
},
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: layers.tooltip,
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -227,11 +213,9 @@ const styles = stylex.create({
|
||||
lineHeight: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
/** The positioning shell: `TooltipWithBounds` owns its inline transform, so nothing visual may sit here. */
|
||||
tooltipShell: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: layers.tooltip,
|
||||
/** Beside a point, the box is centred on the point's height rather than hung from it. */
|
||||
tooltipBeside: {
|
||||
transform: "translateY(-50%)",
|
||||
},
|
||||
tooltipTitle: {
|
||||
fontWeight: 500,
|
||||
@@ -357,7 +341,7 @@ export function ChartFrame({
|
||||
hideAxisLine
|
||||
hideTicks
|
||||
tickLength={0}
|
||||
tickFormat={(value) => formatCount(Number(value))}
|
||||
tickFormat={(value) => formatCompact(Number(value))}
|
||||
// `dy` overrides AxisLeft's own 0.25em nudge, which would double up
|
||||
// with the middle baseline this app centres its value labels on.
|
||||
tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }}
|
||||
@@ -498,8 +482,7 @@ export interface TooltipContent {
|
||||
/**
|
||||
* `TooltipWithBounds` positions itself with an inline transform and drops it
|
||||
* when `unstyled` is set, so the default look is replaced by handing it an empty
|
||||
* style object rather than by turning styling off. What it keeps is the
|
||||
* positioning; the box the reader sees is the element inside it.
|
||||
* style object rather than by turning styling off.
|
||||
*/
|
||||
const NO_INLINE_STYLE = {};
|
||||
|
||||
@@ -526,11 +509,8 @@ export function ChartTooltip({
|
||||
top?: number;
|
||||
/**
|
||||
* Beside the anchor at its own height rather than hung from the chart's top:
|
||||
* the area chart's placement, where `top` is the point's y. The centring is a
|
||||
* negative half-height `offsetTop`, not a CSS transform, so that the flip
|
||||
* `TooltipWithBounds` computes from its own rect sees where the box really
|
||||
* lands. It flips the box to the anchor's left when it would run past the
|
||||
* right edge, and above the anchor when it would run past the bottom.
|
||||
* the area chart's placement, where `top` is the point's y. `TooltipWithBounds`
|
||||
* flips the box to the anchor's left when it would run past the right edge.
|
||||
*/
|
||||
beside?: boolean;
|
||||
}) {
|
||||
@@ -540,25 +520,16 @@ export function ChartTooltip({
|
||||
// a digit boundary, or resolve a client's name, and the stale width would place
|
||||
// it wrongly at the right edge.
|
||||
const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|");
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
// The centring offset is half the box's own height, so it cannot be known
|
||||
// before the box exists. `measureKey` remounts the whole tooltip per content
|
||||
// change, so one measurement per mount covers every size the box takes.
|
||||
useLayoutEffect(() => {
|
||||
if (box.current) setHeight(box.current.offsetHeight);
|
||||
}, []);
|
||||
return (
|
||||
<TooltipWithBounds
|
||||
key={measureKey}
|
||||
left={left}
|
||||
top={top}
|
||||
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
|
||||
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
|
||||
offsetTop={beside ? 0 : CORNER_OFFSET}
|
||||
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>
|
||||
<dl {...stylex.props(styles.tooltipList)}>
|
||||
{content.rows.map((row) => (
|
||||
@@ -576,7 +547,6 @@ export function ChartTooltip({
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</TooltipWithBounds>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,42 +3,12 @@ import { parseClient } from "./clientScope";
|
||||
test("one address passes, and nothing else does", () => {
|
||||
expect(parseClient("192.0.2.30")).toBe("192.0.2.30");
|
||||
expect(parseClient("2001:db8::1")).toBe("2001:db8::1");
|
||||
expect(parseClient("::1")).toBe("::1");
|
||||
expect(parseClient("::ffff:192.0.2.30")).toBe("192.0.2.30");
|
||||
expect(parseClient("fe80::1%25eth0")).toBeUndefined();
|
||||
// An IPv4 tail is the last 32 bits, so nothing may follow it.
|
||||
expect(parseClient("192.0.2.30::")).toBeUndefined();
|
||||
expect(parseClient("::192.0.2.30:1")).toBeUndefined();
|
||||
expect(parseClient("2001:db8:0:0:0:0:0:0:1")).toBeUndefined();
|
||||
expect(parseClient("2001:db8::1::2")).toBeUndefined();
|
||||
expect(parseClient("2001:db8::1:2:3:4:5:6:7")).toBeUndefined();
|
||||
expect(parseClient("256.0.0.1")).toBeUndefined();
|
||||
expect(parseClient("192.0.2")).toBeUndefined();
|
||||
expect(parseClient(" 192.0.2.30")).toBeUndefined();
|
||||
expect(parseClient("abc")).toBeUndefined();
|
||||
expect(parseClient(undefined)).toBeUndefined();
|
||||
expect(parseClient("")).toBeUndefined();
|
||||
expect(parseClient(7)).toBeUndefined();
|
||||
// A list is the Activity filter's grammar, not this page's.
|
||||
expect(parseClient("192.0.2.30,192.0.2.31")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an IPv6 address is reduced to the spelling the logger stores", () => {
|
||||
expect(parseClient("2001:DB8::1")).toBe("2001:db8::1");
|
||||
expect(parseClient("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe("2001:db8::1");
|
||||
expect(parseClient("2001:db8:0:0:1:0:0:1")).toBe("2001:db8::1:0:0:1");
|
||||
expect(parseClient("1:0:0:1:0:0:0:1")).toBe("1:0:0:1::1");
|
||||
expect(parseClient("0:0:0:0:0:0:0:0")).toBe("::");
|
||||
expect(parseClient("2001:db8:0:1:1:1:1:1")).toBe("2001:db8:0:1:1:1:1:1");
|
||||
});
|
||||
|
||||
/**
|
||||
* `src/platform/address.zig` prints hex groups with RFC 5952 compression and
|
||||
* nothing else, and it normalizes an IPv4-mapped address to the plain IPv4, so
|
||||
* a dotted tail in a pasted link has to be folded the same way to match.
|
||||
*/
|
||||
test("an IPv4 tail is folded into hextets, and a mapped address into its IPv4", () => {
|
||||
expect(parseClient("2001:db8::192.0.2.30")).toBe("2001:db8::c000:21e");
|
||||
expect(parseClient("::ffff:192.0.2.30")).toBe("192.0.2.30");
|
||||
expect(parseClient("::FFFF:C000:021E")).toBe("192.0.2.30");
|
||||
// Longer than any address the server stores.
|
||||
expect(parseClient("1".repeat(46))).toBeUndefined();
|
||||
expect(parseClient("1".repeat(45))).toBe("1".repeat(45));
|
||||
});
|
||||
|
||||
@@ -1,96 +1,16 @@
|
||||
/**
|
||||
* The device Overview is scoped to, as URL state beside the period.
|
||||
*
|
||||
* The API takes one exact address (no list) and matches the text the logger
|
||||
* stored, so that is what the route lets through: a value that is not an IPv4
|
||||
* or IPv6 address is dropped from the URL rather than sent on to a 400, and an
|
||||
* IPv6 address is reduced to the RFC 5952 spelling the logger writes, so an
|
||||
* uppercase or expanded form in a pasted link still finds its client. The
|
||||
* server writes hex groups only, so an IPv4 tail is folded into the two hextets
|
||||
* it names and an IPv4-mapped address is reduced to its plain IPv4. An absent
|
||||
* scope is the whole household.
|
||||
* The API takes one exact address (`logger.max_client_len` bytes at most, no
|
||||
* list), so that is what the route lets through: anything else is dropped from
|
||||
* the URL rather than sent on to a 400. An absent scope is the whole household.
|
||||
*/
|
||||
|
||||
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
||||
const HEXTET = /^[0-9a-f]{1,4}$/i;
|
||||
|
||||
/** The two hextets an IPv4 tail names: `192.0.2.30` is `c000:21e`. */
|
||||
function ipv4Hextets(dotted: string): string[] {
|
||||
const octets = dotted.split(".").map(Number);
|
||||
return [((octets[0] << 8) | octets[1]).toString(16), ((octets[2] << 8) | octets[3]).toString(16)];
|
||||
}
|
||||
|
||||
/**
|
||||
* One side of a `::` as hextets, or null. An IPv4 tail is folded into the two
|
||||
* hextets it names — the server prints an IPv6 address as hex groups only,
|
||||
* never with a dotted tail — and it is read only where the address ends, since
|
||||
* the tail is the last 32 bits and nothing may follow it.
|
||||
*/
|
||||
function sideHextets(parts: string[], dottedAllowed: boolean): string[] | null {
|
||||
const last = parts[parts.length - 1];
|
||||
const hextets =
|
||||
last !== undefined && last.includes(".")
|
||||
? dottedAllowed && IPV4.test(last)
|
||||
? [...parts.slice(0, -1), ...ipv4Hextets(last)]
|
||||
: null
|
||||
: parts;
|
||||
if (hextets === null) return null;
|
||||
return hextets.every((group) => HEXTET.test(group)) ? hextets : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 4291 text — up to eight hextets, one `::` at most, an IPv4 tail allowed
|
||||
* as the last 32 bits — parsed to its eight hextets, or null.
|
||||
*/
|
||||
function ipv6Groups(value: string): string[] | null {
|
||||
const halves = value.split("::");
|
||||
if (halves.length > 2) return null;
|
||||
const split = halves.length === 2;
|
||||
const head = sideHextets(halves[0] === "" ? [] : (halves[0] as string).split(":"), !split);
|
||||
const tail = sideHextets(split && halves[1] !== "" ? (halves[1] as string).split(":") : [], split);
|
||||
if (head === null || tail === null) return null;
|
||||
const width = head.length + tail.length;
|
||||
if (split ? width >= 8 : width !== 8) return null;
|
||||
const zeros = Array.from({ length: 8 - width }, () => "0");
|
||||
const expanded = split ? [...head, ...zeros, ...tail] : head;
|
||||
return expanded.map((group) => group.replace(/^0+(?=.)/, "").toLowerCase());
|
||||
}
|
||||
|
||||
/** RFC 5952: lowercase, no leading zeros, the longest run of two or more zero groups as `::` (the first on a tie). */
|
||||
function compressIpv6(groups: string[]): string {
|
||||
let best = { start: -1, length: 0 };
|
||||
for (let i = 0; i < groups.length;) {
|
||||
if (groups[i] !== "0") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < groups.length && groups[j] === "0") j += 1;
|
||||
if (j - i >= 2 && j - i > best.length) best = { start: i, length: j - i };
|
||||
i = j;
|
||||
}
|
||||
if (best.start < 0) return groups.join(":");
|
||||
const head = groups.slice(0, best.start).join(":");
|
||||
const tail = groups.slice(best.start + best.length).join(":");
|
||||
return `${head}::${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dotted IPv4 an IPv4-mapped address stands for, or null. The server
|
||||
* normalizes `::ffff:a.b.c.d` to the plain `a.b.c.d`, so a link that spells the
|
||||
* mapped form has to be reduced the same way to find its client.
|
||||
*/
|
||||
function mappedIpv4(groups: string[]): string | null {
|
||||
if (groups.slice(0, 5).some((group) => group !== "0") || groups[5] !== "ffff") return null;
|
||||
const high = Number.parseInt(groups[6] as string, 16);
|
||||
const low = Number.parseInt(groups[7] as string, 16);
|
||||
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
||||
}
|
||||
/** `logger.max_client_len` on the server: the longest textual address it stores. */
|
||||
const MAX_CLIENT_LEN = 45;
|
||||
|
||||
export function parseClient(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (IPV4.test(value)) return value;
|
||||
const groups = ipv6Groups(value);
|
||||
if (groups === null) return undefined;
|
||||
return mappedIpv4(groups) ?? compressIpv6(groups);
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > MAX_CLIENT_LEN) return undefined;
|
||||
if (value.includes(",")) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* completion. One request cannot disagree with itself, so those behaviours have
|
||||
* no subject left and are gone rather than ported. What survived the collapse is
|
||||
* pinned below: the three states, and the one rule a single request still does
|
||||
* not settle — that the body of the scope the reader just left, period or
|
||||
* device, must never render under the new scope's label.
|
||||
* not settle — that a `keepPreviousData` body from the period the reader left
|
||||
* must never render under the new period's label.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
@@ -63,8 +63,8 @@ afterEach(() => vi.unstubAllGlobals());
|
||||
/** The last retry the hook handed out, so a test can spend it. */
|
||||
let lastRetry: () => void;
|
||||
|
||||
function Probe({ period, client }: { period: Period; client?: string }) {
|
||||
const panel = useOverviewWindow(period, client);
|
||||
function Probe({ period }: { period: Period }) {
|
||||
const panel = useOverviewWindow(period, undefined);
|
||||
if (panel.status === "error") lastRetry = panel.retry;
|
||||
const detail =
|
||||
panel.status === "ready"
|
||||
@@ -75,18 +75,18 @@ function Probe({ period, client }: { period: Period; client?: string }) {
|
||||
return <p>{`${panel.status}:${detail}`}</p>;
|
||||
}
|
||||
|
||||
function renderProbe(period: Period = "24h", scope?: string) {
|
||||
function renderProbe(period: Period = "24h") {
|
||||
const client = createQueryClient();
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe period={period} client={scope} />
|
||||
<Probe period={period} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return {
|
||||
rerenderWith: (next: Period, nextScope?: string) =>
|
||||
rerenderWith: (next: Period) =>
|
||||
view.rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe period={next} client={nextScope} />
|
||||
<Probe period={next} />
|
||||
</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);
|
||||
});
|
||||
|
||||
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");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
|
||||
rerenderWith("1h");
|
||||
// The 24h body is a complete answer and still the wrong one to draw under
|
||||
// "1h", so the page waits for its own.
|
||||
// `keepPreviousData` is holding the 24h body. It is a complete answer and
|
||||
// still the wrong one to draw under "1h", so the page waits.
|
||||
expect(line()).toBe("loading:");
|
||||
await waitFor(() => expect(line()).toBe(`ready:1h@${UNTIL}`));
|
||||
});
|
||||
|
||||
test("rescoping to a device under the same period waits for that device's body", async () => {
|
||||
const { rerenderWith } = renderProbe("24h");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
|
||||
rerenderWith("24h", "192.0.2.30");
|
||||
// Same period, so the household body would pass a period check; it is
|
||||
// still the wrong scope to draw under the device's name.
|
||||
expect(line()).toBe("loading:");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
});
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
* breakdowns describe the same span and the same database state by construction,
|
||||
* and none of that reconciliation has anything left to reconcile.
|
||||
*
|
||||
* The query key carries the period and the client, so the body the hook
|
||||
* returns is always the body of the scope the toolbar names. A rescope shows
|
||||
* the loading state until its own answer lands rather than the previous
|
||||
* scope's charts under the new label: a complete, self-consistent body for the
|
||||
* wrong device is still the wrong body.
|
||||
* What remains is the one rule a single request does not settle by itself.
|
||||
* `keepPreviousData` holds the body of the period the reader just left — a
|
||||
* complete, self-consistent answer, and still the wrong one to draw under the
|
||||
* new label — so a body is a member of this window only while its own `period`
|
||||
* is the selected one. Until then the page is loading.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { overviewQuery } from "@/lib/queries";
|
||||
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 };
|
||||
|
||||
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 retry = useCallback(() => void refetch(), [refetch]);
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
|
||||
const styles = stylex.create({
|
||||
message: {
|
||||
@@ -46,7 +45,7 @@ export default function InlineError({ error, onRetry }: { error: unknown; onRetr
|
||||
if (error.status === 429) {
|
||||
message =
|
||||
remaining !== null && remaining > 0
|
||||
? `Rate limited. Try again in ${formatDuration(remaining)}.`
|
||||
? `Rate limited. Try again in ${remaining}s.`
|
||||
: "Rate limited. Try again.";
|
||||
} else if (error.status === 503) {
|
||||
message = "The server is starting or degraded. Try again shortly.";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
formatBytes,
|
||||
formatClock,
|
||||
formatCompact,
|
||||
formatCount,
|
||||
formatDuration,
|
||||
formatMicros,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
|
||||
test("formatTime renders unix seconds in the given locale and zone", () => {
|
||||
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
|
||||
expect(formatTime(1704067200, "en-US", "UTC").replace(/\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", () => {
|
||||
@@ -28,6 +29,12 @@ test("formatCount groups thousands", () => {
|
||||
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", () => {
|
||||
expect(formatPercent(0)).toBe("0.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
|
||||
* module calls a number-formatting API. A bare `{count}` in JSX is invisible to
|
||||
* this scan and is caught in review; the scan closes the door on the four ways
|
||||
* a competing rule would be written. `datetime-local` zero-padding in
|
||||
* `activity/datetime.ts` is a wire grammar, not a display rule, and is the one
|
||||
* allowed `padStart`. Identifiers, configured values and preset labels are not
|
||||
* quantities at all and are exempt by the header rule, so the scan never sees
|
||||
* them.
|
||||
* The contract's teeth: nothing outside this module formats a number for the
|
||||
* reader. `datetime-local` zero-padding in `activity/datetime.ts` is a wire
|
||||
* grammar, not a display rule, and is the one allowed `padStart`.
|
||||
*/
|
||||
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 offenders: string[] = [];
|
||||
for (const [path, text] of Object.entries(sources)) {
|
||||
|
||||
+6
-10
@@ -6,16 +6,6 @@
|
||||
* duration is its two largest nonzero units with no zero padding. A call site
|
||||
* that formats inline is a defect; the sweep test in `format.test.ts` and the
|
||||
* review both hunt for one.
|
||||
*
|
||||
* What the contract covers is a measured quantity the reader compares: a count,
|
||||
* a share, a rate, a duration, a size in bytes, a time. Three kinds of number
|
||||
* are not quantities and render as written. An identifier or a protocol code —
|
||||
* a group or source id, an RCODE, a QCLASS, an unknown QTYPE's number — names a
|
||||
* thing rather than measuring one, though a row's ordinal is a count. A
|
||||
* configured value shown next to or inside the input that edits it — a TTL of
|
||||
* 3600, a cache size of 10000, a priority — must read back exactly as the
|
||||
* operator typed it. And the fixed label of a preset in a menu — "Past 24
|
||||
* hours", "5 minutes" — is copy, not a measurement.
|
||||
*/
|
||||
|
||||
/** Unix seconds → localized date-time. `locale`/`timeZone` exist for deterministic tests. */
|
||||
@@ -51,12 +41,18 @@ export function formatBucketTime(unixSeconds: number, bucketSeconds: number): st
|
||||
}
|
||||
|
||||
const grouped = new Intl.NumberFormat("en-US");
|
||||
const compact = new Intl.NumberFormat("en-US", { notation: "compact" });
|
||||
|
||||
/** A count, thousands-grouped: 18432 → "18,432". */
|
||||
export function formatCount(count: number): string {
|
||||
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
|
||||
* 15.435 (stored just under) would print 15.43; the nudge lifts an exact half
|
||||
|
||||
@@ -48,7 +48,7 @@ export const healthQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.health, queryFn: api.getHealth, refetchInterval: 10_000 });
|
||||
|
||||
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) =>
|
||||
queryOptions({
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
import type { Period } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
|
||||
export interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
@@ -106,10 +105,7 @@ function RouteError({ error }: ErrorComponentProps) {
|
||||
detail = error.message;
|
||||
} else if (error.status === 429) {
|
||||
title = "Rate limited";
|
||||
detail =
|
||||
error.retryAfter !== undefined
|
||||
? `Try again in ${formatDuration(error.retryAfter)}.`
|
||||
: "Try again shortly.";
|
||||
detail = error.retryAfter !== undefined ? `Try again in ${error.retryAfter}s.` : "Try again shortly.";
|
||||
} else if (error.status >= 500) {
|
||||
title = "Internal error";
|
||||
} else {
|
||||
|
||||
@@ -101,10 +101,9 @@ export function ProtectionDot() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Uptime as of now, not as of the fetch: the version answer is a minute old at
|
||||
* most, so the seconds it reported are aged by the time since it landed. A
|
||||
* restart shows within that minute. Ticks every second, because the first
|
||||
* minute reads in seconds.
|
||||
* Uptime as of now, not as of the fetch: the version answer is cached for the
|
||||
* session, so the seconds it reported are aged by the time since it landed.
|
||||
* Ticks every second, because the first minute reads in seconds.
|
||||
*/
|
||||
function useUptimeSeconds(): number | null {
|
||||
const version = useQuery(versionQuery());
|
||||
|
||||
@@ -13,15 +13,6 @@ test("open episodes are the count, warnings and errors together", () => {
|
||||
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" });
|
||||
});
|
||||
|
||||
/** Four digits of open episodes read as one number, grouped like every other count. */
|
||||
test("a four-figure count is grouped", () => {
|
||||
const badge = diagnosticsBadge(
|
||||
health({ diagnostics: { state: "recording", active_warnings: 1200, active_errors: 34 } }),
|
||||
false,
|
||||
);
|
||||
expect(badge).toEqual({ text: "1,234", label: "1,234 active diagnostic events" });
|
||||
});
|
||||
|
||||
test("one open episode is counted in the singular", () => {
|
||||
const badge = diagnosticsBadge(
|
||||
health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }),
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
*/
|
||||
|
||||
import type { Health } from "@/lib/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
export interface NavBadge {
|
||||
/** 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;
|
||||
const open = health.diagnostics.active_warnings + health.diagnostics.active_errors;
|
||||
if (open > 0) {
|
||||
return {
|
||||
text: formatCount(open),
|
||||
label: `${formatCount(open)} active diagnostic ${open === 1 ? "event" : "events"}`,
|
||||
};
|
||||
return { text: String(open), label: `${open} active diagnostic ${open === 1 ? "event" : "events"}` };
|
||||
}
|
||||
if (health.status === "degraded") return { text: "!", label: "Health degraded" };
|
||||
return null;
|
||||
|
||||
@@ -264,7 +264,6 @@ export const styles = stylex.create({
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
/** Focusable: a table wider than a phone scrolls sideways, and a keyboard must be able to reach the scroll. */
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
|
||||
@@ -92,7 +92,7 @@ export const colors = stylex.defineVars({
|
||||
seriesMagenta: { default: "oklch(66% 0.14 340)", [DARK]: "oklch(66% 0.14 340)" },
|
||||
seriesOrange: { default: "oklch(70% 0.14 55)", [DARK]: "oklch(70% 0.14 55)" },
|
||||
seriesOlive: { default: "oklch(72% 0.11 120)", [DARK]: "oklch(72% 0.11 120)" },
|
||||
seriesOther: { default: "oklch(80% 0.01 260)", [DARK]: "oklch(80% 0.01 260)" },
|
||||
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 busiest type outward. Six steps cover every ring the API answers with
|
||||
|
||||
Reference in New Issue
Block a user