-
+
{client.ip}
+
+
+
+ {" "}
+ · {client.group}
+
+
+ Last seen {formatTime(client.last_seen)}
+
-
+
- {client.group}
- {formatTime(client.first_seen)}
- {formatTime(client.last_seen)}
+ {client.group}
+ {formatTime(client.first_seen)}
+ {formatTime(client.last_seen)}
{/* Naming a client writes configuration, so the affordance is
diff --git a/admin/src/features/clients/NetworkAssignments.tsx b/admin/src/features/clients/NetworkAssignments.tsx
index 5c37624..38b766d 100644
--- a/admin/src/features/clients/NetworkAssignments.tsx
+++ b/admin/src/features/clients/NetworkAssignments.tsx
@@ -102,11 +102,14 @@ const styles = stylex.create({
},
table: {
width: "100%",
- minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
+ /** A full IPv6 prefix, or a group name, can be one unbroken string wider than a phone's column. */
+ unbroken: {
+ overflowWrap: "anywhere",
+ },
});
/**
@@ -155,8 +158,8 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
{prefixes.map((prefix) => (
- {prefix.prefix}
- {prefix.group}
+ {prefix.prefix}
+ {prefix.group}
{prefix.priority}
))}
diff --git a/admin/src/features/configuration/LocalReadOnly.tsx b/admin/src/features/configuration/LocalReadOnly.tsx
index 3c4282d..fa47201 100644
--- a/admin/src/features/configuration/LocalReadOnly.tsx
+++ b/admin/src/features/configuration/LocalReadOnly.tsx
@@ -1,6 +1,7 @@
import * as stylex from "@stylexjs/stylex";
import type { ForwardZone, LocalRecord } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
+import { local, RecordFacts, ZoneResolver } from "./localCells";
import { styles as config } from "./styles";
/**
@@ -26,18 +27,23 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
Name
- Type
- Value
- TTL
+ Type
+ Value
+ TTL
{records.map((record) => (
- {record.name}
- {record.rtype}
- {record.value}
- {record.ttl}
+
+ {record.name}
+
+
+ {record.rtype}
+
+ {record.value}
+
+ {record.ttl}
))}
@@ -66,14 +72,19 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
Zone
- Resolver
+ Resolver
{zones.map((zone) => (
- {zone.zone}
- {zone.resolver}
+
+ {zone.zone}
+
+
+
+ {zone.resolver}
+
))}
diff --git a/admin/src/features/configuration/ProtectionGroups.tsx b/admin/src/features/configuration/ProtectionGroups.tsx
index 5be4edd..cdc367a 100644
--- a/admin/src/features/configuration/ProtectionGroups.tsx
+++ b/admin/src/features/configuration/ProtectionGroups.tsx
@@ -19,6 +19,7 @@ import {
} from "@/lib/queries";
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
+import FactLine from "@/ui/FactLine";
import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select";
import Switch from "@/ui/Switch";
@@ -43,6 +44,13 @@ const ACTION_OPTIONS = [
{ value: "block", label: "block" },
];
+/**
+ * Narrower than a rule's four columns, or a source's name beside its URL: below
+ * it each table keeps its first column and the actions, and the first cell
+ * carries the rest.
+ */
+const COMPACT = "@container (max-width: 36rem)";
+
const styles = stylex.create({
createForm: {
display: "flex",
@@ -96,8 +104,27 @@ const styles = stylex.create({
block: {
color: colors.danger,
},
+ /** A pattern or a URL can be one unbroken string as wide as the table. */
pattern: {
fontWeight: 500,
+ overflowWrap: "anywhere",
+ },
+ unbroken: {
+ overflowWrap: "anywhere",
+ },
+ /** Read character by character, so a wrapped URL fills each line instead of stranding a "-" on one. */
+ urlText: {
+ wordBreak: "break-all",
+ },
+ wide: {
+ display: { default: null, [COMPACT]: "none" },
+ },
+ compact: {
+ display: { default: "none", [COMPACT]: "block" },
+ marginTop: "0.125rem",
+ color: colors.textMuted,
+ fontWeight: 400,
+ overflowWrap: "anywhere",
},
});
@@ -275,6 +302,10 @@ function GroupSourcesReadOnly({ group }: { group: Group }) {
);
}
+function ActionLabel({ action }: { action: RuleAction }) {
+ return {action} ;
+}
+
function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) {
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
if (assigned.length === 0) return This group is assigned no sources.
;
@@ -284,14 +315,17 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo
Source
- URL
+ URL
{assigned.map((source) => (
- {source.name}
- {source.url}
+
+ {source.name}
+ {source.url}
+
+ {source.url}
))}
@@ -471,9 +505,9 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
Pattern
- Kind
- Action
- Created
+ Kind
+ Action
+ Created
{editable && (
Actions
@@ -484,14 +518,23 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
{rules.map((rule) => (
- {rule.pattern}
- {rule.kind}
-
-
- {rule.action}
+
+ {rule.pattern}
+
+ ,
+ rule.kind,
+ `Created ${formatTime(rule.created_at)}`,
+ ]}
+ />
- {formatTime(rule.created_at)}
+ {rule.kind}
+
+
+
+ {formatTime(rule.created_at)}
{editable && (
call.method === method);
}
+/**
+ * The first cell of the row that shows `text`. jsdom applies no stylesheet,
+ * so a narrow table's copy of the hidden columns sits in that cell beside the
+ * columns themselves.
+ */
+function firstCell(text: string): HTMLElement {
+ return within(screen.getByText(text).closest("tr")!).getAllByRole("cell")[0]!;
+}
+
test("the group list is the master, and the selected group is the detail", async () => {
await openProtection();
@@ -122,6 +132,15 @@ test("only the selected group's rules are listed", async () => {
expect(screen.queryByText("ads.example.com")).toBeNull();
});
+test("a rule's Pattern cell carries its action, kind and creation time for a table too narrow for them", async () => {
+ await openProtection(2);
+
+ await screen.findByText("*.social.example");
+ expect(firstCell("*.social.example").textContent).toBe(
+ `*.social.example${["block", "wildcard", `Created ${formatTime(1700000100)}`].join(" · ")}`,
+ );
+});
+
test("a new rule is created in the selected group, with the pattern posted verbatim for a regex", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });
@@ -244,6 +263,23 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});
+test("a source's Name cell carries its URL, counts and last update for a table too narrow for them", async () => {
+ calls = stubApi(DATABASE);
+ await renderPage("/configuration/protection?tab=sources", "Protection");
+ await screen.findByText("Trackers");
+
+ const trackers = firstCell("Trackers").textContent;
+ expect(trackers).toContain("https://example.com/trackers.txt");
+ expect(trackers).toContain("50 domains · 0 wildcards · 0 exceptions");
+ expect(trackers).toContain("3 skipped regex · 4 skipped unsupported");
+ expect(trackers).toContain(`Updated ${formatTime(1700000000)}`);
+ // One of each reads in the singular.
+ expect(firstCell("Ads").textContent).toContain("100 domains · 2 wildcards · 1 exception");
+ expect(firstCell("Ads").textContent).toContain("Never updated");
+ // Two zero counts say nothing a reader needs to parse.
+ expect(firstCell("Ads").textContent).toContain("Nothing skipped");
+});
+
test("a source switch resends the whole row immediately, with no Save step", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
diff --git a/admin/src/features/configuration/ProtectionSources.tsx b/admin/src/features/configuration/ProtectionSources.tsx
index fbf3876..fb6decb 100644
--- a/admin/src/features/configuration/ProtectionSources.tsx
+++ b/admin/src/features/configuration/ProtectionSources.tsx
@@ -12,6 +12,7 @@ import {
} from "@/lib/queries";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
+import FactLine from "@/ui/FactLine";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
@@ -22,17 +23,24 @@ import QueryPanel from "./QueryPanel";
import { styles as config } from "./styles";
const SKIPPED_NOTE =
- "Both “Skipped” columns count lines nxdns read and did not take. Skipped regex lines are patterns nxdns accepts " +
+ "Both skipped counts are lines nxdns read and did not take. Skipped regex lines are patterns nxdns accepts " +
"only from you — adopt one you trust as a regex rule. Skipped unsupported lines are syntax nxdns cannot translate " +
"into a DNS decision: cosmetic element hiding, browser-only modifiers. A skipped unsupported count that dwarfs the " +
"domain count usually means the list is written for a browser extension, and its DNS or hosts variant will block " +
"more here.";
+/**
+ * Narrower than the ten columns side by side: below it the table keeps Name,
+ * Enabled and the actions, and the Name cell carries the rest.
+ */
+const COMPACT = "@container (max-width: 90rem)";
+
const styles = stylex.create({
name: {
fontWeight: 500,
},
badge: {
+ whiteSpace: "nowrap",
marginLeft: "0.5rem",
borderRadius: metrics.radius,
backgroundColor: colors.border,
@@ -49,15 +57,72 @@ const styles = stylex.create({
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
+ unbroken: {
+ overflowWrap: "anywhere",
+ },
+ /** Read character by character, so a wrapped URL fills each line instead of stranding a "-" on one. */
+ urlText: {
+ wordBreak: "break-all",
+ },
+ wide: {
+ display: { default: null, [COMPACT]: "none" },
+ },
+ /** A URL or a name can be one unbroken string as wide as the table. */
+ compact: {
+ display: { default: "none", [COMPACT]: "block" },
+ marginTop: "0.125rem",
+ color: colors.textMuted,
+ overflowWrap: "anywhere",
+ },
+ /** Stacked in the narrow form, which gives the first cell the width; the row is several lines tall there already. */
actions: {
display: "flex",
- gap: "0.75rem",
+ flexDirection: { default: null, [COMPACT]: "column" },
+ alignItems: { default: null, [COMPACT]: "flex-start" },
+ gap: { default: "0.75rem", [COMPACT]: "0.5rem" },
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
+function counted(count: number, one: string, many: string): string {
+ return `${formatCount(count)} ${count === 1 ? one : many}`;
+}
+
+/** The hidden columns' facts, in their column order, for the compact Name cell. */
+function SourceFacts({ source }: { source: Blocklist }) {
+ return (
+ <>
+ {source.url}
+
+
+
+
+ {source.skipped_regex_count === 0 && source.skipped_unsupported_count === 0 ? (
+ "Nothing skipped"
+ ) : (
+
+ )}
+
+
+ {source.last_updated === null ? "Never updated" : `Updated ${formatTime(source.last_updated)}`}
+
+ >
+ );
+}
+
/**
* A runtime action: it re-downloads the sources the running process already
* knows about, so it stays enabled under file authority and while
@@ -142,41 +207,44 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
Name
- URL
+ URL
Enabled
- Domains
- Wildcards
- Exceptions
- Skipped regex
- Skipped unsupported
- Last updated
+ Domains
+ Wildcards
+ Exceptions
+ Skipped regex
+ Skipped unsupported
+ Last updated
{blocklists.map((b) => (
-
+
{b.name}
{b.is_suggested && Suggested }
+
+
+
+ {b.url}
- {b.url}
{String(b.enabled)}
-
+
{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)}
@@ -235,14 +303,14 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
Name
- URL
+ URL
Enabled
- Domains
- Wildcards
- Exceptions
- Skipped regex
- Skipped unsupported
- Last updated
+ Domains
+ Wildcards
+ Exceptions
+ Skipped regex
+ Skipped unsupported
+ Last updated
Actions
@@ -251,11 +319,12 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
{blocklists.map((b) => (
-
+
{b.name}
{b.is_suggested && Suggested }
+
-
+
{b.url}
@@ -268,22 +337,22 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
onChange={() => toggleEnabled(b)}
/>
-
+
{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 f24a3f3..43c3e9e 100644
--- a/admin/src/features/configuration/RecordsTab.tsx
+++ b/admin/src/features/configuration/RecordsTab.tsx
@@ -12,6 +12,7 @@ import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
+import { local, RecordFacts } from "./localCells";
import QueryPanel from "./QueryPanel";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -230,13 +231,13 @@ export default function RecordsTab() {
Name
-
+
Type
-
+
Value
-
+
TTL
@@ -266,10 +267,22 @@ export default function RecordsTab() {
) : (
<>
- {record.name}
- {record.rtype}
- {record.value}
- {record.ttl}
+
+ {record.name}
+
+
+ {record.rtype}
+
+ {record.value}
+
+ {record.ttl}
[
return renderPage(`/configuration/resolution${suffix}`, "Resolution");
}
+/**
+ * The first cell of the row that shows `text`. jsdom applies no stylesheet,
+ * so a narrow table's copy of the hidden columns sits in that cell beside the
+ * columns themselves.
+ */
+function firstCell(text: string): HTMLElement {
+ return within(screen.getByText(text).closest("tr")!).getAllByRole("cell")[0]!;
+}
+
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog(index = 0) {
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[index]!);
@@ -40,6 +49,15 @@ test("the upstream pool is the default tab and lists every field", async () => {
expect(screen.getByText(/applies to the next query/)).toBeTruthy();
});
+test("an upstream's URL cell carries its priority and TLS name for a table too narrow for them", async () => {
+ await openResolution();
+ await screen.findByText("tls://9.9.9.9:853");
+
+ expect(firstCell("tls://9.9.9.9:853").textContent).toBe("tls://9.9.9.9:853Priority 200 · TLS name dns.quad9.net");
+ // An empty TLS name has nothing to say in a line of facts.
+ expect(firstCell("udp://1.1.1.1:53").textContent).toBe("udp://1.1.1.1:53Priority 100");
+});
+
test("adding an upstream posts every field", async () => {
await openResolution();
await screen.findByRole("heading", { name: "Add upstream" });
@@ -236,6 +254,31 @@ test("the record delete dialog names the record and only deletes on confirm", as
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/local-records/1" });
});
+test("a record's Name cell carries its answer and TTL for a table too narrow for them", async () => {
+ await openResolution("records");
+ await screen.findByText("nas.lan.home");
+
+ expect(firstCell("nas.lan.home").textContent).toBe("nas.lan.homeType A value 192.168.1.5TTL 300");
+});
+
+test("a zone's cell carries its resolver for a table too narrow for it", async () => {
+ await openResolution("zones");
+ await screen.findByText("lan.home");
+
+ expect(firstCell("lan.home").textContent).toBe("lan.homeResolver udp://192.168.1.1:53");
+});
+
+test("under file authority the records and zones tables carry the same narrow form", async () => {
+ calls = stubApi(MANAGED_FILE);
+ await renderPage("/configuration/resolution?tab=records", "Resolution");
+ await screen.findByText("nas.lan.home");
+ expect(firstCell("nas.lan.home").textContent).toBe("nas.lan.homeType A value 192.168.1.5TTL 300");
+
+ fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
+ await screen.findByText("lan.home");
+ expect(firstCell("lan.home").textContent).toBe("lan.homeResolver udp://192.168.1.1:53");
+});
+
test("the forward zone delete dialog names the zone and only deletes on confirm", async () => {
await openResolution("zones");
await screen.findByText("lan.home");
diff --git a/admin/src/features/configuration/UpstreamsTab.tsx b/admin/src/features/configuration/UpstreamsTab.tsx
index 14f21fb..0dcc780 100644
--- a/admin/src/features/configuration/UpstreamsTab.tsx
+++ b/admin/src/features/configuration/UpstreamsTab.tsx
@@ -5,6 +5,7 @@ import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
+import FactLine from "@/ui/FactLine";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
@@ -16,18 +17,41 @@ import { styles as config } from "./styles";
const INTRO = "The pool is rebuilt as you save, so an edit here applies to the next query.";
+/**
+ * Narrower than the four columns side by side: below it the table keeps URL,
+ * Enabled and the actions, and the URL cell carries the rest.
+ */
+const COMPACT = "@container (max-width: 42rem)";
+
const styles = stylex.create({
+ /** Cut to one line beside the other columns; whole, wrapping, where it is the row's only text column. */
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
- whiteSpace: "nowrap",
+ whiteSpace: { default: "nowrap", [COMPACT]: "normal" },
+ overflowWrap: "anywhere",
fontWeight: 500,
},
+ unbroken: {
+ overflowWrap: "anywhere",
+ },
+ wide: {
+ display: { default: null, [COMPACT]: "none" },
+ },
+ compact: {
+ display: { default: "none", [COMPACT]: "block" },
+ marginTop: "0.125rem",
+ color: colors.textMuted,
+ overflowWrap: "anywhere",
+ },
+ /** Stacked in the narrow form, which gives the first cell the width; the row is several lines tall there already. */
actions: {
display: "flex",
- gap: "0.75rem",
+ flexDirection: { default: null, [COMPACT]: "column" },
+ alignItems: { default: null, [COMPACT]: "flex-start" },
+ gap: { default: "0.75rem", [COMPACT]: "0.5rem" },
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
@@ -37,6 +61,17 @@ const styles = stylex.create({
},
});
+/** The hidden columns' facts for the compact URL cell; an empty TLS name has nothing to say there. */
+function UpstreamFacts({ upstream }: { upstream: Upstream }) {
+ const facts = [`Priority ${upstream.priority}`];
+ if (upstream.tls_name !== "") facts.push(`TLS name ${upstream.tls_name}`);
+ return (
+
+
+
+ );
+}
+
export default function UpstreamsTab() {
const upstreams = useQuery(upstreamsQuery());
return (
@@ -78,18 +113,21 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
URL
- Priority
+ Priority
Enabled
- TLS name
+ TLS name
{upstreams.map((u) => (
- {u.url}
- {u.priority}
+
+ {u.url}
+
+
+ {u.priority}
{String(u.enabled)}
-
+
{u.tls_name === "" ? (
empty
) : (
@@ -151,9 +189,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
URL
- Priority
+ Priority
Enabled
- TLS name
+ TLS name
Actions
@@ -166,8 +204,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
{u.url}
+
- {u.priority}
+ {u.priority}
toggleEnabled(u)}
/>
- {u.tls_name === "" ? "—" : u.tls_name}
+
+ {u.tls_name === "" ? "—" : u.tls_name}
+
Zone
-
+
Resolver
@@ -230,8 +231,20 @@ export default function ZonesTab() {
) : (
<>
- {zone.zone}
- {zone.resolver}
+
+ {zone.zone}
+
+
+
+ {zone.resolver}
+
+
+ Type
+ {record.rtype} value
+ {record.value}
+
+ TTL {record.ttl}
+ >
+ );
+}
+
+export function ZoneResolver({ zone }: { zone: ForwardZone }) {
+ return (
+
+ Resolver
+ {zone.resolver}
+
+ );
+}
diff --git a/admin/src/features/configuration/styles.ts b/admin/src/features/configuration/styles.ts
index aff53e4..5308f7e 100644
--- a/admin/src/features/configuration/styles.ts
+++ b/admin/src/features/configuration/styles.ts
@@ -77,7 +77,6 @@ export const styles = stylex.create({
},
table: {
width: "100%",
- minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
@@ -102,8 +101,10 @@ export const styles = stylex.create({
flexDirection: "column",
gap: "0.25rem",
},
+ /** A group name can be one unbroken string wider than a phone's line. */
masterLink: {
display: "block",
+ overflowWrap: "anywhere",
borderRadius: metrics.radius,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
diff --git a/admin/src/features/diagnostics/DiagnosticsPage.test.tsx b/admin/src/features/diagnostics/DiagnosticsPage.test.tsx
index 6d46931..f53afc8 100644
--- a/admin/src/features/diagnostics/DiagnosticsPage.test.tsx
+++ b/admin/src/features/diagnostics/DiagnosticsPage.test.tsx
@@ -44,7 +44,13 @@ const ACTIVE = page([
]);
const RESOLVED = page([
- event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
+ event(30, {
+ code: "disk.space",
+ component: "disk",
+ subject: "data",
+ first_seen: NOW_S - 9000,
+ resolved_at: NOW_S - 7200,
+ }),
]);
/**
@@ -165,6 +171,16 @@ test("active episodes come first, each with its title, subject, age and count",
expect(footerLine("Showing 1 resolved entry — end of history")).toBeTruthy();
});
+test("the Event cell carries the severity, subject and timing for a table too narrow for them", async () => {
+ renderRoute();
+
+ const row = (await screen.findByText("Disk space low")).closest("tr")!;
+ const event = within(row).getAllByRole("cell")[1]!;
+ expect(within(event).getByText("Warning")).toBeTruthy();
+ expect(within(event).getByText("data")).toBeTruthy();
+ expect(event.textContent).toMatch(/Resolved .+ · lasted 30m · 3 occurrences/);
+});
+
test("nothing open reads as good news, not as a broken page", async () => {
responses["/api/diagnostics?state=active"] = page([]);
renderRoute();
diff --git a/admin/src/features/diagnostics/DiagnosticsPage.tsx b/admin/src/features/diagnostics/DiagnosticsPage.tsx
index 0117c6d..797b835 100644
--- a/admin/src/features/diagnostics/DiagnosticsPage.tsx
+++ b/admin/src/features/diagnostics/DiagnosticsPage.tsx
@@ -14,6 +14,7 @@ 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 FactLine from "@/ui/FactLine";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
@@ -41,6 +42,13 @@ const COMPONENT_OPTIONS = [
...DIAGNOSTIC_COMPONENTS.map((component) => ({ value: component, label: componentLabel(component) })),
];
+/**
+ * Narrower than the seven history columns need, so the table keeps Event, which
+ * takes the severity, the subject and the timing as lines around the link, as
+ * an active card does, and the Purge action. The wrapper is the container.
+ */
+const COMPACT = "@container (max-width: 56rem)";
+
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
@@ -161,9 +169,9 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textSecondary,
},
- tableWrap: {
+ /** The shared scrolling wrapper, framed. */
+ framedTable: {
marginTop: "0.75rem",
- overflowX: "auto",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
@@ -197,6 +205,17 @@ const styles = stylex.create({
nowrap: {
whiteSpace: "nowrap",
},
+ wide: {
+ display: { default: null, [COMPACT]: "none" },
+ },
+ compactBadge: {
+ display: { default: "none", [COMPACT]: "inline-block" },
+ marginInlineEnd: "0.5rem",
+ },
+ compact: {
+ display: { default: "none", [COMPACT]: "block" },
+ marginTop: "0.125rem",
+ },
rowLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
@@ -207,6 +226,9 @@ const styles = stylex.create({
alignItems: "center",
gap: "0.75rem",
},
+ count: {
+ marginTop: "0.75rem",
+ },
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
@@ -340,10 +362,13 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
const copy = copyFor(event.code);
return (
-
+
+
+
+
{copy.title}
+ {event.subject}
+
+
+
- {event.subject}
- {formatTime(event.first_seen)}
-
+ {event.subject}
+
+ {formatTime(event.first_seen)}
+
+
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
- {formatCount(event.occurrences)}
+
+ {formatCount(event.occurrences)}
+
Nothing has failed and recovered in the retained window.
) : (
<>
-
+
- Severity
+ Severity
Event
- Subject
- Started
- Resolved
- Occurrences
+ Subject
+ Started
+ Resolved
+ Occurrences
Actions
@@ -522,7 +565,7 @@ export default function DiagnosticsPage() {
-
+
Showing{" "}
{formatCount(historyRows.length)} {" "}
resolved {historyRows.length === 1 ? "entry" : "entries"}
diff --git a/admin/src/features/diagnostics/HealthStrip.tsx b/admin/src/features/diagnostics/HealthStrip.tsx
index 44553ee..c1e2ba9 100644
--- a/admin/src/features/diagnostics/HealthStrip.tsx
+++ b/admin/src/features/diagnostics/HealthStrip.tsx
@@ -38,11 +38,11 @@ const styles = stylex.create({
gap: "0.5rem",
// Five is prime, so every count between one and five leaves a short last
// row; three columns made it 3 then 2, which reads as a layout that ran out
- // of room rather than one that chose. So the strip goes from one column
- // straight to two and then to a single row of five, and is never ragged.
+ // of room rather than one that chose. Two columns, then a single row of
+ // five. One column on a phone stacked five cards over the episodes the
+ // page exists to show.
gridTemplateColumns: {
- default: "minmax(0, 1fr)",
- "@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
+ default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
},
listStyleType: "none",
@@ -51,6 +51,8 @@ const styles = stylex.create({
fact: {
display: "flex",
flexWrap: "wrap",
+ // A card stretches to the tallest card in its row; the lines stay at the top.
+ alignContent: "flex-start",
alignItems: "baseline",
gap: "0.375rem",
borderRadius: metrics.radius,
@@ -73,8 +75,10 @@ const styles = stylex.create({
label: {
color: colors.textSecondary,
},
+ /** Below 640px a value always starts its own line, so every card has the same shape. */
value: {
fontWeight: 500,
+ flexBasis: { default: null, "@media (max-width: 639px)": "100%" },
},
detail: {
flexBasis: "100%",
diff --git a/admin/src/shell/AppShell.tsx b/admin/src/shell/AppShell.tsx
index d0e79e2..ea4b337 100644
--- a/admin/src/shell/AppShell.tsx
+++ b/admin/src/shell/AppShell.tsx
@@ -213,7 +213,14 @@ const styles = stylex.create({
paddingInline: "0.5rem",
paddingBlock: "0.5rem",
},
+ /**
+ * Positioned, so it is the containing block of every `srOnly` box on a page,
+ * such as a chart's data table. A scroll container clips only the absolutely
+ * positioned boxes it contains; the others extend the document past the
+ * shell, a second scroll under the one the page already has.
+ */
main: {
+ position: "relative",
flex: 1,
minHeight: { default: null, [WIDE]: 0 },
minWidth: { default: null, [WIDE]: 0 },
diff --git a/admin/src/styles.css b/admin/src/styles.css
index 910bf46..183404c 100644
--- a/admin/src/styles.css
+++ b/admin/src/styles.css
@@ -32,8 +32,15 @@
* chevron, and Segoe UI does not carry that glyph, so on Windows the stack
* has to reach a font that does before it falls back to a substitute box.
*/
+ /*
+ * `overflow-wrap: break-word` breaks a word only where it cannot fit its line:
+ * a group or source name is the user's text and can be one unbroken string
+ * wider than a phone. It leaves min-content alone, so a table cell or a flex
+ * item that holds such a name still needs `anywhere` of its own.
+ */
html {
line-height: 1.5;
+ overflow-wrap: break-word;
-webkit-text-size-adjust: 100%;
/* The tokens carry both schemes; this tells the UA to match its own chrome — form controls, scrollbars. */
color-scheme: light dark;
diff --git a/admin/src/ui/Checkbox.tsx b/admin/src/ui/Checkbox.tsx
index d0f89b0..fe06ec5 100644
--- a/admin/src/ui/Checkbox.tsx
+++ b/admin/src/ui/Checkbox.tsx
@@ -55,7 +55,8 @@ const styles = stylex.create({
/**
* The whole label is the hit area, so it carries the 44px pointer-target
* floor the dialog's Close button already sets, on both axes. The text is
- * 20px tall, and a one-word source name is narrower than 44px again.
+ * 20px tall, and a one-word source name is narrower than 44px again. A
+ * source name can also be one unbroken string wider than a phone's line.
*/
label: {
minHeight: 44,
@@ -65,6 +66,7 @@ const styles = stylex.create({
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
+ overflowWrap: "anywhere",
},
box: {
flexShrink: 0,
diff --git a/admin/src/ui/FactLine.tsx b/admin/src/ui/FactLine.tsx
new file mode 100644
index 0000000..eee8aa4
--- /dev/null
+++ b/admin/src/ui/FactLine.tsx
@@ -0,0 +1,25 @@
+import { Fragment, type ReactNode } from "react";
+import * as stylex from "@stylexjs/stylex";
+
+const styles = stylex.create({
+ fact: {
+ display: "inline-block",
+ },
+});
+
+/**
+ * Short facts joined by " · ". A narrow line breaks after a separator, and a
+ * fact moves to the next line whole: "1" and "occurrence" on two lines read as
+ * two facts. Only a fact wider than the whole line breaks inside itself.
+ */
+export default function FactLine({ facts }: { facts: readonly ReactNode[] }) {
+ return facts.map((fact, index) => (
+
+ {index > 0 && " "}
+
+ {fact}
+ {index < facts.length - 1 && " ·"}
+
+
+ ));
+}
diff --git a/admin/src/ui/styles.ts b/admin/src/ui/styles.ts
index c2c4080..2723a25 100644
--- a/admin/src/ui/styles.ts
+++ b/admin/src/ui/styles.ts
@@ -264,10 +264,21 @@ 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. */
+ /**
+ * Focusable: a table whose data is wider than its compact form still
+ * scrolls sideways, and a keyboard must be able to reach the scroll. It is
+ * also the size container each table's compact form queries.
+ *
+ * Positioned, so it is the containing block of the `srOnly` labels in its
+ * header cells. A scroll container clips only the absolutely positioned
+ * boxes it contains; an unclipped label at the far end of a wide table
+ * widens the whole document, and a phone then zooms the page out to fit it.
+ */
tableWrap: {
+ position: "relative",
marginTop: "1rem",
overflowX: "auto",
+ containerType: "inline-size",
},
/**
* Not a faithful port: `space-y-3` set sibling margins on a block container,