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 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. */ 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, }, 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(null); const [pendingDelete, setPendingDelete] = useState(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; return (

Clients

{group !== undefined && (
{filterGroup !== undefined ? `Showing clients in ${filterGroup.name}.` : `No group with id ${group} exists.`} Clear filter
)} {clients.length === 0 ? (

No clients yet. Rows appear automatically as devices on the network make DNS queries — there is nothing to create by hand.

) : rows.length === 0 ? (

No clients match this filter.

) : (
{rows.map((client) => ( ))}
Address Name Group First seen Last seen Actions
{client.ip} {client.group} {formatTime(client.first_seen)} {formatTime(client.last_seen)} {/* Naming a client writes configuration, so the affordance is absent — not disabled — wherever the write cannot land. */} {readOnly ? ( ) : ( )}
)} : undefined} onConfirm={() => { if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id); setPendingDelete(null); }} onCancel={() => setPendingDelete(null)} /> {editing !== null && setEditing(null)} />}
); }