the client address follows its name as visible muted text in the query tables, the config lock indicator prints its reason beside the tag except in table rows where a page-level note explains the lock instead, and the locked delete buttons describe themselves through that one visible note. the chart legend tooltip is deleted because a named client is deliberately not addressed in the chart, and the dead series address field went with it. titles that merely repeat visible copyable text stay.
274 lines
9.6 KiB
TypeScript
274 lines
9.6 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
|
import { Link, useSearch } from "@tanstack/react-router";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
|
import { formatTime } from "@/lib/format";
|
|
import type { Client, Group } from "@/lib/types";
|
|
import ClientEditDialog from "./ClientEditDialog";
|
|
import NetworkAssignments from "./NetworkAssignments";
|
|
import { ClientDisplayName } from "./clientIdentity";
|
|
import InlineError from "@/lib/InlineError";
|
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
|
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
|
|
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
|
|
/**
|
|
* Deleting an observed row discards runtime state the file never declared, so
|
|
* it stays live under every authority; deleting a declared row contradicts the
|
|
* file and is the one client DELETE the server answers 403 (ruling 7).
|
|
*
|
|
* The lock fails closed, so it also holds while authority is pending or
|
|
* unreachable. There the file is a possibility and not a fact — `hand_edited`
|
|
* alone cannot say which operator surface set the row — so the sentence names
|
|
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
|
|
*/
|
|
/** The one note every locked Delete on this page describes itself with. */
|
|
const DELETE_LOCK_NOTE_ID = "clients-delete-locked-note";
|
|
|
|
function declaredDeleteNote(authority: Authority): string {
|
|
if (authority.state === "resolved") {
|
|
return "This client is declared in the configuration file; remove it there and restart.";
|
|
}
|
|
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
|
}
|
|
|
|
/**
|
|
* How the confirmation names the row. The address is always there and always
|
|
* unique, so it carries the sentence; a name the operator recognizes leads when
|
|
* the row has one.
|
|
*/
|
|
function clientLabel(client: Client): string {
|
|
const name = client.name !== "" ? client.name : client.learned_name;
|
|
return name === "" ? client.ip : `${name} (${client.ip})`;
|
|
}
|
|
|
|
const styles = stylex.create({
|
|
heading: {
|
|
fontSize: "1.5rem",
|
|
lineHeight: "2rem",
|
|
fontWeight: 600,
|
|
},
|
|
empty: {
|
|
marginTop: "1rem",
|
|
color: colors.textMuted,
|
|
},
|
|
lockNote: {
|
|
marginTop: "1rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
filterBar: {
|
|
marginTop: "1rem",
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
table: {
|
|
width: "100%",
|
|
minWidth: "48rem",
|
|
textAlign: "left",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
headRow: {
|
|
borderBottomWidth: 1,
|
|
borderBottomStyle: "solid",
|
|
borderBottomColor: colors.border,
|
|
color: colors.textMuted,
|
|
},
|
|
bodyRow: {
|
|
borderBottomWidth: 1,
|
|
borderBottomStyle: "solid",
|
|
borderBottomColor: colors.border,
|
|
},
|
|
cell: {
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.5rem",
|
|
},
|
|
right: {
|
|
textAlign: "right",
|
|
},
|
|
addressLink: {
|
|
color: colors.primaryOnSurface,
|
|
textDecorationLine: "none",
|
|
},
|
|
actionGroup: {
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
},
|
|
dangerText: {
|
|
color: colors.danger,
|
|
},
|
|
dimWhenDisabled: {
|
|
opacity: { default: 1, ":disabled": 0.5 },
|
|
},
|
|
});
|
|
|
|
export default function ClientsPage() {
|
|
const { data: clients } = useSuspenseQuery(clientsQuery());
|
|
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
|
const { data: groups } = useSuspenseQuery(groupsQuery());
|
|
const { group } = useSearch({ from: "/shell/clients" });
|
|
const queryClient = useQueryClient();
|
|
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
|
const [editing, setEditing] = useState<Client | null>(null);
|
|
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
|
|
const readOnly = useReadOnlyConfig();
|
|
const authority = useAuthority();
|
|
|
|
// A well-formed id the group list does not contain is a link to a group that
|
|
// has since been deleted. It filters to nothing, which is the truth, and the
|
|
// notice says so rather than quietly showing every client.
|
|
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
|
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
|
|
|
// Authority is polled, so it can turn while the confirmation is open. The
|
|
// dialog reads it on every render rather than trusting the state that opened
|
|
// it, and withdraws the answer that would now fail instead of withdrawing the
|
|
// question: the operator is told why, and only they close the dialog.
|
|
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
|
|
|
|
// The reason a locked Delete will not answer, printed once above the table.
|
|
// Per row it would repeat down the whole page; on the button it was a `title`
|
|
// that a keyboard and a touch screen never reached.
|
|
const deletesLocked = readOnly && rows.some((client) => client.hand_edited);
|
|
|
|
return (
|
|
<section>
|
|
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
|
{group !== undefined && (
|
|
<div {...stylex.props(styles.filterBar)}>
|
|
<span>
|
|
{filterGroup !== undefined
|
|
? `Showing clients in ${filterGroup.name}.`
|
|
: `No group with id ${group} exists.`}
|
|
</span>
|
|
<Link to="/clients" search={{}} {...stylex.props(shared.linkButton, shared.focusRing)}>
|
|
Clear filter
|
|
</Link>
|
|
</div>
|
|
)}
|
|
{/* The whole explanation for this page's locks, printed once. Per row it
|
|
would repeat down the table; on the controls it was a `title` that a
|
|
keyboard and a touch screen never reached. */}
|
|
{readOnly && (
|
|
<div {...stylex.props(styles.lockNote)}>
|
|
<p>{lockReason(authority)}.</p>
|
|
{deletesLocked && <p id={DELETE_LOCK_NOTE_ID}>{declaredDeleteNote(authority)}</p>}
|
|
</div>
|
|
)}
|
|
{clients.length === 0 ? (
|
|
<p {...stylex.props(styles.empty)}>
|
|
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
|
nothing to create by hand.
|
|
</p>
|
|
) : rows.length === 0 ? (
|
|
<p {...stylex.props(styles.empty)}>No clients match this filter.</p>
|
|
) : (
|
|
<div {...stylex.props(shared.tableWrap)}>
|
|
<table {...stylex.props(styles.table)}>
|
|
<thead>
|
|
<tr {...stylex.props(styles.headRow)}>
|
|
<th {...stylex.props(styles.cell)}>Address</th>
|
|
<th {...stylex.props(styles.cell)}>Name</th>
|
|
<th {...stylex.props(styles.cell)}>Group</th>
|
|
<th {...stylex.props(styles.cell)}>First seen</th>
|
|
<th {...stylex.props(styles.cell)}>Last seen</th>
|
|
<th {...stylex.props(styles.cell)}>
|
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((client) => (
|
|
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
|
<td {...stylex.props(styles.cell, shared.mono)}>
|
|
<Link
|
|
to="/clients/$id"
|
|
params={{ id: String(client.id) }}
|
|
{...stylex.props(styles.addressLink, shared.focusRing)}
|
|
>
|
|
{client.ip}
|
|
</Link>
|
|
</td>
|
|
<td {...stylex.props(styles.cell)}>
|
|
<ClientDisplayName client={client} />
|
|
</td>
|
|
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
|
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
|
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
|
<td {...stylex.props(styles.cell, styles.right)}>
|
|
<span {...stylex.props(styles.actionGroup)}>
|
|
{/* Naming a client writes configuration, so the affordance is
|
|
absent — not disabled — wherever the write cannot land. */}
|
|
{readOnly ? (
|
|
<ConfigLockIndicator compact />
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(client)}
|
|
{...stylex.props(shared.smallButton, shared.focusRing)}
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setPendingDelete(client)}
|
|
disabled={readOnly && client.hand_edited}
|
|
aria-describedby={
|
|
readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
|
|
}
|
|
{...stylex.props(
|
|
shared.smallButton,
|
|
styles.dangerText,
|
|
styles.dimWhenDisabled,
|
|
shared.focusRing,
|
|
)}
|
|
>
|
|
Delete
|
|
</button>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<ConfirmDialog
|
|
isOpen={pendingDelete !== null}
|
|
title="Delete client"
|
|
message={
|
|
pendingDelete === null
|
|
? ""
|
|
: deleteLocked
|
|
? declaredDeleteNote(authority)
|
|
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
|
|
}
|
|
confirmLabel="Delete"
|
|
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
|
|
onConfirm={() => {
|
|
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
|
|
setPendingDelete(null);
|
|
}}
|
|
onCancel={() => setPendingDelete(null)}
|
|
/>
|
|
<InlineError error={deleteMutation.error} />
|
|
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
|
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
|
</section>
|
|
);
|
|
}
|