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.
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
|
|
import type { OffscaleServer, OffscaleHealth } from './shared';
|
|
import { MIN_OFFSCALE_VERSION } from './shared';
|
|
import { useOffscaleServers, useOffscaleHealth, offscaleErrorMessage } from './useOffscaleServers';
|
|
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
|
|
import { ServerForm } from './ServerForm';
|
|
|
|
// The servers section — register Headscale servers and switch between them. Exactly one is active at a
|
|
// time (a DB invariant, not a UI convention), and every other section in this workspace reads it.
|
|
//
|
|
// EVERY server is probed when this section opens, in parallel, and again for any server registered while
|
|
// it is open. A probe costs two upstream round trips (an unauthenticated /version plus an authenticated
|
|
// call to prove the stored key still works) — cheap enough at this scale, and the alternative was worse:
|
|
// a grey "not checked" dot is the one thing this list must never show, because the reason to look at it
|
|
// is to find out which servers are up. A dot that says nothing makes the whole page say nothing.
|
|
|
|
function timeAgo(iso: string): string {
|
|
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
|
if (!Number.isFinite(seconds)) return 'unknown';
|
|
if (seconds < 60) return 'just now';
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${Math.round(hours / 24)}d ago`;
|
|
}
|
|
|
|
type ServerRowProps = {
|
|
server: OffscaleServer;
|
|
health: OffscaleHealth | undefined;
|
|
testing: boolean;
|
|
busy: boolean;
|
|
onActivate: () => void;
|
|
onTest: () => void;
|
|
onEdit: () => void;
|
|
onRemove: () => void;
|
|
};
|
|
|
|
const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit, onRemove }: ServerRowProps) => {
|
|
const [confirming, setConfirming] = useState(false);
|
|
|
|
// Amber means "asking"; grey should only ever be the frame before the automatic probe starts.
|
|
const tone = health ? (health.ok ? 'ok' : 'bad') : testing ? 'warn' : 'idle';
|
|
|
|
return (
|
|
<Card>
|
|
<div className="flex flex-col gap-3 p-3.5">
|
|
<div className="flex items-start gap-2.5">
|
|
<span className="mt-1.5">
|
|
<Dot tone={tone} />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-zinc-100">{server.name}</span>
|
|
{server.isActive && <Badge tone="active">Active</Badge>}
|
|
</div>
|
|
<div className="mt-0.5 truncate text-xs text-zinc-500" title={server.url}>
|
|
{server.url}
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-600">
|
|
<span>{server.version ? `Headscale ${server.version}` : 'version unknown'}</span>
|
|
{server.lastSeenAt && <span>· reached {timeAgo(server.lastSeenAt)}</span>}
|
|
{health?.ok && <span className="text-emerald-400/80">· responded in {health.ms}ms</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{health && !health.ok && <ErrorNote>{health.error ?? 'The server did not respond'}</ErrorNote>}
|
|
{health?.ok && health.supported === 'unknown' && (
|
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
|
|
This server reports its version as “{health.version}”, so Officer cannot confirm it is{' '}
|
|
{MIN_OFFSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
|
|
builds.
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{!server.isActive && (
|
|
<Button variant="primary" onClick={onActivate} disabled={busy}>
|
|
<Check className="h-3.5 w-3.5" />
|
|
Use this server
|
|
</Button>
|
|
)}
|
|
<Button onClick={onTest} disabled={testing}>
|
|
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Activity className="h-3.5 w-3.5" />}
|
|
{testing ? 'Testing…' : 'Test'}
|
|
</Button>
|
|
<Button onClick={onEdit} disabled={busy}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</Button>
|
|
{confirming ? (
|
|
<>
|
|
<Button variant="danger" onClick={onRemove} 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>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
|
|
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
|
|
<Server className="h-6 w-6" />
|
|
</div>
|
|
<div>
|
|
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
|
|
<p className="mt-1 text-sm text-zinc-500">
|
|
Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here. Officer
|
|
supports Headscale {MIN_OFFSCALE_VERSION} and newer.
|
|
</p>
|
|
</div>
|
|
<Button variant="primary" onClick={onRegister}>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Register a server
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
export const ServersView = () => {
|
|
const { servers, isLoading, error, refetch, activate, remove } = useOffscaleServers();
|
|
const healthProbe = useOffscaleHealth();
|
|
|
|
const [formFor, setFormFor] = useState<'new' | OffscaleServer | null>(null);
|
|
const [health, setHealth] = useState<Record<number, OffscaleHealth>>({});
|
|
// Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be.
|
|
const [testingIds, setTestingIds] = useState<readonly number[]>([]);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
|
|
const test = async (id: number) => {
|
|
setTestingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
|
setActionError(null);
|
|
try {
|
|
const result = await healthProbe.mutateAsync(id);
|
|
setHealth((prev) => ({ ...prev, [id]: result }));
|
|
} catch (err) {
|
|
// A probe that throws is still an answer about the server: record it as a red dot rather than as a
|
|
// page-level error, which would blame the whole screen for one unreachable box.
|
|
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: offscaleErrorMessage(err), ms: 0 } }));
|
|
} finally {
|
|
setTestingIds((prev) => prev.filter((t) => t !== id));
|
|
}
|
|
};
|
|
|
|
// Probe every server once per visit to this section, and any server that appears while it is open. The
|
|
// ref is what makes "once" true: the list identity changes when a probe writes lastSeenAt, and without
|
|
// it each result would trigger the next round forever.
|
|
const probed = useRef(new Set<number>());
|
|
useEffect(() => {
|
|
for (const server of servers) {
|
|
if (probed.current.has(server.id)) continue;
|
|
probed.current.add(server.id);
|
|
void test(server.id);
|
|
}
|
|
}, [servers]);
|
|
|
|
const run = async (fn: () => Promise<unknown>) => {
|
|
setActionError(null);
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
setActionError(offscaleErrorMessage(err));
|
|
}
|
|
};
|
|
|
|
const busy = activate.isPending || remove.isPending;
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto p-4">
|
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
|
|
<SectionHeader
|
|
title="Servers"
|
|
subtitle="One server is active at a time; every other section acts on it."
|
|
action={
|
|
!formFor && (
|
|
<Button variant="primary" onClick={() => setFormFor('new')} disabled={isLoading}>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Register a server
|
|
</Button>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{/* A failed list fetch is reported here, NOT as a replacement for the whole section. It used to be
|
|
an early return, which unmounted the form mid-registration and threw away everything typed into
|
|
it — leaving a reload as the only way to try again. Nothing on this screen may take the form
|
|
off the page except the owner. */}
|
|
{error && (
|
|
<ErrorNote>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span>
|
|
Could not reach the Headscale sidecar: {offscaleErrorMessage(error)}. If it is not running, start it
|
|
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-offscale</code>.
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => void refetch()}
|
|
className="ml-auto shrink-0 cursor-pointer rounded-lg border border-red-500/30 px-2 py-1 font-medium transition-colors hover:bg-red-500/20"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
</ErrorNote>
|
|
)}
|
|
|
|
{/* Keyed by which server it edits: the form seeds its fields from the prop once, at mount, so
|
|
switching straight from one server's Edit to another's would otherwise keep the first one's
|
|
values — and submit diffs those stale values against the NEW server, writing them to it. */}
|
|
{formFor && (
|
|
<ServerForm
|
|
key={formFor === 'new' ? 'new' : formFor.id}
|
|
server={formFor === 'new' ? null : formFor}
|
|
onClose={() => setFormFor(null)}
|
|
/>
|
|
)}
|
|
|
|
{actionError && <ErrorNote>{actionError}</ErrorNote>}
|
|
|
|
{isLoading && (
|
|
<div className="flex items-center justify-center py-16 text-sm text-zinc-500">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Loading servers…
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state doubles as the registration prompt — there is nothing else to do here without a
|
|
server. Hidden while the form is open, because it is then the same offer twice. */}
|
|
{!isLoading && !error && servers.length === 0 && !formFor && (
|
|
<EmptyState onRegister={() => setFormFor('new')} />
|
|
)}
|
|
|
|
{servers.map((server) => (
|
|
<ServerRow
|
|
key={server.id}
|
|
server={server}
|
|
health={health[server.id]}
|
|
testing={testingIds.includes(server.id)}
|
|
busy={busy}
|
|
onActivate={() => void run(() => activate.mutateAsync(server.id))}
|
|
onTest={() => void test(server.id)}
|
|
onEdit={() => setFormFor(server)}
|
|
onRemove={() => void run(() => remove.mutateAsync(server.id))}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|