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 ( ← All clients ); } /** * 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 (

Loading client…

); } if (data === undefined) { return (
void refetch()} />
); } const client = data.find((row) => row.id === clientId); if (client === undefined) { return (

No such client

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.

); } const provenance = provenanceOf(client, authority); return (

{client.ip}

Name
Provenance
{provenance.label} — {provenance.detail}
First seen
{formatTime(client.first_seen)}
Last seen
{formatTime(client.last_seen)}

Policy

Filtering for this client follows the {client.group} group: its safe search setting, its blocklist sources and its rules.

Group settings

Activity

{ if (event.key === "Enter") freshenActivityWindow(); }} {...stylex.props(styles.link, shared.focusRing)} > Queries from this client, last 24 hours

); }