Offscale was the first plugin extracted and it was done before we knew what "extracted" meant. Music, done last, is the standard. This brings offscale to it. ── The rebrand ── The plugin was `offscale` to the platform and `headscale` to itself: sidecar name and handles, the port announcement, the API proxy name, the React components, every hook, the react-query keys, the panel ids and appTypes, and the Postgres table. Now all of those say offscale. The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and Headscale is the server it manages. So every IDENTIFIER is offscale, while a message like `headscale unreachable`, the `headscale apikeys create` hint and the ACL assistant's prompt still say Headscale — because they are talking about the remote server, and renaming them would make the code lie about what it reached. 495 occurrences became 180, and the 180 are all of that second kind. ── The live bug this uncovered ── `headscaleSectionPath` built links to `/headscale/<section>`. The shell has no such route — plugin routes come from `plugin.route`, which is `/offscale` — and it redirects unknown paths to the home page. So every section link in the nav, the console and the server picker silently went home. The extraction moved the route and left the link builder behind. Also live: ServersView told the user to run `pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not existed since the sidecar was renamed. ── The correctness fix music already had ── api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips `prefix.length` characters, so a literal is correct only for a first-party publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale` and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does. ── The rest ── - assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops being a glyph badge. - First tests: 21 of them, over the version floor and the protobuf normalisers. Those are the two places a Headscale release actually breaks this, and they had no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor first would refuse 1.0 as older than 0.29. - OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts, which is not linkable and not published. Now a document, as MUSIC_API.md is. - web/panels.ts re-exported three components. A plugin cannot export components; that was residue of the platform importing them before extraction. - Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/, neither of which has existed since the extraction. The crypto purpose moved headscale → offscale too, and the secret-store row was renamed rather than left to create a fresh key — the material is preserved, so this is reversible. Free to do only because offscale_servers had 0 rows; with one stored API key it would have been a migration.
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 { OffscaleNode } from './shared';
|
|
import { useOffscaleNodes, useOffscaleUsers } from './useOffscaleData';
|
|
import { offscaleErrorMessage } from './useOffscaleServers';
|
|
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: OffscaleNode; 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 } = useOffscaleNodes();
|
|
const { users } = useOffscaleUsers();
|
|
|
|
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(offscaleErrorMessage(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: OffscaleNode; onError: (message: string) => void };
|
|
|
|
const NodeCard = ({ node, onError }: NodeCardProps) => {
|
|
const { rename, toggleRoute, expire, remove } = useOffscaleNodes();
|
|
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(offscaleErrorMessage(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 } = useOffscaleNodes();
|
|
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>
|
|
);
|
|
};
|