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
226 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|