4 Commits
Author SHA1 Message Date
mokhtar 77ae1a189d ci: limit the frontend job to 8 minutes
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 7m14s
Release / guard (push) Successful in 38s
Gates / frontend (push) Successful in 2m30s
Gates / test (push) Successful in 2m38s
Gates / test-aarch64 (push) Successful in 7m43s
Gates / package (push) Successful in 46s
Gates / container (push) Successful in 18s
Release / gates (push) Successful in 11m29s
Release / publish (push) Successful in 5m19s
Gates / test (push) Successful in 2m24s
Gates / test-aarch64 (push) Successful in 7m45s
Gates / frontend (push) Successful in 2m4s
Gates / package (push) Successful in 4m44s
2026-10-21 13:37:33 +02:00
mokhtar a0d8af3170 build: bump version to 0.0.24
Gates / test (push) Successful in 2m23s
Gates / test-aarch64 (push) Successful in 7m54s
Gates / package (push) Canceled after 0s
Gates / container (push) Canceled after 0s
Gates / frontend (push) Canceled after 1h33m1s
CI / gates (push) Canceled after 1h32m56s
2026-10-21 11:02:17 +02:00
mokhtar 3e8cd0ea88 changelog: date the 0.0.24 section 2026-10-21 11:00:15 +02:00
mokhtar f26ee523c2 admin: tables fit a phone, their first cell carrying the hidden columns, and no positioned label widens the page 2026-10-21 10:21:17 +02:00
33 changed files with 774 additions and 161 deletions
+4
View File
@@ -126,6 +126,10 @@ jobs:
# 1536Mi dind limit, and it runs no docker command — so it overlaps the
# heavy runner's zig and image work instead of queueing behind it.
runs-on: ubuntu-24.04-light
# The slowest green run took 4 minutes. A stalled Node download leaves setup-node
# holding an open socket after its retry succeeds, so the step never exits:
# without a limit the job held the runner for over an hour.
timeout-minutes: 8
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+9
View File
@@ -4,6 +4,15 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [0.0.24] - 2026-10-21
### Fixed
- **The admin pages fit a phone.** At 390 px, Activity, Clients, Diagnostics and every configuration table scrolled sideways, and Diagnostics and Clients made the whole page 826 px and 660 px wide, so the phone zoomed out. A table now shows all its columns only where they fit without wrapping. In a narrower table, the first cell carries the other columns' facts on short lines ("Priority 20 · TLS name dns.quad9.net", "Created 19 Oct 2026, 16:15:44"), and long names, URLs, IPv6 addresses and patterns wrap instead of widening the page. In the narrow Sources and Upstreams tables, Edit and Delete stack, which gives the first cell the width. Protection > Sources needs a 1440 px wide table for all its columns, so a 1440 px window now shows its narrow form; in 0.0.23 it made the page 1557 px wide there.
- **Overview is no longer taller than the window.** The charts' screen-reader tables extended the document below the app, so the page had a second scroll bar under the main one at desktop widths.
- **The Diagnostics count line no longer spreads its words across the page.**
- **The Diagnostics health cards no longer spread a short card's lines down its height.**
## [0.0.23] - 2026-10-20
### Fixed
@@ -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,
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.23",
.version = "0.0.24",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+1 -1
View File
@@ -5,7 +5,7 @@ services:
# from a tag runs that tag's image. NXDNS_VERSION picks another release. To
# run a locally built image instead, build it first (see
# deploy/docker/Dockerfile) and set NXDNS_IMAGE=nxdns.
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-0.0.23}}
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-0.0.24}}
restart: unless-stopped
# `run --config` makes the file the sole source of configuration: nxdns
# reconciles the database onto ./etc-nxdns/config.zon at every start, and
+3 -3
View File
@@ -8,10 +8,10 @@
let
# `zig build cut` rewrites the lines between the delimiters by text replacement.
# BEGIN GENERATED BY zig build cut
version = "0.0.23";
version = "0.0.24";
hashes = {
"aarch64-linux" = "sha256-VjvjI8bvFgHDpUcjOqcNysw4RQchCqVNLH78+DMUgFU=";
"x86_64-linux" = "sha256-JV/6gbCc2Ewl6SxpnDuL5Z6ivCh6zNymLtgfUhnYUq8=";
"aarch64-linux" = "sha256-nsjFtsFlUJHp3AJj7ccrXmgBq1QK2tRAvmdofeTqc2E=";
"x86_64-linux" = "sha256-VxDq+UiOWPO4Wyhwdgky1X0QVAqeDgAqD7KeqbTLOJ8=";
};
# END GENERATED BY zig build cut