headscale: give the server picker its own panel
This commit is contained in:
@@ -5,16 +5,17 @@ import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeads
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /headscale uses the Workspace/Panel system (like /soulseek and /music): a section nav (headscale-nav) on
|
||||
// the left and a section view (headscale-view) on the right. Both talk to the officer-headscale sidecar
|
||||
// through the /api/headscale auth proxy, which holds no Headscale credentials of its own — the registered
|
||||
// servers and their keys live in the sidecar.
|
||||
// /headscale uses the Workspace/Panel system (like /soulseek and /music): the server picker
|
||||
// (headscale-servers) above the section nav (headscale-nav) on the left, and the section view
|
||||
// (headscale-view) on the right. All three talk to the officer-headscale sidecar through the /api/headscale
|
||||
// auth proxy, which holds no Headscale credentials of its own — the registered servers and their keys live
|
||||
// in the sidecar.
|
||||
//
|
||||
// The open section is :section in the URL, so both panels read it with useParams instead of passing it
|
||||
// The open section is :section in the URL, so every panel reads it with useParams instead of passing it
|
||||
// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the
|
||||
// single place that decides what an absent or bogus section means.
|
||||
|
||||
const ALLOWED_APP_TYPES = new Set<string | null>(['headscale-nav', 'headscale-view', null]);
|
||||
const ALLOWED_APP_TYPES = new Set<string | null>(['headscale-servers', 'headscale-nav', 'headscale-view', null]);
|
||||
|
||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
@@ -28,12 +29,22 @@ function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||
return changed ? { ...node, children } : node;
|
||||
}
|
||||
|
||||
function hasAppType(node: LayoutNode, appType: string): boolean {
|
||||
if (node.type === 'panel') return node.appType === appType;
|
||||
return node.children.some((c) => hasAppType(c.node, appType));
|
||||
}
|
||||
|
||||
export const HeadscaleScreen = () => {
|
||||
const { section } = useParams();
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
|
||||
|
||||
const workspace = useMemo(() => {
|
||||
const fixed = normalizeLayout(rawWorkspace.value);
|
||||
// A layout saved before the server picker existed has no panel for it, and nothing else would ever add
|
||||
// one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is
|
||||
// cheaper than a screen permanently missing a panel.
|
||||
const fixed = hasAppType(rawWorkspace.value, 'headscale-servers')
|
||||
? normalizeLayout(rawWorkspace.value)
|
||||
: defaultLayout;
|
||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||
return { ...rawWorkspace, value: fixed };
|
||||
}, [rawWorkspace]);
|
||||
|
||||
@@ -5,7 +5,18 @@ export const defaultLayout: LayoutNode = {
|
||||
id: 'headscale-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 22 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'headscale-sidebar',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'headscale-servers', appType: 'headscale-servers' }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 70 },
|
||||
],
|
||||
},
|
||||
size: 22,
|
||||
},
|
||||
{ node: { type: 'panel', id: 'headscale-view', appType: 'headscale-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user