offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
|
||||
import type { HeadscaleUserWithCounts } from './shared';
|
||||
import { useHeadscaleUsers } from './useHeadscaleData';
|
||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { timeAgo } from './format';
|
||||
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
|
||||
import { ViewShell, EmptyBody } from './ViewShell';
|
||||
|
||||
// The users section. A Headscale user is a namespace that owns nodes and pre-auth keys — not a login.
|
||||
//
|
||||
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
|
||||
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
|
||||
|
||||
type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void };
|
||||
|
||||
const UserRow = ({ user, onError }: UserRowProps) => {
|
||||
const { rename, remove } = useHeadscaleUsers();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [draft, setDraft] = useState(user.name);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const busy = rename.isPending || remove.isPending;
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
onError(headscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const submitRename = async () => {
|
||||
const name = draft.trim();
|
||||
setRenaming(false);
|
||||
if (!name || name === user.name) return;
|
||||
await run(() => rename.mutateAsync({ id: user.id, name }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-center gap-3 p-3.5">
|
||||
<Dot tone={user.onlineCount > 0 ? 'ok' : 'idle'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(ev) => setDraft(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">{user.name}</span>
|
||||
{user.provider && <Badge>{user.provider}</Badge>}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
|
||||
<span>
|
||||
{user.nodeCount} node{user.nodeCount === 1 ? '' : 's'}
|
||||
{user.onlineCount > 0 && `, ${user.onlineCount} online`}
|
||||
</span>
|
||||
{user.email && <span>· {user.email}</span>}
|
||||
<span>· created {timeAgo(user.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDraft(user.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</Button>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(user.id))} disabled={busy}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{user.nodeCount > 0 ? `Delete with ${user.nodeCount} node(s)` : 'Confirm delete'}
|
||||
</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" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
|
||||
const { create } = useHeadscaleUsers();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (!name.trim()) return setError('A name is required');
|
||||
try {
|
||||
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(headscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="flex flex-col gap-3 p-4"
|
||||
>
|
||||
<div className="text-sm font-semibold text-zinc-100">New user</div>
|
||||
<Field
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="laptop-fleet"
|
||||
hint="Lowercase, no spaces. This is the namespace nodes and keys belong to."
|
||||
autoFocus
|
||||
/>
|
||||
<Field label="Email (optional)" value={email} onChange={setEmail} placeholder="someone@example.com" />
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button type="submit" variant="primary" disabled={create.isPending}>
|
||||
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Create user
|
||||
</Button>
|
||||
<Button onClick={onClose} disabled={create.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsersView = () => {
|
||||
const { users, isLoading, error } = useHeadscaleUsers();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<ViewShell isLoading={isLoading} error={error} label="users">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-4 px-1 pb-1">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Users</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">Namespaces that own nodes and pre-auth keys.</p>
|
||||
</div>
|
||||
{!creating && (
|
||||
<Button variant="primary" onClick={() => setCreating(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New user
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating && <CreateUserForm onClose={() => setCreating(false)} />}
|
||||
{actionError && <ErrorNote>{actionError}</ErrorNote>}
|
||||
|
||||
{users.length === 0 && !creating && (
|
||||
<EmptyBody
|
||||
icon={<Users className="h-6 w-6" />}
|
||||
title="No users yet"
|
||||
hint="Every node belongs to a user. Create one before issuing a pre-auth key."
|
||||
/>
|
||||
)}
|
||||
|
||||
{users.map((user) => (
|
||||
<UserRow key={user.id} user={user} onError={setActionError} />
|
||||
))}
|
||||
</div>
|
||||
</ViewShell>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user