admin: tables fit a phone, their first cell carrying the hidden columns, and no positioned label widens the page
This commit is contained in:
@@ -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.
|
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.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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
|
## [0.0.23] - 2026-10-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ const PRESETS = [
|
|||||||
|
|
||||||
const CUSTOM_ITEM = "Custom…";
|
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({
|
const styles = stylex.create({
|
||||||
toolbar: {
|
toolbar: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
@@ -167,13 +173,22 @@ const styles = stylex.create({
|
|||||||
color: colors.primaryText,
|
color: colors.primaryText,
|
||||||
outlineColor: { default: null, ":focus-visible": 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
|
* 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
|
* filter is set, and on the one row the toolbar fits on, a control that
|
||||||
* every other control out from under the pointer that was reaching for it.
|
* 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: {
|
clearHidden: {
|
||||||
visibility: "hidden",
|
visibility: "hidden",
|
||||||
|
display: { default: null, [ONE_ROW_DOES_NOT_FIT]: "none" },
|
||||||
},
|
},
|
||||||
customRow: {
|
customRow: {
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
@@ -500,7 +515,7 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={flush}>
|
<form onSubmit={flush} {...stylex.props(styles.form)}>
|
||||||
<div {...stylex.props(styles.toolbar)}>
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
<div {...stylex.props(styles.searchWrap)}>
|
<div {...stylex.props(styles.searchWrap)}>
|
||||||
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
|
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
|
|||||||
import { health } from "@/lib/healthFixture";
|
import { health } from "@/lib/healthFixture";
|
||||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||||
|
import { activityCell } from "./activityCell";
|
||||||
import { FakeEventSource } from "./fakeEventSource";
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
|
|
||||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
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",
|
"Route",
|
||||||
"Duration",
|
"Duration",
|
||||||
]);
|
]);
|
||||||
const first = screen.getByText("first.example").closest("tr")!;
|
expect(activityCell("first.example", "Type").textContent).toBe("HTTPS");
|
||||||
expect(within(first).getByText("HTTPS")).toBeTruthy();
|
expect(activityCell("first.example", "Result").textContent).toBe("NOERROR");
|
||||||
expect(within(first).getByText("NOERROR")).toBeTruthy();
|
expect(activityCell("first.example", "Route").textContent).toBe("Cache");
|
||||||
expect(within(first).getByText("Cache")).toBeTruthy();
|
expect(activityCell("first.example", "Duration").textContent).toBe("1.2 ms");
|
||||||
expect(within(first).getByText("1.2 ms")).toBeTruthy();
|
|
||||||
|
|
||||||
const blocked = screen.getByText("ads.example").closest("tr")!;
|
|
||||||
// The Result cell says Blocked even though the client saw NOERROR, and the
|
// 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.
|
// Route cell says how: this is the pair the old Status column could not show.
|
||||||
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
expect(activityCell("ads.example", "Result").textContent).toBe("Blocked");
|
||||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
expect(activityCell("ads.example", "Route").textContent).toBe("Blocked");
|
||||||
|
expect(activityCell("ads.example", "Duration").textContent).toBe("—");
|
||||||
expect(countLine(/Showing 2 queries/)).toBeTruthy();
|
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();
|
renderPage();
|
||||||
|
await screen.findAllByText("Kitchen Pi");
|
||||||
|
|
||||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
// 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
|
// The address reads out with the name it replaced, rather than sitting in a
|
||||||
// title only a mouse can reach.
|
// title only a mouse can reach.
|
||||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
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
|
// 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.
|
// 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(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||||
|
|
||||||
// A known client with neither name, and a client the loaded list has never
|
// 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.
|
// 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(within(activityCell("nameless.example", "Client")).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("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 () => {
|
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: {
|
loading: {
|
||||||
minHeight: "24rem",
|
minHeight: "24rem",
|
||||||
},
|
},
|
||||||
tableWrap: {
|
/** The shared scrolling wrapper, framed. */
|
||||||
marginTop: "1rem",
|
framedTable: {
|
||||||
overflowX: "auto",
|
|
||||||
borderRadius: metrics.radius,
|
borderRadius: metrics.radius,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -153,7 +152,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
|
|||||||
</div>
|
</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)}>
|
<table {...stylex.props(styles.table)}>
|
||||||
<ActivityTableHead />
|
<ActivityTableHead />
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createAppRouter } from "@/routes";
|
|||||||
import type { Client } from "@/lib/types";
|
import type { Client } from "@/lib/types";
|
||||||
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
|
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
|
||||||
import { health } from "@/lib/healthFixture";
|
import { health } from "@/lib/healthFixture";
|
||||||
|
import { activityCell } from "./activityCell";
|
||||||
import { FakeEventSource } from "./fakeEventSource";
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
import LiveActivity from "./LiveActivity";
|
import LiveActivity from "./LiveActivity";
|
||||||
import type { ActivitySearch } from "./search";
|
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" } }));
|
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
|
// The address reads out with the name it replaced, rather than sitting in a
|
||||||
// title only a mouse can reach.
|
// title only a mouse can reach.
|
||||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||||
expect(named.getAttribute("title")).toBeNull();
|
expect(named.getAttribute("title")).toBeNull();
|
||||||
expect(screen.queryByText("pi.lan")).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(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||||
|
|
||||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||||
expect(screen.getByText("192.0.2.99").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 () => {
|
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();
|
await openLive();
|
||||||
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })));
|
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();
|
expect(screen.queryByText("Kitchen Pi")).toBeNull();
|
||||||
|
|
||||||
releaseClients();
|
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 () => {
|
test("repeated connection failures show the viewer-cap state with a retry button", async () => {
|
||||||
|
|||||||
@@ -128,9 +128,8 @@ const styles = stylex.create({
|
|||||||
marginTop: "1.5rem",
|
marginTop: "1.5rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
tableWrap: {
|
/** The shared scrolling wrapper, framed. */
|
||||||
marginTop: "1rem",
|
framedTable: {
|
||||||
overflowX: "auto",
|
|
||||||
borderRadius: metrics.radius,
|
borderRadius: metrics.radius,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
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)}>
|
<table {...stylex.props(styles.table)}>
|
||||||
<ActivityTableHead />
|
<ActivityTableHead />
|
||||||
<tbody>
|
<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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
|||||||
import { rcodeShortName } from "@/features/provenance/provenanceCopy";
|
import { rcodeShortName } from "@/features/provenance/provenanceCopy";
|
||||||
import { qtypeName } from "@/features/provenance/qtype";
|
import { qtypeName } from "@/features/provenance/qtype";
|
||||||
import type { QuerySummary } from "@/features/provenance/querySummary";
|
import type { QuerySummary } from "@/features/provenance/querySummary";
|
||||||
|
import FactLine from "@/ui/FactLine";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
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. */
|
/** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */
|
||||||
const DOMAIN_MAX_WIDTH = "24rem";
|
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({
|
const styles = stylex.create({
|
||||||
head: {
|
head: {
|
||||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||||
@@ -52,6 +62,16 @@ const styles = stylex.create({
|
|||||||
domainCell: {
|
domainCell: {
|
||||||
maxWidth: DOMAIN_MAX_WIDTH,
|
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: {
|
small: {
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
lineHeight: "1rem",
|
lineHeight: "1rem",
|
||||||
@@ -93,6 +113,7 @@ const styles = stylex.create({
|
|||||||
*/
|
*/
|
||||||
badge: {
|
badge: {
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
borderRadius: metrics.radius,
|
borderRadius: metrics.radius,
|
||||||
paddingInline: "0.375rem",
|
paddingInline: "0.375rem",
|
||||||
paddingBlock: "0.125rem",
|
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>;
|
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({
|
export function ActivityCells({
|
||||||
row,
|
row,
|
||||||
clientNames,
|
clientNames,
|
||||||
@@ -163,22 +207,23 @@ export function ActivityCells({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted, styles.wide)}>{formatTime(row.ts)}</td>
|
||||||
<td
|
<td title={row.domain} {...stylex.props(styles.cell, styles.small, styles.domainCell)}>
|
||||||
title={row.domain}
|
{renderDomain(
|
||||||
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
|
row.id,
|
||||||
>
|
<span {...stylex.props(styles.domainClamp, styles.breakAll, shared.mono)}>{row.domain}</span>,
|
||||||
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
|
)}
|
||||||
|
<CompactFacts row={row} clientNames={clientNames} />
|
||||||
</td>
|
</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} />
|
<ClientName ip={row.client_ip} names={clientNames} />
|
||||||
</td>
|
</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)}>
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||||
<ResultCellContent row={row} />
|
<ResultCellContent row={row} />
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap, styles.wide)}>{routeLabel(row.route_kind)}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
|
||||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
@@ -187,12 +232,14 @@ export function ActivityCells({
|
|||||||
|
|
||||||
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
|
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() {
|
export function ActivityTableHead() {
|
||||||
return (
|
return (
|
||||||
<thead {...stylex.props(styles.head)}>
|
<thead {...stylex.props(styles.head)}>
|
||||||
<tr>
|
<tr>
|
||||||
{ACTIVITY_COLUMNS.map((column) => (
|
{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}
|
{column}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -42,21 +42,42 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
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 () => {
|
test("renders the identity-first table: address, name, group, first and last seen", async () => {
|
||||||
await renderClientsPage();
|
await renderClientsPage();
|
||||||
|
|
||||||
const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
|
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.10")).toBeTruthy();
|
||||||
expect(screen.getByText("192.168.1.11")).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);
|
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 () => {
|
test("a named row shows the typed name and hides the learned one", async () => {
|
||||||
await renderClientsPage();
|
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();
|
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 cell holds the learned name followed by the marker, so the match is on
|
||||||
// the containing span rather than on a bare text node.
|
// 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"),
|
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
|
||||||
);
|
);
|
||||||
// The marker is text, not colour, so a screen reader announces it too.
|
// The marker is text, not colour, so a screen reader announces it too.
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ function clientLabel(client: Client): string {
|
|||||||
return name === "" ? client.ip : `${name} (${client.ip})`;
|
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({
|
const styles = stylex.create({
|
||||||
heading: {
|
heading: {
|
||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
@@ -84,7 +91,6 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minWidth: "48rem",
|
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
@@ -107,9 +113,24 @@ const styles = stylex.create({
|
|||||||
right: {
|
right: {
|
||||||
textAlign: "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: {
|
addressLink: {
|
||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
},
|
},
|
||||||
actionGroup: {
|
actionGroup: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
@@ -190,10 +211,10 @@ export default function ClientsPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr {...stylex.props(styles.headRow)}>
|
<tr {...stylex.props(styles.headRow)}>
|
||||||
<th {...stylex.props(styles.cell)}>Address</th>
|
<th {...stylex.props(styles.cell)}>Address</th>
|
||||||
<th {...stylex.props(styles.cell)}>Name</th>
|
<th {...stylex.props(styles.cell, styles.wide)}>Name</th>
|
||||||
<th {...stylex.props(styles.cell)}>Group</th>
|
<th {...stylex.props(styles.cell, styles.wide)}>Group</th>
|
||||||
<th {...stylex.props(styles.cell)}>First seen</th>
|
<th {...stylex.props(styles.cell, styles.wide)}>First seen</th>
|
||||||
<th {...stylex.props(styles.cell)}>Last seen</th>
|
<th {...stylex.props(styles.cell, styles.wide)}>Last seen</th>
|
||||||
<th {...stylex.props(styles.cell)}>
|
<th {...stylex.props(styles.cell)}>
|
||||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -202,23 +223,32 @@ export default function ClientsPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((client) => (
|
{rows.map((client) => (
|
||||||
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>
|
<td {...stylex.props(styles.cell)}>
|
||||||
<Link
|
<Link
|
||||||
to="/clients/$id"
|
to="/clients/$id"
|
||||||
params={{ id: String(client.id) }}
|
params={{ id: String(client.id) }}
|
||||||
{...stylex.props(styles.addressLink, shared.focusRing)}
|
{...stylex.props(styles.addressLink, shared.mono, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{client.ip}
|
{client.ip}
|
||||||
</Link>
|
</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>
|
||||||
<td {...stylex.props(styles.cell)}>
|
<td {...stylex.props(styles.cell, styles.wide)}>
|
||||||
<span {...stylex.props(styles.name)}>
|
<span {...stylex.props(styles.name)}>
|
||||||
<ClientDisplayName client={client} />
|
<ClientDisplayName client={client} />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
<td {...stylex.props(styles.cell, styles.wide)}>{client.group}</td>
|
||||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
<td {...stylex.props(styles.cell, styles.wide)}>{formatTime(client.first_seen)}</td>
|
||||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
<td {...stylex.props(styles.cell, styles.wide)}>{formatTime(client.last_seen)}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.right)}>
|
<td {...stylex.props(styles.cell, styles.right)}>
|
||||||
<span {...stylex.props(styles.actionGroup)}>
|
<span {...stylex.props(styles.actionGroup)}>
|
||||||
{/* Naming a client writes configuration, so the affordance is
|
{/* Naming a client writes configuration, so the affordance is
|
||||||
|
|||||||
@@ -102,11 +102,14 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minWidth: "max-content",
|
|
||||||
borderCollapse: "collapse",
|
borderCollapse: "collapse",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
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>
|
<tbody>
|
||||||
{prefixes.map((prefix) => (
|
{prefixes.map((prefix) => (
|
||||||
<tr key={prefix.id}>
|
<tr key={prefix.id}>
|
||||||
<td {...stylex.props(shared.td, shared.mono)}>{prefix.prefix}</td>
|
<td {...stylex.props(shared.td, shared.mono, styles.unbroken)}>{prefix.prefix}</td>
|
||||||
<td {...stylex.props(shared.td)}>{prefix.group}</td>
|
<td {...stylex.props(shared.td, styles.unbroken)}>{prefix.group}</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{prefix.priority}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{prefix.priority}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import type { ForwardZone, LocalRecord } from "@/lib/types";
|
import type { ForwardZone, LocalRecord } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { local, RecordFacts, ZoneResolver } from "./localCells";
|
||||||
import { styles as config } from "./styles";
|
import { styles as config } from "./styles";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,18 +27,23 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>Name</th>
|
<th {...stylex.props(shared.th)}>Name</th>
|
||||||
<th {...stylex.props(shared.th)}>Type</th>
|
<th {...stylex.props(shared.th, local.wide)}>Type</th>
|
||||||
<th {...stylex.props(shared.th)}>Value</th>
|
<th {...stylex.props(shared.th, local.wide)}>Value</th>
|
||||||
<th {...stylex.props(shared.th)}>TTL</th>
|
<th {...stylex.props(shared.th, local.wide)}>TTL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{records.map((record) => (
|
{records.map((record) => (
|
||||||
<tr key={record.id}>
|
<tr key={record.id}>
|
||||||
<td {...stylex.props(shared.td, shared.mono)}>{record.name}</td>
|
<td {...stylex.props(shared.td, local.unbroken)}>
|
||||||
<td {...stylex.props(shared.td)}>{record.rtype}</td>
|
<span {...stylex.props(shared.mono)}>{record.name}</span>
|
||||||
<td {...stylex.props(shared.td, shared.mono)}>{record.value}</td>
|
<RecordFacts record={record} />
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{record.ttl}</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -66,14 +72,19 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>Zone</th>
|
<th {...stylex.props(shared.th)}>Zone</th>
|
||||||
<th {...stylex.props(shared.th)}>Resolver</th>
|
<th {...stylex.props(shared.th, local.wide)}>Resolver</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{zones.map((zone) => (
|
{zones.map((zone) => (
|
||||||
<tr key={zone.id}>
|
<tr key={zone.id}>
|
||||||
<td {...stylex.props(shared.td, shared.mono)}>{zone.zone}</td>
|
<td {...stylex.props(shared.td, shared.mono, local.unbroken)}>
|
||||||
<td {...stylex.props(shared.td, shared.mono)}>{zone.resolver}</td>
|
{zone.zone}
|
||||||
|
<ZoneResolver zone={zone} />
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(shared.td, shared.mono, local.unbroken, local.wide)}>
|
||||||
|
{zone.resolver}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
|
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import FactLine from "@/ui/FactLine";
|
||||||
import DefinitionList from "@/ui/DefinitionList";
|
import DefinitionList from "@/ui/DefinitionList";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
import Switch from "@/ui/Switch";
|
import Switch from "@/ui/Switch";
|
||||||
@@ -43,6 +44,13 @@ const ACTION_OPTIONS = [
|
|||||||
{ value: "block", label: "block" },
|
{ 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({
|
const styles = stylex.create({
|
||||||
createForm: {
|
createForm: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -96,8 +104,27 @@ const styles = stylex.create({
|
|||||||
block: {
|
block: {
|
||||||
color: colors.danger,
|
color: colors.danger,
|
||||||
},
|
},
|
||||||
|
/** A pattern or a URL can be one unbroken string as wide as the table. */
|
||||||
pattern: {
|
pattern: {
|
||||||
fontWeight: 500,
|
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[] }) {
|
function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) {
|
||||||
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
|
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>;
|
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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>Source</th>
|
<th {...stylex.props(shared.th)}>Source</th>
|
||||||
<th {...stylex.props(shared.th)}>URL</th>
|
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{assigned.map((source) => (
|
{assigned.map((source) => (
|
||||||
<tr key={source.id}>
|
<tr key={source.id}>
|
||||||
<td {...stylex.props(shared.td)}>{source.name}</td>
|
<td {...stylex.props(shared.td, styles.unbroken)}>
|
||||||
<td {...stylex.props(shared.td, shared.mono)}>{source.url}</td>
|
{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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -471,9 +505,9 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>Pattern</th>
|
<th {...stylex.props(shared.th)}>Pattern</th>
|
||||||
<th {...stylex.props(shared.th)}>Kind</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Kind</th>
|
||||||
<th {...stylex.props(shared.th)}>Action</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Action</th>
|
||||||
<th {...stylex.props(shared.th)}>Created</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Created</th>
|
||||||
{editable && (
|
{editable && (
|
||||||
<th {...stylex.props(shared.th)}>
|
<th {...stylex.props(shared.th)}>
|
||||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
@@ -484,14 +518,23 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<tr key={rule.id}>
|
<tr key={rule.id}>
|
||||||
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
|
<td {...stylex.props(shared.td, styles.pattern)}>
|
||||||
<td {...stylex.props(shared.td)}>{rule.kind}</td>
|
{rule.pattern}
|
||||||
<td {...stylex.props(shared.td)}>
|
<span {...stylex.props(styles.compact)}>
|
||||||
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
|
<FactLine
|
||||||
{rule.action}
|
facts={[
|
||||||
|
<ActionLabel key="action" action={rule.action} />,
|
||||||
|
rule.kind,
|
||||||
|
`Created ${formatTime(rule.created_at)}`,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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 && (
|
{editable && (
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { formatTime } from "@/lib/format";
|
||||||
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
|
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,15 @@ function writes(method: string): Call[] {
|
|||||||
return calls.filter((call) => call.method === method);
|
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 () => {
|
test("the group list is the master, and the selected group is the detail", async () => {
|
||||||
await openProtection();
|
await openProtection();
|
||||||
|
|
||||||
@@ -122,6 +132,15 @@ test("only the selected group's rules are listed", async () => {
|
|||||||
expect(screen.queryByText("ads.example.com")).toBeNull();
|
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 () => {
|
test("a new rule is created in the selected group, with the pattern posted verbatim for a regex", async () => {
|
||||||
await openProtection(2);
|
await openProtection(2);
|
||||||
await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });
|
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();
|
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 () => {
|
test("a source switch resends the whole row immediately, with no Save step", async () => {
|
||||||
calls = stubApi(DATABASE);
|
calls = stubApi(DATABASE);
|
||||||
await renderPage("/configuration/protection?tab=sources", "Protection");
|
await renderPage("/configuration/protection?tab=sources", "Protection");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import FactLine from "@/ui/FactLine";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||||
import Switch from "@/ui/Switch";
|
import Switch from "@/ui/Switch";
|
||||||
@@ -22,17 +23,24 @@ import QueryPanel from "./QueryPanel";
|
|||||||
import { styles as config } from "./styles";
|
import { styles as config } from "./styles";
|
||||||
|
|
||||||
const SKIPPED_NOTE =
|
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 " +
|
"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 " +
|
"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 " +
|
"domain count usually means the list is written for a browser extension, and its DNS or hosts variant will block " +
|
||||||
"more here.";
|
"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({
|
const styles = stylex.create({
|
||||||
name: {
|
name: {
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
badge: {
|
badge: {
|
||||||
|
whiteSpace: "nowrap",
|
||||||
marginLeft: "0.5rem",
|
marginLeft: "0.5rem",
|
||||||
borderRadius: metrics.radius,
|
borderRadius: metrics.radius,
|
||||||
backgroundColor: colors.border,
|
backgroundColor: colors.border,
|
||||||
@@ -49,15 +57,72 @@ const styles = stylex.create({
|
|||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
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: {
|
actions: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
flexDirection: { default: null, [COMPACT]: "column" },
|
||||||
|
alignItems: { default: null, [COMPACT]: "flex-start" },
|
||||||
|
gap: { default: "0.75rem", [COMPACT]: "0.5rem" },
|
||||||
},
|
},
|
||||||
dimWhenDisabled: {
|
dimWhenDisabled: {
|
||||||
opacity: { default: 1, ":disabled": 0.5 },
|
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
|
* A runtime action: it re-downloads the sources the running process already
|
||||||
* knows about, so it stays enabled under file authority and while
|
* knows about, so it stays enabled under file authority and while
|
||||||
@@ -142,41 +207,44 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>Name</th>
|
<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)}>Enabled</th>
|
||||||
<th {...stylex.props(shared.th)}>Domains</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Domains</th>
|
||||||
<th {...stylex.props(shared.th)}>Wildcards</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Wildcards</th>
|
||||||
<th {...stylex.props(shared.th)}>Exceptions</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Exceptions</th>
|
||||||
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Skipped regex</th>
|
||||||
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Skipped unsupported</th>
|
||||||
<th {...stylex.props(shared.th)}>Last updated</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Last updated</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{blocklists.map((b) => (
|
{blocklists.map((b) => (
|
||||||
<tr key={b.id}>
|
<tr key={b.id}>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td, styles.unbroken)}>
|
||||||
<span {...stylex.props(styles.name)}>{b.name}</span>
|
<span {...stylex.props(styles.name)}>{b.name}</span>
|
||||||
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</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>
|
||||||
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</td>
|
|
||||||
<td {...stylex.props(shared.td)}>{String(b.enabled)}</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)}
|
{formatCount(b.domain_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.wildcard_count)}
|
{formatCount(b.wildcard_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.exception_count)}
|
{formatCount(b.exception_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.skipped_regex_count)}
|
{formatCount(b.skipped_regex_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.skipped_unsupported_count)}
|
{formatCount(b.skipped_unsupported_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td, styles.wide)}>
|
||||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -235,14 +303,14 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>Name</th>
|
<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)}>Enabled</th>
|
||||||
<th {...stylex.props(shared.th)}>Domains</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Domains</th>
|
||||||
<th {...stylex.props(shared.th)}>Wildcards</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Wildcards</th>
|
||||||
<th {...stylex.props(shared.th)}>Exceptions</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Exceptions</th>
|
||||||
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Skipped regex</th>
|
||||||
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Skipped unsupported</th>
|
||||||
<th {...stylex.props(shared.th)}>Last updated</th>
|
<th {...stylex.props(shared.th, styles.wide)}>Last updated</th>
|
||||||
<th {...stylex.props(shared.th)}>
|
<th {...stylex.props(shared.th)}>
|
||||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -251,11 +319,12 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{blocklists.map((b) => (
|
{blocklists.map((b) => (
|
||||||
<tr key={b.id}>
|
<tr key={b.id}>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td, styles.unbroken)}>
|
||||||
<span {...stylex.props(styles.name)}>{b.name}</span>
|
<span {...stylex.props(styles.name)}>{b.name}</span>
|
||||||
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
|
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
|
||||||
|
<SourceFacts source={b} />
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td, styles.wide)}>
|
||||||
<span {...stylex.props(styles.url)} title={b.url}>
|
<span {...stylex.props(styles.url)} title={b.url}>
|
||||||
{b.url}
|
{b.url}
|
||||||
</span>
|
</span>
|
||||||
@@ -268,22 +337,22 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
|||||||
onChange={() => toggleEnabled(b)}
|
onChange={() => toggleEnabled(b)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.domain_count)}
|
{formatCount(b.domain_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.wildcard_count)}
|
{formatCount(b.wildcard_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.exception_count)}
|
{formatCount(b.exception_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.skipped_regex_count)}
|
{formatCount(b.skipped_regex_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||||
{formatCount(b.skipped_unsupported_count)}
|
{formatCount(b.skipped_unsupported_count)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td, styles.wide)}>
|
||||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td)}>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import InlineError from "@/lib/InlineError";
|
|||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
import { useCrudForm } from "@/ui/useCrudForm";
|
import { useCrudForm } from "@/ui/useCrudForm";
|
||||||
|
import { local, RecordFacts } from "./localCells";
|
||||||
import QueryPanel from "./QueryPanel";
|
import QueryPanel from "./QueryPanel";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
@@ -230,13 +231,13 @@ export default function RecordsTab() {
|
|||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
|
||||||
Value
|
Value
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
|
||||||
TTL
|
TTL
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||||
@@ -266,10 +267,22 @@ export default function RecordsTab() {
|
|||||||
</td>
|
</td>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
|
<td {...stylex.props(styles.cell, local.unbroken)}>
|
||||||
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
|
<span {...stylex.props(shared.mono)}>{record.name}</span>
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
|
<RecordFacts record={record} />
|
||||||
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
|
</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)}>
|
<td {...stylex.props(styles.actionCell)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
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
|
* 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");
|
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. */
|
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
|
||||||
async function openDeleteDialog(index = 0) {
|
async function openDeleteDialog(index = 0) {
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[index]!);
|
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();
|
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 () => {
|
test("adding an upstream posts every field", async () => {
|
||||||
await openResolution();
|
await openResolution();
|
||||||
await screen.findByRole("heading", { name: "Add upstream" });
|
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" });
|
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 () => {
|
test("the forward zone delete dialog names the zone and only deletes on confirm", async () => {
|
||||||
await openResolution("zones");
|
await openResolution("zones");
|
||||||
await screen.findByText("lan.home");
|
await screen.findByText("lan.home");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import InlineError from "@/lib/InlineError";
|
|||||||
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
|
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
|
||||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import FactLine from "@/ui/FactLine";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import Switch from "@/ui/Switch";
|
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.";
|
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({
|
const styles = stylex.create({
|
||||||
|
/** Cut to one line beside the other columns; whole, wrapping, where it is the row's only text column. */
|
||||||
url: {
|
url: {
|
||||||
display: "block",
|
display: "block",
|
||||||
maxWidth: "18rem",
|
maxWidth: "18rem",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: { default: "nowrap", [COMPACT]: "normal" },
|
||||||
|
overflowWrap: "anywhere",
|
||||||
fontWeight: 500,
|
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: {
|
actions: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
flexDirection: { default: null, [COMPACT]: "column" },
|
||||||
|
alignItems: { default: null, [COMPACT]: "flex-start" },
|
||||||
|
gap: { default: "0.75rem", [COMPACT]: "0.5rem" },
|
||||||
},
|
},
|
||||||
dimWhenDisabled: {
|
dimWhenDisabled: {
|
||||||
opacity: { default: 1, ":disabled": 0.5 },
|
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() {
|
export default function UpstreamsTab() {
|
||||||
const upstreams = useQuery(upstreamsQuery());
|
const upstreams = useQuery(upstreamsQuery());
|
||||||
return (
|
return (
|
||||||
@@ -78,18 +113,21 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>URL</th>
|
<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)}>Enabled</th>
|
||||||
<th {...stylex.props(shared.th)}>TLS name</th>
|
<th {...stylex.props(shared.th, styles.wide)}>TLS name</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{upstreams.map((u) => (
|
{upstreams.map((u) => (
|
||||||
<tr key={u.id}>
|
<tr key={u.id}>
|
||||||
<td {...stylex.props(shared.td, shared.mono)}>{u.url}</td>
|
<td {...stylex.props(shared.td, styles.unbroken)}>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
<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)}>{String(u.enabled)}</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td, styles.unbroken, styles.wide)}>
|
||||||
{u.tls_name === "" ? (
|
{u.tls_name === "" ? (
|
||||||
<span {...stylex.props(styles.absent)}>empty</span>
|
<span {...stylex.props(styles.absent)}>empty</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -151,9 +189,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>URL</th>
|
<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)}>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)}>
|
<th {...stylex.props(shared.th)}>
|
||||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -166,8 +204,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
|||||||
<span {...stylex.props(styles.url)} title={u.url}>
|
<span {...stylex.props(styles.url)} title={u.url}>
|
||||||
{u.url}
|
{u.url}
|
||||||
</span>
|
</span>
|
||||||
|
<UpstreamFacts upstream={u} />
|
||||||
</td>
|
</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)}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<Switch
|
<Switch
|
||||||
aria-label={`${u.url} enabled`}
|
aria-label={`${u.url} enabled`}
|
||||||
@@ -176,7 +215,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
|||||||
onChange={() => toggleEnabled(u)}
|
onChange={() => toggleEnabled(u)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</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)}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<div {...stylex.props(styles.actions)}>
|
<div {...stylex.props(styles.actions)}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
|||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { useCrudForm } from "@/ui/useCrudForm";
|
import { useCrudForm } from "@/ui/useCrudForm";
|
||||||
|
import { local, ZoneResolver } from "./localCells";
|
||||||
import QueryPanel from "./QueryPanel";
|
import QueryPanel from "./QueryPanel";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
@@ -200,7 +201,7 @@ export default function ZonesTab() {
|
|||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
Zone
|
Zone
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
|
||||||
Resolver
|
Resolver
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||||
@@ -230,8 +231,20 @@ export default function ZonesTab() {
|
|||||||
</td>
|
</td>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
|
<td {...stylex.props(styles.cell, shared.mono, local.unbroken)}>
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
|
{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)}>
|
<td {...stylex.props(styles.actionCell)}>
|
||||||
<button
|
<button
|
||||||
type="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,7 +77,6 @@ export const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minWidth: "max-content",
|
|
||||||
borderCollapse: "collapse",
|
borderCollapse: "collapse",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
@@ -102,8 +101,10 @@ export const styles = stylex.create({
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "0.25rem",
|
gap: "0.25rem",
|
||||||
},
|
},
|
||||||
|
/** A group name can be one unbroken string wider than a phone's line. */
|
||||||
masterLink: {
|
masterLink: {
|
||||||
display: "block",
|
display: "block",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
borderRadius: metrics.radius,
|
borderRadius: metrics.radius,
|
||||||
paddingInline: "0.75rem",
|
paddingInline: "0.75rem",
|
||||||
paddingBlock: "0.375rem",
|
paddingBlock: "0.375rem",
|
||||||
|
|||||||
@@ -44,7 +44,13 @@ const ACTIVE = page([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const RESOLVED = 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();
|
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 () => {
|
test("nothing open reads as good news, not as a broken page", async () => {
|
||||||
responses["/api/diagnostics?state=active"] = page([]);
|
responses["/api/diagnostics?state=active"] = page([]);
|
||||||
renderRoute();
|
renderRoute();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { formatCount, formatDuration, formatTime } from "@/lib/format";
|
|||||||
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
|
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
|
||||||
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import FactLine from "@/ui/FactLine";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||||
@@ -41,6 +42,13 @@ const COMPONENT_OPTIONS = [
|
|||||||
...DIAGNOSTIC_COMPONENTS.map((component) => ({ value: component, label: componentLabel(component) })),
|
...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({
|
const styles = stylex.create({
|
||||||
heading: {
|
heading: {
|
||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
@@ -161,9 +169,9 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
tableWrap: {
|
/** The shared scrolling wrapper, framed. */
|
||||||
|
framedTable: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
overflowX: "auto",
|
|
||||||
borderRadius: metrics.radius,
|
borderRadius: metrics.radius,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -197,6 +205,17 @@ const styles = stylex.create({
|
|||||||
nowrap: {
|
nowrap: {
|
||||||
whiteSpace: "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: {
|
rowLink: {
|
||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
@@ -207,6 +226,9 @@ const styles = stylex.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
},
|
},
|
||||||
|
count: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
},
|
||||||
note: {
|
note: {
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
@@ -340,10 +362,13 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
|
|||||||
const copy = copyFor(event.code);
|
const copy = copyFor(event.code);
|
||||||
return (
|
return (
|
||||||
<tr {...stylex.props(styles.row)}>
|
<tr {...stylex.props(styles.row)}>
|
||||||
<td {...stylex.props(styles.cell)}>
|
<td {...stylex.props(styles.cell, styles.wide)}>
|
||||||
<SeverityBadge severity={event.severity} />
|
<SeverityBadge severity={event.severity} />
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell)}>
|
<td {...stylex.props(styles.cell)}>
|
||||||
|
<span {...stylex.props(styles.compactBadge)}>
|
||||||
|
<SeverityBadge severity={event.severity} />
|
||||||
|
</span>
|
||||||
<Link
|
<Link
|
||||||
to="/diagnostics/$id"
|
to="/diagnostics/$id"
|
||||||
params={{ id: String(event.id) }}
|
params={{ id: String(event.id) }}
|
||||||
@@ -351,13 +376,31 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
|
|||||||
>
|
>
|
||||||
{copy.title}
|
{copy.title}
|
||||||
</Link>
|
</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>
|
||||||
<td {...stylex.props(styles.cell)}>{event.subject}</td>
|
<td {...stylex.props(styles.cell, styles.wide)}>{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, styles.wide)}>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
{formatTime(event.first_seen)}
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
|
||||||
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
||||||
</td>
|
</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)}>
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||||
<button
|
<button
|
||||||
type="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>
|
<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)}>
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead {...stylex.props(styles.head)}>
|
<thead {...stylex.props(styles.head)}>
|
||||||
<tr>
|
<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)}>Event</th>
|
||||||
<th {...stylex.props(styles.th)}>Subject</th>
|
<th {...stylex.props(styles.th, styles.wide)}>Subject</th>
|
||||||
<th {...stylex.props(styles.th)}>Started</th>
|
<th {...stylex.props(styles.th, styles.wide)}>Started</th>
|
||||||
<th {...stylex.props(styles.th)}>Resolved</th>
|
<th {...stylex.props(styles.th, styles.wide)}>Resolved</th>
|
||||||
<th {...stylex.props(styles.th)}>Occurrences</th>
|
<th {...stylex.props(styles.th, styles.wide)}>Occurrences</th>
|
||||||
<th {...stylex.props(styles.th)}>
|
<th {...stylex.props(styles.th)}>
|
||||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -522,7 +565,7 @@ export default function DiagnosticsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p {...stylex.props(styles.footer, styles.note)}>
|
<p {...stylex.props(styles.count, styles.note)}>
|
||||||
Showing{" "}
|
Showing{" "}
|
||||||
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "}
|
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "}
|
||||||
resolved {historyRows.length === 1 ? "entry" : "entries"}
|
resolved {historyRows.length === 1 ? "entry" : "entries"}
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ const styles = stylex.create({
|
|||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
// Five is prime, so every count between one and five leaves a short last
|
// 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
|
// 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
|
// of room rather than one that chose. Two columns, then a single row of
|
||||||
// straight to two and then to a single row of five, and is never ragged.
|
// five. One column on a phone stacked five cards over the episodes the
|
||||||
|
// page exists to show.
|
||||||
gridTemplateColumns: {
|
gridTemplateColumns: {
|
||||||
default: "minmax(0, 1fr)",
|
default: "repeat(2, minmax(0, 1fr))",
|
||||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
|
||||||
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
|
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
|
||||||
},
|
},
|
||||||
listStyleType: "none",
|
listStyleType: "none",
|
||||||
@@ -51,6 +51,8 @@ const styles = stylex.create({
|
|||||||
fact: {
|
fact: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
|
// A card stretches to the tallest card in its row; the lines stay at the top.
|
||||||
|
alignContent: "flex-start",
|
||||||
alignItems: "baseline",
|
alignItems: "baseline",
|
||||||
gap: "0.375rem",
|
gap: "0.375rem",
|
||||||
borderRadius: metrics.radius,
|
borderRadius: metrics.radius,
|
||||||
@@ -73,8 +75,10 @@ const styles = stylex.create({
|
|||||||
label: {
|
label: {
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
|
/** Below 640px a value always starts its own line, so every card has the same shape. */
|
||||||
value: {
|
value: {
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
flexBasis: { default: null, "@media (max-width: 639px)": "100%" },
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
flexBasis: "100%",
|
flexBasis: "100%",
|
||||||
|
|||||||
@@ -213,7 +213,14 @@ const styles = stylex.create({
|
|||||||
paddingInline: "0.5rem",
|
paddingInline: "0.5rem",
|
||||||
paddingBlock: "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: {
|
main: {
|
||||||
|
position: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: { default: null, [WIDE]: 0 },
|
minHeight: { default: null, [WIDE]: 0 },
|
||||||
minWidth: { default: null, [WIDE]: 0 },
|
minWidth: { default: null, [WIDE]: 0 },
|
||||||
|
|||||||
@@ -32,8 +32,15 @@
|
|||||||
* chevron, and Segoe UI does not carry that glyph, so on Windows the stack
|
* 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.
|
* 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 {
|
html {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
overflow-wrap: break-word;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
/* The tokens carry both schemes; this tells the UA to match its own chrome — form controls, scrollbars. */
|
/* The tokens carry both schemes; this tells the UA to match its own chrome — form controls, scrollbars. */
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ const styles = stylex.create({
|
|||||||
/**
|
/**
|
||||||
* The whole label is the hit area, so it carries the 44px pointer-target
|
* 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
|
* 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: {
|
label: {
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
@@ -65,6 +66,7 @@ const styles = stylex.create({
|
|||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
},
|
},
|
||||||
box: {
|
box: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|||||||
@@ -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
@@ -264,10 +264,21 @@ export const styles = stylex.create({
|
|||||||
paddingInline: "0.75rem",
|
paddingInline: "0.75rem",
|
||||||
paddingBlock: "0.5rem",
|
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: {
|
tableWrap: {
|
||||||
|
position: "relative",
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
|
containerType: "inline-size",
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Not a faithful port: `space-y-3` set sibling margins on a block container,
|
* Not a faithful port: `space-y-3` set sibling margins on a block container,
|
||||||
|
|||||||
Reference in New Issue
Block a user