Files
nxdns/admin/src/features/activity/cells.tsx
T
mokhtar 85b8be50a0
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s
admin: overview redesign, device scope, one formatting contract (milestone 39)
The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only.

GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry.

All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else.

Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash.

Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round.

Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
2026-09-07 23:11:50 +02:00

203 lines
6.7 KiB
TypeScript

/**
* The seven columns of the Activity table: Time, Domain, Client, Type, Result,
* Route and Duration.
*
* The labels here are the compact forms a scanned table needs. The detail page
* keeps `provenanceCopy`'s long forms, which spell out the same facts with room
* for the rcode number and the "answered by" phrasing.
*
* The cells render both a stored row and a streamed event, so they know nothing
* about routes: the caller renders the Domain cell's contents and decides what,
* if anything, a row opens. A streamed row has no id — the frame precedes its
* own insert — and only the caller knows whether it has a surface for one.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatMicros, formatTime } from "@/lib/format";
import type { RouteKind } from "@/lib/types";
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 { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
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";
const styles = stylex.create({
head: {
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
textAlign: "left",
},
th: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textSecondary,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
nowrap: {
whiteSpace: "nowrap",
},
breakAll: {
wordBreak: "break-all",
},
domainCell: {
maxWidth: DOMAIN_MAX_WIDTH,
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
/**
* The padding makes the whole row height clickable, and the equal negative
* margin gives that height back to the row. The clamp lives on the inner
* span, not here: a padded `-webkit-box` can paint a third clipped line
* inside its own padding.
*/
domainLink: {
display: "block",
maxWidth: DOMAIN_MAX_WIDTH,
paddingBlock: "0.5rem",
marginBlock: "-0.5rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
/**
* A domain is unbounded; the column is not. The clamp counts only the lines
* of the element that directly holds the text, so every cell wraps its text
* in this span — a `td` that took `display: -webkit-box` would stop being a
* table cell.
*/
domainClamp: {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
overflow: "hidden",
},
/**
* The badge shape and its weight are the signal; the tint only says which
* kind of unhappy answer this was. A monochrome or colour-blind reading of
* the table still separates a blocked or failed row from a plain NOERROR
* one, which a hue alone would not.
*/
badge: {
display: "inline-block",
borderRadius: metrics.radius,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
},
blockedBadge: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
faultBadge: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(90.1% 0.076 70.697)" },
},
});
const ROUTE_LABELS: Record<RouteKind, string> = {
blocked: "Blocked",
local: "Local",
forward_zone: "Forward zone",
upstream: "Upstream",
cache: "Cache",
rejected: "Rejected",
};
/** The compact Route label. `Record` over the union, so a new kind fails `tsc`. */
export function routeLabel(kind: RouteKind): string {
return ROUTE_LABELS[kind];
}
/**
* The compact Result label. A block is the answer the operator asked nxdns for,
* so it wins over the rcode it was delivered as — a blocked name answered with
* NOERROR and a zero address is still "Blocked". Everything else reads as the
* code the client saw.
*/
export function resultLabel(row: Pick<QuerySummary, "blocked" | "rcode">): string {
return row.blocked ? "Blocked" : rcodeShortName(row.rcode);
}
/**
* What a row's domain is wrapped in. The caller owns the routes, so it builds
* the element; the look stays here, as `activityDomainLink`, which the caller
* spreads onto the control itself — a link's own colour beats one inherited
* from a wrapper. `id` is null for a streamed row, which history has no surface
* for and live opens from memory.
*/
export type DomainRenderer = (id: number | null, children: ReactNode) => ReactNode;
export const activityDomainLink = styles.domainLink;
export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" | "rcode"> }) {
const label = resultLabel(row);
if (row.blocked) return <span {...stylex.props(styles.badge, styles.blockedBadge)}>{label}</span>;
if (row.rcode !== 0) return <span {...stylex.props(styles.badge, styles.faultBadge)}>{label}</span>;
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
}
export function ActivityCells({
row,
clientNames,
renderDomain,
}: {
row: QuerySummary;
clientNames: ClientNames;
renderDomain: DomainRenderer;
}) {
return (
<>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td
title={row.domain}
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
>
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
<ClientName ip={row.client_ip} names={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<ResultCellContent row={row} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
</td>
</>
);
}
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
export function ActivityTableHead() {
return (
<thead {...stylex.props(styles.head)}>
<tr>
{ACTIVITY_COLUMNS.map((column) => (
<th key={column} {...stylex.props(styles.th)}>
{column}
</th>
))}
</tr>
</thead>
);
}