Files
nxdns/admin/src/features/clients/ClientDetailPage.tsx
T
mokhtar 7e0df5fd94
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
milestone 32: task-shaped configuration, file mode as a rendering, config status api
2026-08-22 22:42:50 +02:00

226 lines
6.4 KiB
TypeScript

import { useState } from "react";
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 { 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: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
address: {
marginTop: "0.25rem",
color: colors.textSecondary,
},
panel: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
},
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.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
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,
},
loading: {
marginTop: "1rem",
color: colors.textMuted,
},
});
function BackLink() {
return (
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
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>
<div {...stylex.props(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>
</div>
<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>
);
}