headscale: give the server picker its own panel

This commit is contained in:
2026-08-06 01:11:17 +00:00
parent 5f6377ac33
commit 08948bc4aa
5 changed files with 105 additions and 66 deletions
@@ -1,28 +1,15 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import {
Network,
Server,
Laptop,
Users,
KeyRound,
Smartphone,
ShieldCheck,
Activity,
TerminalSquare,
Check,
} from 'lucide-react';
import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react';
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Left panel of the /headscale workspace: the active-server switcher on top, sections below.
// 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.
//
// The server switcher stays a button on purpose: activating a server is a mutation (a DB write that changes
// which server every other section acts on), not navigation. It has no URL of its own and shouldn't.
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
servers: Server,
@@ -50,49 +37,11 @@ const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => (
);
export const HeadscaleNav = () => {
const { servers, active, activate } = useHeadscaleServers();
const { active } = useHeadscaleServers();
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/15 text-indigo-400 ring-1 ring-black/5">
<Network className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Headscale</div>
<div className="truncate text-xs text-muted-foreground">{active ? active.name : 'no server'}</div>
</div>
</div>
{servers.length > 1 && (
<div className="px-2 pb-3">
<div className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Server
</div>
<div className="flex flex-col gap-0.5">
{servers.map((server) => {
const isActive = server.isActive;
return (
<button
key={server.id}
type="button"
onClick={() => !isActive && activate.mutate(server.id)}
disabled={activate.isPending}
title={server.url}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
isActive ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${isActive ? 'text-primary' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{server.name}</span>
</button>
);
})}
</div>
</div>
)}
<nav className="flex flex-col gap-0.5 px-2 pb-3">
<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
@@ -0,0 +1,60 @@
import { Check, Network, Plus } from 'lucide-react';
import { Link } from 'react-router';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Top-left panel of the /headscale workspace: which server everything else acts on.
//
// It is its own panel rather than a block inside HeadscaleNav because the two answer different questions —
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
// (a DB write that re-scopes every other query), so these stay buttons with no URL of their own, while the
// section list below is real links.
//
// Every registered server is listed, including when there is only one: the panel's whole job is to say what
// the rest of the screen is talking to, and a picker that hides itself at one server makes that invisible.
export const HeadscaleServerPicker = () => {
const { servers, active, activate, isLoading } = useHeadscaleServers();
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/15 text-indigo-400 ring-1 ring-black/5">
<Network className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Headscale</div>
<div className="truncate text-xs text-muted-foreground">{active ? active.name : 'no server'}</div>
</div>
</div>
<div className="flex flex-col gap-0.5 px-2 pb-3">
{servers.map((server) => (
<button
key={server.id}
type="button"
onClick={() => !server.isActive && activate.mutate(server.id)}
disabled={activate.isPending}
title={server.url}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
server.isActive ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${server.isActive ? 'text-primary' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{server.name}</span>
</button>
))}
{servers.length === 0 && !isLoading && (
<Link
to={headscaleSectionPath('servers')}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="h-3.5 w-3.5 shrink-0" />
Register a server
</Link>
)}
</div>
</div>
);
};
@@ -1,12 +1,20 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { PanelLeft, LayoutGrid } from 'lucide-react';
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
import { HeadscaleNav } from './HeadscaleNav';
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
import { HeadscaleView } from './HeadscaleView';
import { HeadscaleViewHeader } from './HeadscaleViewHeader';
export { HeadscaleNav, HeadscaleView };
export { HeadscaleNav, HeadscaleServerPicker, HeadscaleView };
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'headscale-servers',
name: 'Headscale servers',
icon: Network,
component: HeadscaleServerPicker,
availableOnPanel: false,
},
{ key: 'headscale-nav', name: 'Headscale', icon: PanelLeft, component: HeadscaleNav, availableOnPanel: false },
{
key: 'headscale-view',