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
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
242 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|