import { useState } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import * as stylex from "@stylexjs/stylex"; import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries"; import { formatTime } from "@/lib/format"; import type { Client } from "@/lib/types"; import ClientEditDialog from "./ClientEditDialog"; import PrefixesEditor from "./PrefixesEditor"; import InlineError from "@/lib/InlineError"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; /** * Deleting an observed row discards runtime state the file never declared, so * it stays live under file authority; deleting a hand-edited row contradicts * the file and is the one client DELETE the server answers 403 (ruling 7). */ const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart."; const styles = stylex.create({ heading: { fontSize: "1.5rem", lineHeight: "2rem", fontWeight: 600, }, empty: { marginTop: "1rem", color: colors.textMuted, }, 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", }, dash: { color: colors.textMuted, }, badge: { marginLeft: "0.5rem", borderRadius: "0.25rem", backgroundColor: colors.primary, paddingInline: "0.375rem", paddingBlock: "0.125rem", fontSize: "0.75rem", lineHeight: "1rem", fontWeight: 500, color: colors.primaryText, }, confirmGroup: { display: "inline-flex", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: "0.5rem", }, actionGroup: { display: "inline-flex", gap: "0.5rem", }, note: { fontSize: "0.75rem", lineHeight: "1rem", color: colors.textMuted, }, dangerText: { color: colors.danger, }, dimWhenDisabled: { opacity: { default: 1, ":disabled": 0.5 }, }, /** * The accessible name of the actions column, kept out of the visual table * without leaving the accessibility tree. */ }); export default function ClientsPage() { const { data: clients } = useSuspenseQuery(clientsQuery()); const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery()); const { data: groups } = useSuspenseQuery(groupsQuery()); const queryClient = useQueryClient(); const deleteMutation = useMutation(clientDeleteMutation(queryClient)); const [editing, setEditing] = useState(null); const [confirmingId, setConfirmingId] = useState(null); const readOnly = useReadOnlyConfig(); return (

Clients

{clients.length === 0 ? (

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

) : (
{clients.map((client) => ( ))}
IP Name Group First seen Last seen Actions
{client.ip} {client.name !== "" ? ( client.name ) : client.learned_name !== "" ? ( {client.learned_name} learned ) : ( )} {client.hand_edited && edited} {client.group} {formatTime(client.first_seen)} {formatTime(client.last_seen)} {confirmingId === client.id ? ( Deleted clients re-materialize on their next DNS query. ) : ( )}
)} {editing !== null && setEditing(null)} />}
); }