The tailnet plugin — machines, users, pre-auth keys, access policy and device invites. Moved out of officerdev/platform, where it had lived in plugins/ since the plugin system was built. Until now this code existed in exactly one place: the platform repository. That made "gitignore the plugins directory" impossible to do safely, because untracking it would have left 49 files on a single disk with no remote. This repository is what makes that move safe. Same extraction as plugins/music before it: source only, no history. The platform's history still holds every commit that shaped this, and the SHAs cited across the codebase keep resolving — replaying it here would have created a second, divergent account of the same work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
437 lines
18 KiB
TypeScript
437 lines
18 KiB
TypeScript
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 (
|
|
<div className="flex items-center gap-2 rounded-lg border border-white/5 bg-white/[0.02] px-2.5 py-1.5">
|
|
{isExit ? <Globe className="h-3.5 w-3.5 shrink-0 text-amber-400" /> : <Dot tone={approved ? 'ok' : 'idle'} />}
|
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-zinc-300">{route}</span>
|
|
{isExit && <span className="shrink-0 text-[10px] uppercase tracking-wide text-amber-400/80">exit node</span>}
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => onToggle(!approved)}
|
|
className={`shrink-0 cursor-pointer rounded-md border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
|
|
approved
|
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'
|
|
: 'border-white/10 text-zinc-400 hover:bg-white/10 hover:text-zinc-100'
|
|
}`}
|
|
>
|
|
{approved ? 'Approved' : 'Approve'}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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<unknown>) => {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
onError(headscaleErrorMessage(err));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2.5">
|
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Owner and tags</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<UserRound className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
|
<select
|
|
value={owner}
|
|
onChange={(ev) => setOwner(ev.target.value)}
|
|
disabled={busy || pending}
|
|
className="min-w-0 flex-1 cursor-pointer rounded-md border border-white/10 bg-black/40 px-2 py-1 text-xs text-zinc-200 outline-none focus:border-primary/50 disabled:opacity-40"
|
|
>
|
|
{!node.user && <option value="">no owner</option>}
|
|
{users.map((user) => (
|
|
<option key={user.id} value={user.id}>
|
|
{user.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{ownerDirty && (
|
|
<>
|
|
<Button
|
|
onClick={() => void run(() => moveToUser.mutateAsync({ id: node.id, userId: owner }))}
|
|
disabled={busy || pending}
|
|
>
|
|
<ArrowRightLeft className="h-3.5 w-3.5" />
|
|
Move
|
|
</Button>
|
|
<Button onClick={() => setOwner(node.user?.id ?? '')} disabled={busy || pending}>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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 && (
|
|
<p className="text-[11px] leading-snug text-amber-400/90">
|
|
Moving this node to <span className="font-medium">{target?.name ?? 'another user'}</span> 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.
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<TagIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
|
<input
|
|
value={draftTags}
|
|
onChange={(ev) => 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 && (
|
|
<>
|
|
<Button
|
|
onClick={() => void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }))}
|
|
disabled={busy || pending}
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
Apply tags
|
|
</Button>
|
|
<Button onClick={() => setDraftTags(node.tags.join(' '))} disabled={busy || pending}>
|
|
Revert
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<p className="text-[11px] leading-snug text-zinc-600">
|
|
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 <span className="text-zinc-500">tag:</span> prefix is added for you.
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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<unknown>) => {
|
|
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 (
|
|
<Card>
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center gap-2.5 px-3.5 py-3">
|
|
<Dot tone={node.online ? 'ok' : 'idle'} />
|
|
<div className="min-w-0 flex-1">
|
|
{renaming ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
value={draftName}
|
|
onChange={(ev) => 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"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void submitRename()}
|
|
className="cursor-pointer p-1 text-emerald-400"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-zinc-100">
|
|
<span className="tabular-nums text-zinc-500">{node.id}:</span> {node.hostname}{' '}
|
|
<span className="font-normal text-zinc-500">({node.name})</span>
|
|
</span>
|
|
{node.user && <Badge>{node.user.name}</Badge>}
|
|
{node.isExitNode && <Badge>exit</Badge>}
|
|
{node.tags.map((tag) => (
|
|
<Badge key={tag} tone="active">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
|
|
<span className="font-mono">{node.ipAddresses[0] ?? 'no address'}</span>
|
|
<span>· {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`}</span>
|
|
{node.subnetRoutes.length > 0 && <span>· {node.subnetRoutes.length} route(s) active</span>}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-label={open ? 'Collapse' : 'Expand'}
|
|
className="shrink-0 cursor-pointer p-1 text-zinc-500 transition-colors hover:text-zinc-200"
|
|
>
|
|
<ChevronRight className={`h-4 w-4 transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{open && (
|
|
<div className="flex flex-col gap-3 border-t border-white/10 bg-black/30 p-3">
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
|
|
<div className="text-zinc-500">Addresses</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
{node.ipAddresses.map((ip) => (
|
|
<button
|
|
key={ip}
|
|
type="button"
|
|
onClick={() => copy(ip)}
|
|
title="Copy"
|
|
className="group flex cursor-pointer items-center gap-1 text-left font-mono text-zinc-300"
|
|
>
|
|
{ip}
|
|
<Copy className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-60" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="text-zinc-500">Hostname</div>
|
|
<div className="truncate font-mono text-zinc-300">{node.hostname}</div>
|
|
<div className="text-zinc-500">Registered</div>
|
|
<div className="text-zinc-300">
|
|
{timeAgo(node.createdAt)} · {node.registerMethod}
|
|
</div>
|
|
<div className="text-zinc-500">Key expires</div>
|
|
<div className="text-zinc-300" title={fullDate(node.expiry)}>
|
|
{timeUntil(node.expiry)}
|
|
</div>
|
|
<div className="text-zinc-500">Last seen</div>
|
|
<div className="text-zinc-300" title={fullDate(node.lastSeen)}>
|
|
{node.online ? 'now' : timeAgo(node.lastSeen)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
|
|
Advertised routes
|
|
</div>
|
|
{node.availableRoutes.length === 0 ? (
|
|
<div className="text-[11px] text-zinc-600">This node advertises no routes.</div>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
{node.availableRoutes.map((route) => (
|
|
<RouteRow
|
|
key={route}
|
|
route={route}
|
|
approved={node.approvedRoutes.includes(route)}
|
|
busy={busy}
|
|
onToggle={(approved) => void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Ownership node={node} busy={busy} onError={onError} />
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
onClick={() => {
|
|
setDraftName(node.name);
|
|
setRenaming(true);
|
|
}}
|
|
disabled={busy}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Rename
|
|
</Button>
|
|
<Button
|
|
onClick={() => void run(() => expire.mutateAsync(node.id))}
|
|
disabled={busy}
|
|
title="Expire the node's key — it stays registered but must re-authenticate"
|
|
>
|
|
<TimerReset className="h-3.5 w-3.5" />
|
|
Force re-auth
|
|
</Button>
|
|
{confirming ? (
|
|
<>
|
|
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(node.id))} disabled={busy}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Confirm remove
|
|
</Button>
|
|
<Button onClick={() => setConfirming(false)} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export const NodesView = () => {
|
|
const { nodes, isLoading, error } = useHeadscaleNodes();
|
|
const [filter, setFilter] = useState('');
|
|
const [actionError, setActionError] = useState<string | null>(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 (
|
|
<ViewShell isLoading={isLoading} error={error} label="nodes">
|
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
|
|
<div className="flex items-center gap-3 px-1 pb-1">
|
|
<div className="min-w-0 flex-1">
|
|
<h2 className="text-sm font-semibold text-zinc-100">Nodes</h2>
|
|
<p className="mt-0.5 text-xs text-zinc-500">
|
|
{nodes.length} registered · {online} online
|
|
</p>
|
|
</div>
|
|
<div className="relative w-56 shrink-0">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-600" />
|
|
<input
|
|
value={filter}
|
|
onChange={(ev) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{actionError && <ErrorNote>{actionError}</ErrorNote>}
|
|
|
|
{nodes.length === 0 && (
|
|
<EmptyBody
|
|
icon={<Laptop className="h-6 w-6" />}
|
|
title="No nodes yet"
|
|
hint="Create a pre-auth key and run `tailscale up --login-server <your server> --authkey <key>` on a machine to join it."
|
|
/>
|
|
)}
|
|
{nodes.length > 0 && visible.length === 0 && (
|
|
<div className="py-10 text-center text-sm text-zinc-500">Nothing matches “{filter}”.</div>
|
|
)}
|
|
|
|
{visible.map((node) => (
|
|
<NodeCard key={node.id} node={node} onError={setActionError} />
|
|
))}
|
|
</div>
|
|
</ViewShell>
|
|
);
|
|
};
|