admin: tables fit a phone, their first cell carrying the hidden columns, and no positioned label widens the page

This commit is contained in:
2026-10-21 10:21:17 +02:00
parent 9a99890aea
commit f26ee523c2
29 changed files with 765 additions and 156 deletions
@@ -57,6 +57,12 @@ const PRESETS = [
const CUSTOM_ITEM = "Custom…";
/**
* Narrower than the toolbar on one row: the search field's basis, Clients, the
* result group, the widest preset's Time label and Clear, with their gaps.
*/
const ONE_ROW_DOES_NOT_FIT = "@container (max-width: 48rem)";
const styles = stylex.create({
toolbar: {
marginTop: "1rem",
@@ -167,13 +173,22 @@ const styles = stylex.create({
color: colors.primaryText,
outlineColor: { default: null, ":focus-visible": colors.primaryText },
},
/** The toolbar's controls wrap by the width the form gets, not by the viewport. */
form: {
containerType: "inline-size",
},
/**
* Clear keeps its box when there is nothing to clear. It appears the moment a
* filter is set, and a control that appeared by widening the row would move
* every other control out from under the pointer that was reaching for it.
* filter is set, and on the one row the toolbar fits on, a control that
* appeared by widening it would shrink the growing search field and move
* every other control out from under the pointer reaching for it. A toolbar
* too narrow for one row wraps the search field onto a row of its own, so
* there a Clear that appears moves nothing, and a kept box would only wrap
* onto a row of nothing above the table.
*/
clearHidden: {
visibility: "hidden",
display: { default: null, [ONE_ROW_DOES_NOT_FIT]: "none" },
},
customRow: {
marginTop: "0.5rem",
@@ -500,7 +515,7 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
}
return (
<form onSubmit={flush}>
<form onSubmit={flush} {...stylex.props(styles.form)}>
<div {...stylex.props(styles.toolbar)}>
<div {...stylex.props(styles.searchWrap)}>
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/provenance/provenanceFixture";
import { activityCell } from "./activityCell";
import { FakeEventSource } from "./fakeEventSource";
function client(id: number, ip: string, name: string, learnedName: string): Client {
@@ -185,17 +186,16 @@ test("renders the first page with the seven columns filled in", async () => {
"Route",
"Duration",
]);
const first = screen.getByText("first.example").closest("tr")!;
expect(within(first).getByText("HTTPS")).toBeTruthy();
expect(within(first).getByText("NOERROR")).toBeTruthy();
expect(within(first).getByText("Cache")).toBeTruthy();
expect(within(first).getByText("1.2 ms")).toBeTruthy();
expect(activityCell("first.example", "Type").textContent).toBe("HTTPS");
expect(activityCell("first.example", "Result").textContent).toBe("NOERROR");
expect(activityCell("first.example", "Route").textContent).toBe("Cache");
expect(activityCell("first.example", "Duration").textContent).toBe("1.2 ms");
const blocked = screen.getByText("ads.example").closest("tr")!;
// The Result cell says Blocked even though the client saw NOERROR, and the
// Route cell says how: this is the pair the old Status column could not show.
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
expect(within(blocked).getByText("—")).toBeTruthy();
expect(activityCell("ads.example", "Result").textContent).toBe("Blocked");
expect(activityCell("ads.example", "Route").textContent).toBe("Blocked");
expect(activityCell("ads.example", "Duration").textContent).toBe("—");
expect(countLine(/Showing 2 queries/)).toBeTruthy();
});
@@ -216,9 +216,10 @@ test("resolves each row's client to its display name, reading the IP out with it
});
renderPage();
await screen.findAllByText("Kitchen Pi");
// A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi");
const named = within(activityCell("named.example", "Client")).getByText("Kitchen Pi");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
@@ -227,14 +228,39 @@ test("resolves each row's client to its display name, reading the IP out with it
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan");
const learned = within(activityCell("learned.example", "Client")).getByText("laptop.lan");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with nothing standing in for it.
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(within(activityCell("stranger.example", "Client")).getByText("192.0.2.99").textContent).toBe("192.0.2.99");
});
test("the Domain cell carries the hidden columns' facts for a table too narrow for them", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [row(20, "named.example", { client_ip: "192.0.2.10" })],
next_before: null,
coverage: COMPLETE,
} satisfies QueriesPage);
});
renderPage();
await screen.findAllByText("Kitchen Pi");
const domain = activityCell("named.example", "Domain");
expect(within(domain).getByText("Kitchen Pi").textContent).toBe("Kitchen Pi (192.0.2.10)");
const facts = [
activityCell("named.example", "Time").textContent,
`Type ${activityCell("named.example", "Type").textContent}`,
activityCell("named.example", "Route").textContent,
activityCell("named.example", "Duration").textContent,
].join(" · ");
expect(domain.textContent).toContain(facts);
});
test("load more appends the next page and stops at the end of the log", async () => {
@@ -36,9 +36,8 @@ const styles = stylex.create({
loading: {
minHeight: "24rem",
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
/** The shared scrolling wrapper, framed. */
framedTable: {
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
@@ -153,7 +152,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
</div>
) : (
<>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
@@ -15,6 +15,7 @@ import { createAppRouter } from "@/routes";
import type { Client } from "@/lib/types";
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
import { health } from "@/lib/healthFixture";
import { activityCell } from "./activityCell";
import { FakeEventSource } from "./fakeEventSource";
import LiveActivity from "./LiveActivity";
import type { ActivitySearch } from "./search";
@@ -187,19 +188,20 @@ test("resolves each row's client to its display name, reading the IP out with it
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
});
const named = await screen.findByText("Kitchen Pi");
await screen.findAllByText("Kitchen Pi");
const named = within(activityCell("named.example", "Client")).getByText("Kitchen Pi");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
expect(screen.queryByText("pi.lan")).toBeNull();
const learned = screen.getByText("laptop.lan");
const learned = within(activityCell("learned.example", "Client")).getByText("laptop.lan");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(within(activityCell("stranger.example", "Client")).getByText("192.0.2.99").getAttribute("title")).toBeNull();
});
test("rows stream in as bare IPs while the client list is still loading", async () => {
@@ -222,11 +224,12 @@ test("rows stream in as bare IPs while the client list is still loading", async
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })));
expect(screen.getByText("192.0.2.10")).toBeTruthy();
expect(within(activityCell("named.example", "Client")).getByText("192.0.2.10")).toBeTruthy();
expect(screen.queryByText("Kitchen Pi")).toBeNull();
releaseClients();
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
await screen.findAllByText("Kitchen Pi");
expect(within(activityCell("named.example", "Client")).getByText("Kitchen Pi")).toBeTruthy();
});
test("repeated connection failures show the viewer-cap state with a retry button", async () => {
+3 -4
View File
@@ -128,9 +128,8 @@ const styles = stylex.create({
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
/** The shared scrolling wrapper, framed. */
framedTable: {
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
@@ -351,7 +350,7 @@ export default function LiveActivity({
)
) : (
<>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
@@ -0,0 +1,16 @@
import { screen, within } from "@testing-library/react";
import { ACTIVITY_COLUMNS } from "./cells";
/**
* Test helper: one column's cell in the row that shows `domain`. jsdom applies
* no stylesheet, so the compact Domain cell's copy of the other columns' facts
* sits in the document beside the wide cells, and a page-wide text query finds
* both.
*/
export function activityCell(domain: string, column: (typeof ACTIVITY_COLUMNS)[number]): HTMLElement {
const row = screen.getByText(domain).closest("tr");
if (row === null) throw new Error(`no row shows ${domain}`);
const cell = within(row).getAllByRole("cell")[ACTIVITY_COLUMNS.indexOf(column)];
if (cell === undefined) throw new Error(`the row for ${domain} has no ${column} cell`);
return cell;
}
+58 -11
View File
@@ -20,6 +20,7 @@ import { ClientName, type ClientNames } from "@/features/clients/clientNames";
import { rcodeShortName } from "@/features/provenance/provenanceCopy";
import { qtypeName } from "@/features/provenance/qtype";
import type { QuerySummary } from "@/features/provenance/querySummary";
import FactLine from "@/ui/FactLine";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
@@ -28,6 +29,15 @@ const DARK = "@media (prefers-color-scheme: dark)";
/** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */
const DOMAIN_MAX_WIDTH = "24rem";
/**
* Narrower than the seven columns need with ordinary data, so the table keeps
* two: Domain, which takes the other columns' facts as lines under the name,
* and Result. Hiding cells keeps a native table; re-laying its rows out as
* blocks would put the table semantics at the mercy of each browser engine.
* The wrapper that scrolls is the container.
*/
const COMPACT = "@container (max-width: 50rem)";
const styles = stylex.create({
head: {
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
@@ -52,6 +62,16 @@ const styles = stylex.create({
domainCell: {
maxWidth: DOMAIN_MAX_WIDTH,
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
/** A name can be one unbroken label as wide as the table; `anywhere` also lowers the cell's minimum width. */
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
overflowWrap: "anywhere",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
@@ -93,6 +113,7 @@ const styles = stylex.create({
*/
badge: {
display: "inline-block",
whiteSpace: "nowrap",
borderRadius: metrics.radius,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
@@ -152,6 +173,29 @@ export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" |
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
}
/** The facts the hidden columns hold, in their column order, for the compact Domain cell. */
function CompactFacts({ row, clientNames }: { row: QuerySummary; clientNames: ClientNames }) {
// The label stands in for the hidden Type header: a screen reader says a bare "A" as the article.
const qtype = (
<>
<span {...stylex.props(shared.srOnly)}>Type </span>
{qtypeName(row.qtype)}
</>
);
const facts: ReactNode[] = [formatTime(row.ts), qtype, routeLabel(row.route_kind)];
if (row.response_time_us !== null) facts.push(formatMicros(row.response_time_us));
return (
<>
<span {...stylex.props(styles.compact)}>
<ClientName ip={row.client_ip} names={clientNames} />
</span>
<span {...stylex.props(styles.compact, shared.tabularNums)}>
<FactLine facts={facts} />
</span>
</>
);
}
export function ActivityCells({
row,
clientNames,
@@ -163,22 +207,23 @@ export function ActivityCells({
}) {
return (
<>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td
title={row.domain}
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
>
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted, styles.wide)}>{formatTime(row.ts)}</td>
<td title={row.domain} {...stylex.props(styles.cell, styles.small, styles.domainCell)}>
{renderDomain(
row.id,
<span {...stylex.props(styles.domainClamp, styles.breakAll, shared.mono)}>{row.domain}</span>,
)}
<CompactFacts row={row} clientNames={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, styles.wide)}>
<ClientName ip={row.client_ip} names={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, styles.wide)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<ResultCellContent row={row} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
<td {...stylex.props(styles.cell, styles.nowrap, styles.wide)}>{routeLabel(row.route_kind)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
</td>
</>
@@ -187,12 +232,14 @@ export function ActivityCells({
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
const COMPACT_COLUMNS: ReadonlySet<string> = new Set(["Domain", "Result"]);
export function ActivityTableHead() {
return (
<thead {...stylex.props(styles.head)}>
<tr>
{ACTIVITY_COLUMNS.map((column) => (
<th key={column} {...stylex.props(styles.th)}>
<th key={column} {...stylex.props(styles.th, !COMPACT_COLUMNS.has(column) && styles.wide)}>
{column}
</th>
))}
@@ -42,21 +42,42 @@ afterEach(() => {
vi.unstubAllGlobals();
});
const CLIENT_COLUMNS = ["Address", "Name", "Group", "First seen", "Last seen", "Actions"] as const;
/**
* One column's cell in the row for `ip`. jsdom applies no stylesheet, so the
* compact Address cell's copy of the name and group sits beside the wide cells,
* and a page-wide text query finds both.
*/
function clientCell(ip: string, column: (typeof CLIENT_COLUMNS)[number]): HTMLElement {
const row = screen.getByText(ip).closest("tr")!;
return within(row).getAllByRole("cell")[CLIENT_COLUMNS.indexOf(column)]!;
}
test("renders the identity-first table: address, name, group, first and last seen", async () => {
await renderClientsPage();
const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]);
expect(headers).toEqual([...CLIENT_COLUMNS]);
expect(screen.getByText("192.168.1.10")).toBeTruthy();
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("laptop")).toBeTruthy();
expect(within(clientCell("192.168.1.10", "Name")).getByText("laptop")).toBeTruthy();
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
});
test("the Address cell carries the name, group and last seen for a table too narrow for them", async () => {
await renderClientsPage();
const address = clientCell("192.168.1.10", "Address");
expect(within(address).getByText("laptop")).toBeTruthy();
expect(address.textContent).toContain("· default");
expect(address.textContent).toContain(`Last seen ${clientCell("192.168.1.10", "Last seen").textContent}`);
});
test("a named row shows the typed name and hides the learned one", async () => {
await renderClientsPage();
expect(screen.getByText("laptop")).toBeTruthy();
expect(within(clientCell("192.168.1.10", "Name")).getByText("laptop")).toBeTruthy();
expect(screen.queryByText("laptop-1.lan")).toBeNull();
});
@@ -65,7 +86,7 @@ test("an unnamed row shows the learned name with the learned marker", async () =
// The cell holds the learned name followed by the marker, so the match is on
// the containing span rather than on a bare text node.
const learned = screen.getByText(
const learned = within(clientCell("192.168.1.11", "Name")).getByText(
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
);
// The marker is text, not colour, so a screen reader announces it too.
+41 -11
View File
@@ -45,6 +45,13 @@ function clientLabel(client: Client): string {
return name === "" ? client.ip : `${name} (${client.ip})`;
}
/**
* Narrower than the six columns need, so the table keeps Address, which takes
* the name, group and last-seen time as lines under the link, and the actions.
* First seen stays on the client's own page. The wrapper is the container.
*/
const COMPACT = "@container (max-width: 50rem)";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
@@ -84,7 +91,6 @@ const styles = stylex.create({
},
table: {
width: "100%",
minWidth: "48rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
@@ -107,9 +113,24 @@ const styles = stylex.create({
right: {
textAlign: "right",
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
/** A name can be one unbroken label as wide as the table; `anywhere` also lowers the cell's minimum width. */
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
overflowWrap: "anywhere",
},
compactName: {
color: colors.text,
},
/** A full IPv6 address is 39 characters of mono, wider than a phone's Address column. */
addressLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
overflowWrap: "anywhere",
},
actionGroup: {
display: "inline-flex",
@@ -190,10 +211,10 @@ export default function ClientsPage() {
<thead>
<tr {...stylex.props(styles.headRow)}>
<th {...stylex.props(styles.cell)}>Address</th>
<th {...stylex.props(styles.cell)}>Name</th>
<th {...stylex.props(styles.cell)}>Group</th>
<th {...stylex.props(styles.cell)}>First seen</th>
<th {...stylex.props(styles.cell)}>Last seen</th>
<th {...stylex.props(styles.cell, styles.wide)}>Name</th>
<th {...stylex.props(styles.cell, styles.wide)}>Group</th>
<th {...stylex.props(styles.cell, styles.wide)}>First seen</th>
<th {...stylex.props(styles.cell, styles.wide)}>Last seen</th>
<th {...stylex.props(styles.cell)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
@@ -202,23 +223,32 @@ export default function ClientsPage() {
<tbody>
{rows.map((client) => (
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>
<td {...stylex.props(styles.cell)}>
<Link
to="/clients/$id"
params={{ id: String(client.id) }}
{...stylex.props(styles.addressLink, shared.focusRing)}
{...stylex.props(styles.addressLink, shared.mono, shared.focusRing)}
>
{client.ip}
</Link>
<span {...stylex.props(styles.compact)}>
<span {...stylex.props(styles.compactName)}>
<ClientDisplayName client={client} />
</span>{" "}
· {client.group}
</span>
<span {...stylex.props(styles.compact)}>
Last seen {formatTime(client.last_seen)}
</span>
</td>
<td {...stylex.props(styles.cell)}>
<td {...stylex.props(styles.cell, styles.wide)}>
<span {...stylex.props(styles.name)}>
<ClientDisplayName client={client} />
</span>
</td>
<td {...stylex.props(styles.cell)}>{client.group}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.wide)}>{client.group}</td>
<td {...stylex.props(styles.cell, styles.wide)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell, styles.wide)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.right)}>
<span {...stylex.props(styles.actionGroup)}>
{/* Naming a client writes configuration, so the affordance is
@@ -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[] }) {
<tbody>
{prefixes.map((prefix) => (
<tr key={prefix.id}>
<td {...stylex.props(shared.td, shared.mono)}>{prefix.prefix}</td>
<td {...stylex.props(shared.td)}>{prefix.group}</td>
<td {...stylex.props(shared.td, shared.mono, styles.unbroken)}>{prefix.prefix}</td>
<td {...stylex.props(shared.td, styles.unbroken)}>{prefix.group}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{prefix.priority}</td>
</tr>
))}
@@ -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[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>Type</th>
<th {...stylex.props(shared.th)}>Value</th>
<th {...stylex.props(shared.th)}>TTL</th>
<th {...stylex.props(shared.th, local.wide)}>Type</th>
<th {...stylex.props(shared.th, local.wide)}>Value</th>
<th {...stylex.props(shared.th, local.wide)}>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.id}>
<td {...stylex.props(shared.td, shared.mono)}>{record.name}</td>
<td {...stylex.props(shared.td)}>{record.rtype}</td>
<td {...stylex.props(shared.td, shared.mono)}>{record.value}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{record.ttl}</td>
<td {...stylex.props(shared.td, local.unbroken)}>
<span {...stylex.props(shared.mono)}>{record.name}</span>
<RecordFacts record={record} />
</td>
<td {...stylex.props(shared.td, local.wide)}>{record.rtype}</td>
<td {...stylex.props(shared.td, shared.mono, local.unbroken, local.wide)}>
{record.value}
</td>
<td {...stylex.props(shared.td, shared.tabularNums, local.wide)}>{record.ttl}</td>
</tr>
))}
</tbody>
@@ -66,14 +72,19 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Zone</th>
<th {...stylex.props(shared.th)}>Resolver</th>
<th {...stylex.props(shared.th, local.wide)}>Resolver</th>
</tr>
</thead>
<tbody>
{zones.map((zone) => (
<tr key={zone.id}>
<td {...stylex.props(shared.td, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(shared.td, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(shared.td, shared.mono, local.unbroken)}>
{zone.zone}
<ZoneResolver zone={zone} />
</td>
<td {...stylex.props(shared.td, shared.mono, local.unbroken, local.wide)}>
{zone.resolver}
</td>
</tr>
))}
</tbody>
@@ -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 <span {...stylex.props(action === "allow" ? styles.allow : styles.block)}>{action}</span>;
}
function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) {
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>;
@@ -284,14 +315,17 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo
<thead>
<tr>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
</tr>
</thead>
<tbody>
{assigned.map((source) => (
<tr key={source.id}>
<td {...stylex.props(shared.td)}>{source.name}</td>
<td {...stylex.props(shared.td, shared.mono)}>{source.url}</td>
<td {...stylex.props(shared.td, styles.unbroken)}>
{source.name}
<span {...stylex.props(styles.compact, shared.mono, styles.urlText)}>{source.url}</span>
</td>
<td {...stylex.props(shared.td, shared.mono, styles.urlText, styles.wide)}>{source.url}</td>
</tr>
))}
</tbody>
@@ -471,9 +505,9 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Created</th>
<th {...stylex.props(shared.th, styles.wide)}>Kind</th>
<th {...stylex.props(shared.th, styles.wide)}>Action</th>
<th {...stylex.props(shared.th, styles.wide)}>Created</th>
{editable && (
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
@@ -484,14 +518,23 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
<td {...stylex.props(shared.td, styles.pattern)}>
{rule.pattern}
<span {...stylex.props(styles.compact)}>
<FactLine
facts={[
<ActionLabel key="action" action={rule.action} />,
rule.kind,
`Created ${formatTime(rule.created_at)}`,
]}
/>
</span>
</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td {...stylex.props(shared.td, styles.wide)}>{rule.kind}</td>
<td {...stylex.props(shared.td, styles.wide)}>
<ActionLabel action={rule.action} />
</td>
<td {...stylex.props(shared.td, styles.wide)}>{formatTime(rule.created_at)}</td>
{editable && (
<td {...stylex.props(shared.td)}>
<button
@@ -1,4 +1,5 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { formatTime } from "@/lib/format";
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
/**
@@ -23,6 +24,15 @@ function writes(method: string): Call[] {
return calls.filter((call) => 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");
@@ -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 (
<>
<span {...stylex.props(styles.compact, shared.mono, styles.urlText)}>{source.url}</span>
<span {...stylex.props(styles.compact, shared.tabularNums)}>
<FactLine
facts={[
counted(source.domain_count, "domain", "domains"),
counted(source.wildcard_count, "wildcard", "wildcards"),
counted(source.exception_count, "exception", "exceptions"),
]}
/>
</span>
<span {...stylex.props(styles.compact, shared.tabularNums)}>
{source.skipped_regex_count === 0 && source.skipped_unsupported_count === 0 ? (
"Nothing skipped"
) : (
<FactLine
facts={[
`${formatCount(source.skipped_regex_count)} skipped regex`,
`${formatCount(source.skipped_unsupported_count)} skipped unsupported`,
]}
/>
)}
</span>
<span {...stylex.props(styles.compact)}>
{source.last_updated === null ? "Never updated" : `Updated ${formatTime(source.last_updated)}`}
</span>
</>
);
}
/**
* 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[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th, styles.wide)}>Domains</th>
<th {...stylex.props(shared.th, styles.wide)}>Wildcards</th>
<th {...stylex.props(shared.th, styles.wide)}>Exceptions</th>
<th {...stylex.props(shared.th, styles.wide)}>Skipped regex</th>
<th {...stylex.props(shared.th, styles.wide)}>Skipped unsupported</th>
<th {...stylex.props(shared.th, styles.wide)}>Last updated</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.unbroken)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
<SourceFacts source={b} />
</td>
<td {...stylex.props(shared.td, shared.mono, styles.urlText, styles.wide)}>
{b.url}
</td>
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</td>
<td {...stylex.props(shared.td)}>{String(b.enabled)}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.domain_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.wildcard_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.exception_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.skipped_regex_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.skipped_unsupported_count)}
</td>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.wide)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
</tr>
@@ -235,14 +303,14 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th, styles.wide)}>Domains</th>
<th {...stylex.props(shared.th, styles.wide)}>Wildcards</th>
<th {...stylex.props(shared.th, styles.wide)}>Exceptions</th>
<th {...stylex.props(shared.th, styles.wide)}>Skipped regex</th>
<th {...stylex.props(shared.th, styles.wide)}>Skipped unsupported</th>
<th {...stylex.props(shared.th, styles.wide)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
@@ -251,11 +319,12 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.unbroken)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
<SourceFacts source={b} />
</td>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.wide)}>
<span {...stylex.props(styles.url)} title={b.url}>
{b.url}
</span>
@@ -268,22 +337,22 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
onChange={() => toggleEnabled(b)}
/>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.domain_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.wildcard_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.exception_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.skipped_regex_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.skipped_unsupported_count)}
</td>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.wide)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td {...stylex.props(shared.td)}>
@@ -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() {
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
Type
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
Value
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
TTL
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
@@ -266,10 +267,22 @@ export default function RecordsTab() {
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.cell, local.unbroken)}>
<span {...stylex.props(shared.mono)}>{record.name}</span>
<RecordFacts record={record} />
</td>
<td {...stylex.props(styles.cell, local.wide)}>{record.rtype}</td>
<td
{...stylex.props(
styles.cell,
shared.mono,
local.unbroken,
local.wide,
)}
>
{record.value}
</td>
<td {...stylex.props(styles.cell, local.wide)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
@@ -1,5 +1,5 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { DATABASE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
import { DATABASE, MANAGED_FILE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
/**
* Resolution in database mode: the upstream pool, the local records and the
@@ -22,6 +22,15 @@ async function openResolution(tab?: string, options: Parameters<typeof stubApi>[
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");
@@ -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 (
<span {...stylex.props(styles.compact, shared.tabularNums)}>
<FactLine facts={facts} />
</span>
);
}
export default function UpstreamsTab() {
const upstreams = useQuery(upstreamsQuery());
return (
@@ -78,18 +113,21 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th, styles.wide)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
<th {...stylex.props(shared.th, styles.wide)}>TLS name</th>
</tr>
</thead>
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td {...stylex.props(shared.td, shared.mono)}>{u.url}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td, styles.unbroken)}>
<span {...stylex.props(shared.mono)}>{u.url}</span>
<UpstreamFacts upstream={u} />
</td>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>{String(u.enabled)}</td>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.unbroken, styles.wide)}>
{u.tls_name === "" ? (
<span {...stylex.props(styles.absent)}>empty</span>
) : (
@@ -151,9 +189,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th, styles.wide)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
<th {...stylex.props(shared.th, styles.wide)}>TLS name</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
@@ -166,8 +204,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
<span {...stylex.props(styles.url)} title={u.url}>
{u.url}
</span>
<UpstreamFacts upstream={u} />
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<Switch
aria-label={`${u.url} enabled`}
@@ -176,7 +215,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
onChange={() => toggleEnabled(u)}
/>
</td>
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td {...stylex.props(shared.td, styles.unbroken, styles.wide)}>
{u.tls_name === "" ? "—" : u.tls_name}
</td>
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
+16 -3
View File
@@ -11,6 +11,7 @@ import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import { local, ZoneResolver } from "./localCells";
import QueryPanel from "./QueryPanel";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -200,7 +201,7 @@ export default function ZonesTab() {
<th scope="col" {...stylex.props(styles.headCell)}>
Zone
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
Resolver
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
@@ -230,8 +231,20 @@ export default function ZonesTab() {
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.cell, shared.mono, local.unbroken)}>
{zone.zone}
<ZoneResolver zone={zone} />
</td>
<td
{...stylex.props(
styles.cell,
shared.mono,
local.unbroken,
local.wide,
)}
>
{zone.resolver}
</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
@@ -0,0 +1,56 @@
import * as stylex from "@stylexjs/stylex";
import type { ForwardZone, LocalRecord } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
/**
* The compact forms of the local-records and forward-zones tables, for both
* the editable tabs and their file-mode renderings. Below the width a record's
* four columns, or a zone beside its resolver, need, a table keeps its first
* column and the actions, and the first cell carries the rest. The scrolling
* wrapper is the container.
*/
const COMPACT = "@container (max-width: 28rem)";
export const local = stylex.create({
/** A name, a value or a resolver can be one unbroken string as wide as the table. */
unbroken: {
overflowWrap: "anywhere",
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
overflowWrap: "anywhere",
},
});
/**
* The answer, then how long it is cached. The labels stand in for the hidden
* headers: a screen reader says a bare "A" as the article, and a CNAME's value
* is a second name beside the record's own.
*/
export function RecordFacts({ record }: { record: LocalRecord }) {
return (
<>
<span {...stylex.props(local.compact)}>
<span {...stylex.props(shared.srOnly)}>Type </span>
{record.rtype} <span {...stylex.props(shared.srOnly)}>value </span>
<span {...stylex.props(shared.mono)}>{record.value}</span>
</span>
<span {...stylex.props(local.compact, shared.tabularNums)}>TTL {record.ttl}</span>
</>
);
}
export function ZoneResolver({ zone }: { zone: ForwardZone }) {
return (
<span {...stylex.props(local.compact)}>
<span {...stylex.props(shared.srOnly)}>Resolver </span>
<span {...stylex.props(shared.mono)}>{zone.resolver}</span>
</span>
);
}
+2 -1
View File
@@ -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",
@@ -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();
@@ -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 (
<tr {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell)}>
<td {...stylex.props(styles.cell, styles.wide)}>
<SeverityBadge severity={event.severity} />
</td>
<td {...stylex.props(styles.cell)}>
<span {...stylex.props(styles.compactBadge)}>
<SeverityBadge severity={event.severity} />
</span>
<Link
to="/diagnostics/$id"
params={{ id: String(event.id) }}
@@ -351,13 +376,31 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
>
{copy.title}
</Link>
<span {...stylex.props(styles.compact, styles.subject)}>{event.subject}</span>
<span {...stylex.props(styles.compact, styles.meta, shared.tabularNums)}>
<FactLine
facts={[
...(event.resolved_at === null
? [`Started ${formatTime(event.first_seen)}`]
: [
`Resolved ${formatTime(event.resolved_at)}`,
`lasted ${formatDuration(event.resolved_at - event.first_seen)}`,
]),
occurrenceText(event.occurrences),
]}
/>
</span>
</td>
<td {...stylex.props(styles.cell)}>{event.subject}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatTime(event.first_seen)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
<td {...stylex.props(styles.cell, styles.wide)}>{event.subject}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
{formatTime(event.first_seen)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
{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, styles.wide)}>
{formatCount(event.occurrences)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<button
type="button"
@@ -495,16 +538,16 @@ 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 tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<thead {...stylex.props(styles.head)}>
<tr>
<th {...stylex.props(styles.th)}>Severity</th>
<th {...stylex.props(styles.th, styles.wide)}>Severity</th>
<th {...stylex.props(styles.th)}>Event</th>
<th {...stylex.props(styles.th)}>Subject</th>
<th {...stylex.props(styles.th)}>Started</th>
<th {...stylex.props(styles.th)}>Resolved</th>
<th {...stylex.props(styles.th)}>Occurrences</th>
<th {...stylex.props(styles.th, styles.wide)}>Subject</th>
<th {...stylex.props(styles.th, styles.wide)}>Started</th>
<th {...stylex.props(styles.th, styles.wide)}>Resolved</th>
<th {...stylex.props(styles.th, styles.wide)}>Occurrences</th>
<th {...stylex.props(styles.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
@@ -522,7 +565,7 @@ export default function DiagnosticsPage() {
</tbody>
</table>
</div>
<p {...stylex.props(styles.footer, styles.note)}>
<p {...stylex.props(styles.count, styles.note)}>
Showing{" "}
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "}
resolved {historyRows.length === 1 ? "entry" : "entries"}
@@ -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%",
+7
View File
@@ -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 },
+7
View File
@@ -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;
+3 -1
View File
@@ -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,
+25
View File
@@ -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) => (
<Fragment key={index}>
{index > 0 && " "}
<span {...stylex.props(styles.fact)}>
{fact}
{index < facts.length - 1 && " ·"}
</span>
</Fragment>
));
}
+12 -1
View File
@@ -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,