import { useState } from 'react'; import { Laptop, Globe, Trash2, Pencil, TimerReset, Check, X, Search, Copy, ChevronRight, UserRound, ArrowRightLeft, Tag as TagIcon, } from 'lucide-react'; import type { HeadscaleNode } from './shared'; import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData'; import { headscaleErrorMessage } from './useHeadscaleServers'; import { timeAgo, timeUntil, fullDate } from './format'; import { Card, Button, Dot, Badge, ErrorNote } from './Cards'; import { ViewShell, EmptyBody } from './ViewShell'; import { copyToClipboard } from 'helpers/clipboard'; // The nodes section — the machines in the tailnet. // // Route approval is the only genuinely dangerous control here, so it is explicit: every route the node // ADVERTISES is listed, each with its own approve/revoke toggle, and an exit node is called what it is // rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved // set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets. const copy = (text: string) => void copyToClipboard(text); type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void }; const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => { const isExit = route === '0.0.0.0/0' || route === '::/0'; return (
{isExit ? : } {route} {isExit && exit node}
); }; /** * Tags as Headscale stores them: every one prefixed `tag:`. Typing the prefix every time is noise, so the * editor accepts either form and normalizes here — which is also how the dirty check stays honest, since * `web` and `tag:web` are the same tag and neither should look like an edit. */ const parseTags = (text: string): string[] => { const parts = text .split(/[\s,]+/) .map((t) => t.trim()) .filter(Boolean); return [...new Set(parts.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)))]; }; /** Set comparison, not sequence: Headscale is free to store the tags in an order the owner didn't type. */ const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t) => b.includes(t)); type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: string) => void }; /** * Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit * together behind the disclosure rather than next to Rename. * * Mounted only while the card is expanded: it needs the user list, and fetching every user to render a * collapsed row would be a request per screenful for a control nobody is looking at. The query key is * shared with the Users section, so an expanded card is usually a cache hit anyway. */ const Ownership = ({ node, busy, onError }: OwnershipProps) => { const { setTags, moveToUser } = useHeadscaleNodes(); const { users } = useHeadscaleUsers(); const [owner, setOwner] = useState(node.user?.id ?? ''); const [draftTags, setDraftTags] = useState(node.tags.join(' ')); const pending = setTags.isPending || moveToUser.isPending; const nextTags = parseTags(draftTags); const tagsDirty = !sameTags(nextTags, node.tags); const ownerDirty = !!owner && owner !== node.user?.id; const target = users.find((u) => u.id === owner); const run = async (fn: () => Promise) => { try { await fn(); } catch (err) { onError(headscaleErrorMessage(err)); } }; return (
Owner and tags
{ownerDirty && ( <> )}
{/* Said before the move, not after: the node keeps its address and its tags, but the rules that let anything reach it are written per user, so it can go dark to everything that used to see it. */} {ownerDirty && (

Moving this node to {target?.name ?? 'another user'} changes which policy rules apply to it. Its addresses and tags stay, but anything reaching it through a rule written for{' '} {node.user?.name ?? 'its current owner'} will stop.

)}
setDraftTags(ev.target.value)} onKeyDown={(ev) => { if (ev.key === 'Enter' && tagsDirty) void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags })); if (ev.key === 'Escape') setDraftTags(node.tags.join(' ')); }} placeholder="tag:server tag:eu — space separated" spellCheck={false} autoComplete="off" className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 font-mono text-[11px] text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-primary/50" /> {tagsDirty && ( <> )}

Tags are what the access policy targets. A tag no rule mentions does nothing; removing one a rule depends on cuts the node off from it. The tag: prefix is added for you.

); }; type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void }; const NodeCard = ({ node, onError }: NodeCardProps) => { const { rename, toggleRoute, expire, remove } = useHeadscaleNodes(); const [open, setOpen] = useState(false); const [renaming, setRenaming] = useState(false); const [draftName, setDraftName] = useState(node.name); const [confirming, setConfirming] = useState(false); const busy = rename.isPending || toggleRoute.isPending || expire.isPending || remove.isPending; const run = async (fn: () => Promise) => { try { await fn(); } catch (err) { onError(headscaleErrorMessage(err)); } }; const submitRename = async () => { const name = draftName.trim(); setRenaming(false); if (!name || name === node.name) return; await run(() => rename.mutateAsync({ id: node.id, name })); }; return (
{renaming ? (
setDraftName(ev.target.value)} onKeyDown={(ev) => { if (ev.key === 'Enter') void submitRename(); if (ev.key === 'Escape') setRenaming(false); }} autoFocus spellCheck={false} className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50" />
) : (
{node.id}: {node.hostname}{' '} ({node.name}) {node.user && {node.user.name}} {node.isExitNode && exit} {node.tags.map((tag) => ( {tag} ))}
)}
{node.ipAddresses[0] ?? 'no address'} · {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`} {node.subnetRoutes.length > 0 && · {node.subnetRoutes.length} route(s) active}
{open && (
Addresses
{node.ipAddresses.map((ip) => ( ))}
Hostname
{node.hostname}
Registered
{timeAgo(node.createdAt)} · {node.registerMethod}
Key expires
{timeUntil(node.expiry)}
Last seen
{node.online ? 'now' : timeAgo(node.lastSeen)}
Advertised routes
{node.availableRoutes.length === 0 ? (
This node advertises no routes.
) : (
{node.availableRoutes.map((route) => ( void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))} /> ))}
)}
{confirming ? ( <> ) : ( )}
)}
); }; export const NodesView = () => { const { nodes, isLoading, error } = useHeadscaleNodes(); const [filter, setFilter] = useState(''); const [actionError, setActionError] = useState(null); const needle = filter.trim().toLowerCase(); const visible = needle ? nodes.filter( (n) => n.id === needle || n.name.toLowerCase().includes(needle) || n.hostname.toLowerCase().includes(needle) || n.user?.name.toLowerCase().includes(needle) || n.ipAddresses.some((ip) => ip.includes(needle)) || n.tags.some((t) => t.toLowerCase().includes(needle)), ) : nodes; const online = nodes.filter((n) => n.online).length; return (

Nodes

{nodes.length} registered · {online} online

setFilter(ev.target.value)} placeholder="Filter by id, name, user, IP, tag" spellCheck={false} className="w-full rounded-lg border border-white/10 bg-black/40 py-1.5 pl-8 pr-2.5 text-xs text-zinc-100 outline-none placeholder:text-zinc-600 focus:border-primary/50" />
{actionError && {actionError}} {nodes.length === 0 && ( } title="No nodes yet" hint="Create a pre-auth key and run `tailscale up --login-server --authkey ` on a machine to join it." /> )} {nodes.length > 0 && visible.length === 0 && (
Nothing matches “{filter}”.
)} {visible.map((node) => ( ))}
); };