diff --git a/CHANGELOG.md b/CHANGELOG.md index 8603d0f..60d179d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## [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 ### Fixed diff --git a/admin/src/features/activity/ActivityFilters.tsx b/admin/src/features/activity/ActivityFilters.tsx index 7ba7423..d238380 100644 --- a/admin/src/features/activity/ActivityFilters.tsx +++ b/admin/src/features/activity/ActivityFilters.tsx @@ -57,6 +57,12 @@ const PRESETS = [ const CUSTOM_ITEM = "Custom…"; +/** + * Narrower than the toolbar on one row: the search field's basis, Clients, the + * result group, the widest preset's Time label and Clear, with their gaps. + */ +const ONE_ROW_DOES_NOT_FIT = "@container (max-width: 48rem)"; + const styles = stylex.create({ toolbar: { marginTop: "1rem", @@ -167,13 +173,22 @@ const styles = stylex.create({ color: colors.primaryText, outlineColor: { default: null, ":focus-visible": colors.primaryText }, }, + /** The toolbar's controls wrap by the width the form gets, not by the viewport. */ + form: { + containerType: "inline-size", + }, /** * Clear keeps its box when there is nothing to clear. It appears the moment a - * filter is set, and a control that appeared by widening the row would move - * every other control out from under the pointer that was reaching for it. + * filter is set, and on the one row the toolbar fits on, a control that + * appeared by widening it would shrink the growing search field and move + * every other control out from under the pointer reaching for it. A toolbar + * too narrow for one row wraps the search field onto a row of its own, so + * there a Clear that appears moves nothing, and a kept box would only wrap + * onto a row of nothing above the table. */ clearHidden: { visibility: "hidden", + display: { default: null, [ONE_ROW_DOES_NOT_FIT]: "none" }, }, customRow: { marginTop: "0.5rem", @@ -500,7 +515,7 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) { } return ( -
+
) : ( <> -
+
diff --git a/admin/src/features/activity/LiveActivity.test.tsx b/admin/src/features/activity/LiveActivity.test.tsx index 57c9235..80cc3c5 100644 --- a/admin/src/features/activity/LiveActivity.test.tsx +++ b/admin/src/features/activity/LiveActivity.test.tsx @@ -15,6 +15,7 @@ import { createAppRouter } from "@/routes"; import type { Client } from "@/lib/types"; import { provenance, queryRow } from "@/features/provenance/provenanceFixture"; import { health } from "@/lib/healthFixture"; +import { activityCell } from "./activityCell"; import { FakeEventSource } from "./fakeEventSource"; import LiveActivity from "./LiveActivity"; import type { ActivitySearch } from "./search"; @@ -187,19 +188,20 @@ test("resolves each row's client to its display name, reading the IP out with it sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } })); }); - const named = await screen.findByText("Kitchen Pi"); + await screen.findAllByText("Kitchen Pi"); + const named = within(activityCell("named.example", "Client")).getByText("Kitchen Pi"); // The address reads out with the name it replaced, rather than sitting in a // title only a mouse can reach. expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)"); expect(named.getAttribute("title")).toBeNull(); expect(screen.queryByText("pi.lan")).toBeNull(); - const learned = screen.getByText("laptop.lan"); + const learned = within(activityCell("learned.example", "Client")).getByText("laptop.lan"); expect(learned.textContent).toBe("laptop.lan (192.0.2.11)"); expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull(); - expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull(); - expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull(); + expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").getAttribute("title")).toBeNull(); + expect(within(activityCell("stranger.example", "Client")).getByText("192.0.2.99").getAttribute("title")).toBeNull(); }); test("rows stream in as bare IPs while the client list is still loading", async () => { @@ -222,11 +224,12 @@ test("rows stream in as bare IPs while the client list is still loading", async await openLive(); act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }))); - expect(screen.getByText("192.0.2.10")).toBeTruthy(); + expect(within(activityCell("named.example", "Client")).getByText("192.0.2.10")).toBeTruthy(); expect(screen.queryByText("Kitchen Pi")).toBeNull(); releaseClients(); - expect(await screen.findByText("Kitchen Pi")).toBeTruthy(); + await screen.findAllByText("Kitchen Pi"); + expect(within(activityCell("named.example", "Client")).getByText("Kitchen Pi")).toBeTruthy(); }); test("repeated connection failures show the viewer-cap state with a retry button", async () => { diff --git a/admin/src/features/activity/LiveActivity.tsx b/admin/src/features/activity/LiveActivity.tsx index e36819f..1100d57 100644 --- a/admin/src/features/activity/LiveActivity.tsx +++ b/admin/src/features/activity/LiveActivity.tsx @@ -128,9 +128,8 @@ const styles = stylex.create({ marginTop: "1.5rem", color: colors.textMuted, }, - tableWrap: { - marginTop: "1rem", - overflowX: "auto", + /** The shared scrolling wrapper, framed. */ + framedTable: { borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", @@ -351,7 +350,7 @@ export default function LiveActivity({ ) ) : ( <> -
+
diff --git a/admin/src/features/activity/activityCell.ts b/admin/src/features/activity/activityCell.ts new file mode 100644 index 0000000..c0745c6 --- /dev/null +++ b/admin/src/features/activity/activityCell.ts @@ -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; +} diff --git a/admin/src/features/activity/cells.tsx b/admin/src/features/activity/cells.tsx index fe58b83..6027893 100644 --- a/admin/src/features/activity/cells.tsx +++ b/admin/src/features/activity/cells.tsx @@ -20,6 +20,7 @@ import { ClientName, type ClientNames } from "@/features/clients/clientNames"; import { rcodeShortName } from "@/features/provenance/provenanceCopy"; import { qtypeName } from "@/features/provenance/qtype"; import type { QuerySummary } from "@/features/provenance/querySummary"; +import FactLine from "@/ui/FactLine"; import { styles as shared } from "@/ui/styles"; import { colors, metrics } from "@/ui/tokens.stylex"; @@ -28,6 +29,15 @@ const DARK = "@media (prefers-color-scheme: dark)"; /** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */ const DOMAIN_MAX_WIDTH = "24rem"; +/** + * Narrower than the seven columns need with ordinary data, so the table keeps + * two: Domain, which takes the other columns' facts as lines under the name, + * and Result. Hiding cells keeps a native table; re-laying its rows out as + * blocks would put the table semantics at the mercy of each browser engine. + * The wrapper that scrolls is the container. + */ +const COMPACT = "@container (max-width: 50rem)"; + const styles = stylex.create({ head: { backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" }, @@ -52,6 +62,16 @@ const styles = stylex.create({ domainCell: { maxWidth: DOMAIN_MAX_WIDTH, }, + wide: { + display: { default: null, [COMPACT]: "none" }, + }, + /** A name can be one unbroken label as wide as the table; `anywhere` also lowers the cell's minimum width. */ + compact: { + display: { default: "none", [COMPACT]: "block" }, + marginTop: "0.125rem", + color: colors.textMuted, + overflowWrap: "anywhere", + }, small: { fontSize: "0.75rem", lineHeight: "1rem", @@ -93,6 +113,7 @@ const styles = stylex.create({ */ badge: { display: "inline-block", + whiteSpace: "nowrap", borderRadius: metrics.radius, paddingInline: "0.375rem", paddingBlock: "0.125rem", @@ -152,6 +173,29 @@ export function ResultCellContent({ row }: { row: Pick{label}; } +/** 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 = ( + <> + Type + {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 ( + <> + + + + + + + + ); +} + export function ActivityCells({ row, clientNames, @@ -163,22 +207,23 @@ export function ActivityCells({ }) { return ( <> - - + - - + - - + @@ -187,12 +232,14 @@ export function ActivityCells({ export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const; +const COMPACT_COLUMNS: ReadonlySet = new Set(["Domain", "Result"]); + export function ActivityTableHead() { return ( {ACTIVITY_COLUMNS.map((column) => ( - ))} diff --git a/admin/src/features/clients/ClientsPage.test.tsx b/admin/src/features/clients/ClientsPage.test.tsx index 91d9be5..d79e69e 100644 --- a/admin/src/features/clients/ClientsPage.test.tsx +++ b/admin/src/features/clients/ClientsPage.test.tsx @@ -42,21 +42,42 @@ afterEach(() => { vi.unstubAllGlobals(); }); +const CLIENT_COLUMNS = ["Address", "Name", "Group", "First seen", "Last seen", "Actions"] as const; + +/** + * One column's cell in the row for `ip`. jsdom applies no stylesheet, so the + * compact Address cell's copy of the name and group sits beside the wide cells, + * and a page-wide text query finds both. + */ +function clientCell(ip: string, column: (typeof CLIENT_COLUMNS)[number]): HTMLElement { + const row = screen.getByText(ip).closest("tr")!; + return within(row).getAllByRole("cell")[CLIENT_COLUMNS.indexOf(column)]!; +} + test("renders the identity-first table: address, name, group, first and last seen", async () => { await renderClientsPage(); const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent); - expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]); + expect(headers).toEqual([...CLIENT_COLUMNS]); expect(screen.getByText("192.168.1.10")).toBeTruthy(); expect(screen.getByText("192.168.1.11")).toBeTruthy(); - expect(screen.getByText("laptop")).toBeTruthy(); + expect(within(clientCell("192.168.1.10", "Name")).getByText("laptop")).toBeTruthy(); expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1); }); +test("the Address cell carries the name, group and last seen for a table too narrow for them", async () => { + await renderClientsPage(); + + const address = clientCell("192.168.1.10", "Address"); + expect(within(address).getByText("laptop")).toBeTruthy(); + expect(address.textContent).toContain("· default"); + expect(address.textContent).toContain(`Last seen ${clientCell("192.168.1.10", "Last seen").textContent}`); +}); + test("a named row shows the typed name and hides the learned one", async () => { await renderClientsPage(); - expect(screen.getByText("laptop")).toBeTruthy(); + expect(within(clientCell("192.168.1.10", "Name")).getByText("laptop")).toBeTruthy(); expect(screen.queryByText("laptop-1.lan")).toBeNull(); }); @@ -65,7 +86,7 @@ test("an unnamed row shows the learned name with the learned marker", async () = // The cell holds the learned name followed by the marker, so the match is on // the containing span rather than on a bare text node. - const learned = screen.getByText( + const learned = within(clientCell("192.168.1.11", "Name")).getByText( (content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"), ); // The marker is text, not colour, so a screen reader announces it too. diff --git a/admin/src/features/clients/ClientsPage.tsx b/admin/src/features/clients/ClientsPage.tsx index e82f40b..7e4423f 100644 --- a/admin/src/features/clients/ClientsPage.tsx +++ b/admin/src/features/clients/ClientsPage.tsx @@ -45,6 +45,13 @@ function clientLabel(client: Client): string { return name === "" ? client.ip : `${name} (${client.ip})`; } +/** + * Narrower than the six columns need, so the table keeps Address, which takes + * the name, group and last-seen time as lines under the link, and the actions. + * First seen stays on the client's own page. The wrapper is the container. + */ +const COMPACT = "@container (max-width: 50rem)"; + const styles = stylex.create({ heading: { fontSize: "1.5rem", @@ -84,7 +91,6 @@ const styles = stylex.create({ }, table: { width: "100%", - minWidth: "48rem", textAlign: "left", fontSize: "0.875rem", lineHeight: "1.25rem", @@ -107,9 +113,24 @@ const styles = stylex.create({ right: { textAlign: "right", }, + wide: { + display: { default: null, [COMPACT]: "none" }, + }, + /** A name can be one unbroken label as wide as the table; `anywhere` also lowers the cell's minimum width. */ + compact: { + display: { default: "none", [COMPACT]: "block" }, + marginTop: "0.125rem", + color: colors.textMuted, + overflowWrap: "anywhere", + }, + compactName: { + color: colors.text, + }, + /** A full IPv6 address is 39 characters of mono, wider than a phone's Address column. */ addressLink: { color: colors.primaryOnSurface, textDecorationLine: "none", + overflowWrap: "anywhere", }, actionGroup: { display: "inline-flex", @@ -190,10 +211,10 @@ export default function ClientsPage() { - - - - + + + + @@ -202,23 +223,32 @@ export default function ClientsPage() { {rows.map((client) => ( - - - - - + + + {prefixes.map((prefix) => ( - - + + ))} diff --git a/admin/src/features/configuration/LocalReadOnly.tsx b/admin/src/features/configuration/LocalReadOnly.tsx index 3c4282d..fa47201 100644 --- a/admin/src/features/configuration/LocalReadOnly.tsx +++ b/admin/src/features/configuration/LocalReadOnly.tsx @@ -1,6 +1,7 @@ import * as stylex from "@stylexjs/stylex"; import type { ForwardZone, LocalRecord } from "@/lib/types"; import { styles as shared } from "@/ui/styles"; +import { local, RecordFacts, ZoneResolver } from "./localCells"; import { styles as config } from "./styles"; /** @@ -26,18 +27,23 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) { - - - + + + {records.map((record) => ( - - - - + + + + ))} @@ -66,14 +72,19 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) { - + {zones.map((zone) => ( - - + + ))} diff --git a/admin/src/features/configuration/ProtectionGroups.tsx b/admin/src/features/configuration/ProtectionGroups.tsx index 5be4edd..cdc367a 100644 --- a/admin/src/features/configuration/ProtectionGroups.tsx +++ b/admin/src/features/configuration/ProtectionGroups.tsx @@ -19,6 +19,7 @@ import { } from "@/lib/queries"; import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types"; import ConfirmDialog from "@/ui/ConfirmDialog"; +import FactLine from "@/ui/FactLine"; import DefinitionList from "@/ui/DefinitionList"; import Select from "@/ui/Select"; import Switch from "@/ui/Switch"; @@ -43,6 +44,13 @@ const ACTION_OPTIONS = [ { value: "block", label: "block" }, ]; +/** + * Narrower than a rule's four columns, or a source's name beside its URL: below + * it each table keeps its first column and the actions, and the first cell + * carries the rest. + */ +const COMPACT = "@container (max-width: 36rem)"; + const styles = stylex.create({ createForm: { display: "flex", @@ -96,8 +104,27 @@ const styles = stylex.create({ block: { color: colors.danger, }, + /** A pattern or a URL can be one unbroken string as wide as the table. */ pattern: { fontWeight: 500, + overflowWrap: "anywhere", + }, + unbroken: { + overflowWrap: "anywhere", + }, + /** Read character by character, so a wrapped URL fills each line instead of stranding a "-" on one. */ + urlText: { + wordBreak: "break-all", + }, + wide: { + display: { default: null, [COMPACT]: "none" }, + }, + compact: { + display: { default: "none", [COMPACT]: "block" }, + marginTop: "0.125rem", + color: colors.textMuted, + fontWeight: 400, + overflowWrap: "anywhere", }, }); @@ -275,6 +302,10 @@ function GroupSourcesReadOnly({ group }: { group: Group }) { ); } +function ActionLabel({ action }: { action: RuleAction }) { + return {action}; +} + function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) { const assigned = catalogue.filter((source) => sourceIds.includes(source.id)); if (assigned.length === 0) return

This group is assigned no sources.

; @@ -284,14 +315,17 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo - + {assigned.map((source) => ( - - + + ))} @@ -471,9 +505,9 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) { - - - + + + {editable && ( {rules.map((rule) => ( - - - - + + + {editable && ( - + - - - - - - + + + + + + {blocklists.map((b) => ( - + - - - - - - - @@ -235,14 +303,14 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) { - + - - - - - - + + + + + + @@ -251,11 +319,12 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) { {blocklists.map((b) => ( - - - - - - - - - - - - - - + + + + - + - + {upstreams.map((u) => ( - - + + - - + - + @@ -166,8 +204,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) { {u.url} + - + - + - + + - - - - + + - +
{formatTime(row.ts)} - {renderDomain(row.id, {row.domain})} + {formatTime(row.ts)} + {renderDomain( + row.id, + {row.domain}, + )} + + {qtypeName(row.qtype)}{qtypeName(row.qtype)} {routeLabel(row.route_kind)} + {routeLabel(row.route_kind)} {row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
+ {column}
AddressNameGroupFirst seenLast seenNameGroupFirst seenLast seen Actions
+ {client.ip} + + + + {" "} + · {client.group} + + + Last seen {formatTime(client.last_seen)} + + {client.group}{formatTime(client.first_seen)}{formatTime(client.last_seen)}{client.group}{formatTime(client.first_seen)}{formatTime(client.last_seen)} {/* Naming a client writes configuration, so the affordance is diff --git a/admin/src/features/clients/NetworkAssignments.tsx b/admin/src/features/clients/NetworkAssignments.tsx index 5c37624..38b766d 100644 --- a/admin/src/features/clients/NetworkAssignments.tsx +++ b/admin/src/features/clients/NetworkAssignments.tsx @@ -102,11 +102,14 @@ const styles = stylex.create({ }, table: { width: "100%", - minWidth: "max-content", borderCollapse: "collapse", fontSize: "0.875rem", lineHeight: "1.25rem", }, + /** A full IPv6 prefix, or a group name, can be one unbroken string wider than a phone's column. */ + unbroken: { + overflowWrap: "anywhere", + }, }); /** @@ -155,8 +158,8 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
{prefix.prefix}{prefix.group}{prefix.prefix}{prefix.group} {prefix.priority}
NameTypeValueTTLTypeValueTTL
{record.name}{record.rtype}{record.value}{record.ttl} + {record.name} + + {record.rtype} + {record.value} + {record.ttl}
ZoneResolverResolver
{zone.zone}{zone.resolver} + {zone.zone} + + + {zone.resolver} +
SourceURLURL
{source.name}{source.url} + {source.name} + {source.url} + {source.url}
PatternKindActionCreatedKindActionCreated Actions @@ -484,14 +518,23 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
{rule.pattern}{rule.kind} - - {rule.action} + + {rule.pattern} + + , + rule.kind, + `Created ${formatTime(rule.created_at)}`, + ]} + /> {formatTime(rule.created_at)}{rule.kind} + + {formatTime(rule.created_at)}
NameURLURL EnabledDomainsWildcardsExceptionsSkipped regexSkipped unsupportedLast updatedDomainsWildcardsExceptionsSkipped regexSkipped unsupportedLast updated
+ {b.name} {b.is_suggested && Suggested} + + + {b.url} {b.url} {String(b.enabled)} + {formatCount(b.domain_count)} + {formatCount(b.wildcard_count)} + {formatCount(b.exception_count)} + {formatCount(b.skipped_regex_count)} + {formatCount(b.skipped_unsupported_count)} + {b.last_updated === null ? "never" : formatTime(b.last_updated)}
NameURLURL EnabledDomainsWildcardsExceptionsSkipped regexSkipped unsupportedLast updatedDomainsWildcardsExceptionsSkipped regexSkipped unsupportedLast updated Actions
+ {b.name} {b.is_suggested && Suggested} + + {b.url} @@ -268,22 +337,22 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) { onChange={() => toggleEnabled(b)} /> + {formatCount(b.domain_count)} + {formatCount(b.wildcard_count)} + {formatCount(b.exception_count)} + {formatCount(b.skipped_regex_count)} + {formatCount(b.skipped_unsupported_count)} + {b.last_updated === null ? "never" : formatTime(b.last_updated)} diff --git a/admin/src/features/configuration/RecordsTab.tsx b/admin/src/features/configuration/RecordsTab.tsx index f24a3f3..43c3e9e 100644 --- a/admin/src/features/configuration/RecordsTab.tsx +++ b/admin/src/features/configuration/RecordsTab.tsx @@ -12,6 +12,7 @@ import InlineError from "@/lib/InlineError"; import ConfirmDialog from "@/ui/ConfirmDialog"; import Select from "@/ui/Select"; import { useCrudForm } from "@/ui/useCrudForm"; +import { local, RecordFacts } from "./localCells"; import QueryPanel from "./QueryPanel"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; @@ -230,13 +231,13 @@ export default function RecordsTab() { Name + Type + Value + TTL @@ -266,10 +267,22 @@ export default function RecordsTab() { ) : ( <> - {record.name}{record.rtype}{record.value}{record.ttl} + {record.name} + + {record.rtype} + {record.value} + {record.ttl}
URLPriorityPriority EnabledTLS nameTLS name
{u.url}{u.priority} + {u.url} + + {u.priority} {String(u.enabled)} + {u.tls_name === "" ? ( empty ) : ( @@ -151,9 +189,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
URLPriorityPriority EnabledTLS nameTLS name Actions {u.priority}{u.priority} toggleEnabled(u)} /> {u.tls_name === "" ? "—" : u.tls_name} + {u.tls_name === "" ? "—" : u.tls_name} +
+ Resolver @@ -230,8 +231,20 @@ export default function ZonesTab() { ) : ( <> - {zone.zone}{zone.resolver} + {zone.zone} + + + {zone.resolver} +
+ + + + {copy.title} + {event.subject} + + + {event.subject}{formatTime(event.first_seen)} + {event.subject} + {formatTime(event.first_seen)} + {event.resolved_at === null ? "—" : formatTime(event.resolved_at)} {formatCount(event.occurrences)} + {formatCount(event.occurrences)} +