milestone 32: task-shaped configuration, file mode as a rendering, config status api
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
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
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user