Files
nxdns/admin/src/features/clients/ClientDetailPage.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

242 lines
6.9 KiB
TypeScript

import { useState } from "react";
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { formatTime } from "@/lib/format";
import { clientsQuery } from "@/lib/queries";
import { useAuthority } from "@/features/configuration/authority";
import Card from "@/ui/Card";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ClientDisplayName, provenanceOf } from "./clientIdentity";
/** The Activity window this page offers: the last day, in seconds. */
const ACTIVITY_WINDOW_SECONDS = 24 * 60 * 60;
const nowInSeconds = () => Math.floor(Date.now() / 1000);
const styles = stylex.create({
back: {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
backIcon: {
display: "inline-flex",
},
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
address: {
marginTop: "0.25rem",
color: colors.textSecondary,
},
panel: {
marginTop: "1rem",
maxWidth: "48rem",
},
facts: {
display: "grid",
gap: "0.5rem 1rem",
gridTemplateColumns: {
default: "auto",
"@media (min-width: 640px)": "max-content 1fr",
},
margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
term: {
color: colors.textMuted,
},
value: {
margin: 0,
},
provenanceDetail: {
marginTop: "0.125rem",
color: colors.textMuted,
},
sectionHeading: {
marginTop: "1.5rem",
fontSize: "1.4rem",
lineHeight: 1.2,
fontWeight: 650,
letterSpacing: "-0.015em",
},
prose: {
marginTop: "0.5rem",
maxWidth: "48rem",
fontSize: "0.875rem",
lineHeight: "1.5rem",
},
links: {
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
link: {
color: colors.primaryOnSurface,
},
/**
* Room for the heading, the facts panel and the two sections under it, so
* the page settles at roughly its filled height instead of growing into it.
*/
loading: {
minHeight: "24rem",
marginTop: "1rem",
color: colors.textMuted,
},
});
function BackLink() {
return (
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
<ArrowLeft size={12} />
</span>
All clients
</Link>
);
}
/**
* One client, addressed by the row id `PUT /api/clients/{id}` already uses.
*
* No endpoint answers for a single client, so the list is the source: the route
* loader ensures it, which makes a cold deep link one fetch rather than a
* miss. An id the loaded list does not contain is a row that was deleted or
* never existed — a state this page explains once, with no refetch behind it.
*/
export default function ClientDetailPage() {
const { id } = useParams({ from: "/shell/clients/$id" });
const clientId = Number(id);
const { data, error, isPending, refetch } = useQuery(clientsQuery());
const authority = useAuthority();
// The Activity window is relative to now, and a real link must carry its
// bounds in the href before the click — that is what makes middle-click,
// copy-link and open-in-new-tab work. A page left open would otherwise link
// to yesterday's day, so the window is re-minted when the link is about to be
// used: pointerdown precedes the click, and keydown precedes the click Enter
// synthesizes. Focus re-mints too, but it cannot be the last word — a link can
// hold focus for hours before the keypress. All three are discrete events, so
// the href is fresh by the time navigation reads it.
const [activityUntil, setActivityUntil] = useState(nowInSeconds);
const freshenActivityWindow = () => setActivityUntil(nowInSeconds());
if (isPending) {
return (
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
Loading client
</p>
);
}
if (data === undefined) {
return (
<section>
<BackLink />
<InlineError error={error} onRetry={() => void refetch()} />
</section>
);
}
const client = data.find((row) => row.id === clientId);
if (client === undefined) {
return (
<section>
<BackLink />
<h1 {...stylex.props(styles.heading)}>No such client</h1>
<p {...stylex.props(styles.prose)}>
nxdns has no client with id {id}. It was deleted, or the link was to a row that never existed. A
device that is still on the network reappears in the list on its next DNS query.
</p>
</section>
);
}
const provenance = provenanceOf(client, authority);
return (
<section>
<BackLink />
<h1 {...stylex.props(styles.heading)}>
<ClientDisplayName client={client} />
</h1>
<p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p>
<Card
title="Details"
description="The name nxdns shows for this device, where that name came from, and when the device was last seen."
style={styles.panel}
>
<dl {...stylex.props(styles.facts)}>
<dt {...stylex.props(styles.term)}>Name</dt>
<dd {...stylex.props(styles.value)}>
<ClientDisplayName client={client} />
</dd>
<dt {...stylex.props(styles.term)}>Provenance</dt>
<dd {...stylex.props(styles.value)}>
{provenance.label}
<span {...stylex.props(styles.provenanceDetail)}> {provenance.detail}</span>
</dd>
<dt {...stylex.props(styles.term)}>First seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(client.first_seen)}</dd>
<dt {...stylex.props(styles.term)}>Last seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd>
</dl>
</Card>
<h2 {...stylex.props(styles.sectionHeading)}>Policy</h2>
<p {...stylex.props(styles.prose)}>
Filtering for this client follows the <strong>{client.group}</strong> group: its safe search setting,
its blocklist sources and its rules.
</p>
<p {...stylex.props(styles.links)}>
<Link
to="/configuration/protection"
search={{ tab: "groups", group: client.group_id }}
{...stylex.props(styles.link, shared.focusRing)}
>
Group settings
</Link>
</p>
<h2 {...stylex.props(styles.sectionHeading)}>Activity</h2>
<p {...stylex.props(styles.links)}>
<Link
to="/activity"
search={{
mode: "history",
client: client.ip,
since: activityUntil - ACTIVITY_WINDOW_SECONDS,
until: activityUntil,
domain: undefined,
blocked: undefined,
}}
onPointerDown={freshenActivityWindow}
onFocus={freshenActivityWindow}
onKeyDown={(event) => {
if (event.key === "Enter") freshenActivityWindow();
}}
{...stylex.props(styles.link, shared.focusRing)}
>
Queries from this client, last 24 hours
</Link>
</p>
</section>
);
}