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>
76 lines
3.0 KiB
TypeScript
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>
|
|
);
|
|
};
|