diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx index d206d440..627dbca9 100644 --- a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx @@ -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(['headscale-nav', 'headscale-view', null]); +const ALLOWED_APP_TYPES = new Set(['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('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]); diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts index ac8932b9..9d5c4236 100644 --- a/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts +++ b/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts @@ -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 }, ], }; diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx index 077d1117..2e14b31d 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx @@ -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/
, 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 = { 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 (
-
-
- -
-
-
Headscale
-
{active ? active.name : 'no server'}
-
-
- - {servers.length > 1 && ( -
-
- Server -
-
- {servers.map((server) => { - const isActive = server.isActive; - return ( - - ); - })} -
-
- )} - -