Files
offscale/web/HeadscaleNav.tsx
T
pastilhasandClaude Opus 5 8a446bb4b5 offscale, extracted from the platform into its own repository
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>
2026-08-15 18:12:57 +00:00

76 lines
3.0 KiB
TypeScript

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>
);
};