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,75 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react';
|
||||
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
|
||||
// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
|
||||
// above (HeadscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
|
||||
//
|
||||
// Sections are real links to /headscale/<section>, not channel writes — so they cmd-click into a new tab,
|
||||
// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
|
||||
// being derived in JS, per the navigation audit's Phase 4.
|
||||
|
||||
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
|
||||
servers: Server,
|
||||
nodes: Laptop,
|
||||
users: Users,
|
||||
keys: KeyRound,
|
||||
invites: Smartphone,
|
||||
policy: ShieldCheck,
|
||||
diagnostics: Activity,
|
||||
console: TerminalSquare,
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
type SectionBodyProps = { icon: LucideIcon; label: string; selected: boolean };
|
||||
|
||||
const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => (
|
||||
<>
|
||||
{selected && <span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${selected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
{label}
|
||||
</>
|
||||
);
|
||||
|
||||
export const HeadscaleNav = () => {
|
||||
const { active } = useHeadscaleServers();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<nav className="flex flex-col gap-0.5 px-2 py-3">
|
||||
{HEADSCALE_SECTIONS.map(({ id, label }) => {
|
||||
// Without an active server the domain sections have nothing to act on, so they are rendered as
|
||||
// plain text rather than as anchors — a disabled <a> is not a thing, and a link that goes nowhere
|
||||
// useful is worse than no link.
|
||||
if (id !== 'servers' && !active) {
|
||||
return (
|
||||
<span key={id} title="Select a server first" className={`${ROW} cursor-default opacity-40`}>
|
||||
<SectionBody icon={ICONS[id]} label={label} selected={false} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink
|
||||
key={id}
|
||||
to={headscaleSectionPath(id)}
|
||||
className={({ isActive }) =>
|
||||
`${ROW} ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => <SectionBody icon={ICONS[id]} label={label} selected={isActive} />}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user