drive the headscale section from the url

sections were held in a usePanelChannel, which is exactly the opaque
click the navigation audit catalogues: the id lived in an onClick
closure, so a section could not be linked to, opened in a new tab or
reached with the back button.

/headscale/:section is now the source of truth. nav items are real
NavLinks with the active class coming from the router rather than
derived in js, and the screen redirects bare or unknown sections to a
canonical url so the highlight always matches the address bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 15:36:39 +00:00
co-authored by Claude Opus 5
parent 62dfc572d0
commit 17522be71a
8 changed files with 92 additions and 42 deletions
+1
View File
@@ -43,6 +43,7 @@ export function App() {
<Route path="/music" element={<Dashboard.MusicScreen />} /> <Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} /> <Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} /> <Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} /> <Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} /> <Route path="/activity" element={<Dashboard.ActivityScreen />} />
@@ -1,13 +1,18 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev'; import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev'; import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout'; import { defaultLayout } from './defaultLayout';
// /headscale uses the Workspace/Panel system (like /soulseek and /music): a section nav (headscale-nav) on // /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, coordinating via the 'headscale:section' // the left and a section view (headscale-view) on the right. Both talk to the officer-headscale sidecar
// channel. Both talk to the officer-headscale sidecar through the /api/headscale auth proxy, which holds no // through the /api/headscale auth proxy, which holds no Headscale credentials of its own — the registered
// Headscale credentials of its own — the registered servers and their keys live in the sidecar. // 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
// 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-nav', 'headscale-view', null]);
@@ -24,6 +29,7 @@ function normalizeLayout(node: LayoutNode): LayoutNode {
} }
export const HeadscaleScreen = () => { export const HeadscaleScreen = () => {
const { section } = useParams();
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout); const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
const workspace = useMemo(() => { const workspace = useMemo(() => {
@@ -38,6 +44,13 @@ export const HeadscaleScreen = () => {
} }
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
// Bare /headscale, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
// default while the address bar says something else — the nav highlight is derived from the URL, so a URL
// that names nothing would leave nothing highlighted.
if (!isHeadscaleSection(section)) {
return <Navigate to={headscaleSectionPath(DEFAULT_HEADSCALE_SECTION)} replace />;
}
return ( return (
<div className="h-full w-full pt-2"> <div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked /> <WorkspaceView workspace={workspace} locked />
@@ -1,14 +1,17 @@
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Network, Server, Laptop, Users, KeyRound, Check } from 'lucide-react'; import { Network, Server, Laptop, Users, KeyRound, Check } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers'; import { useHeadscaleServers } from './useHeadscaleServers';
// Left panel of the /headscale workspace: the active-server switcher on top, sections below. Publishes the // Left panel of the /headscale workspace: the active-server switcher on top, sections below.
// selected section on 'headscale:section'; HeadscaleView (right) renders the matching UI.
// //
// Switching servers is the primary action here rather than a buried setting — the owner runs several // Sections are real links to /headscale/<section>, not channel writes — so they cmd-click into a new tab,
// control servers and every other section is scoped to whichever is active. // 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> = { const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
servers: Server, servers: Server,
@@ -17,8 +20,21 @@ const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
keys: KeyRound, keys: KeyRound,
}; };
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 = () => { export const HeadscaleNav = () => {
const [section, setSection] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
const { servers, active, activate } = useHeadscaleServers(); const { servers, active, activate } = useHeadscaleServers();
return ( return (
@@ -63,30 +79,30 @@ export const HeadscaleNav = () => {
<nav className="flex flex-col gap-0.5 px-2 pb-3"> <nav className="flex flex-col gap-0.5 px-2 pb-3">
{HEADSCALE_SECTIONS.map(({ id, label }) => { {HEADSCALE_SECTIONS.map(({ id, label }) => {
const Icon = ICONS[id]; // Without an active server the domain sections have nothing to act on, so they are rendered as
const selected = section === id; // plain text rather than as anchors — a disabled <a> is not a thing, and a link that goes nowhere
// Without an active server there is nothing for the domain sections to act on. // useful is worse than no link.
const disabled = id !== 'servers' && !active; 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 ( return (
<button <NavLink
key={id} key={id}
type="button" to={headscaleSectionPath(id)}
onClick={() => setSection(id)} className={({ isActive }) =>
disabled={disabled} `${ROW} ${
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${ isActive
selected ? 'bg-primary/10 font-medium text-primary'
? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground' }`
} ${disabled ? 'cursor-default opacity-40 hover:bg-transparent hover:text-muted-foreground' : ''}`} }
> >
{selected && ( {({ isActive }) => <SectionBody icon={ICONS[id]} label={label} selected={isActive} />}
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" /> </NavLink>
)}
<Icon
className={`h-4 w-4 shrink-0 ${selected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</button>
); );
})} })}
</nav> </nav>
@@ -1,17 +1,16 @@
import { usePanelChannel } from 'hooks/usePanelChannel'; import { useHeadscaleSection } from './useHeadscaleSection';
import { HEADSCALE_SECTION_CHANNEL, type HeadscaleSectionId } from './shared';
import { ServersView } from './ServersView'; import { ServersView } from './ServersView';
import { NodesView } from './NodesView'; import { NodesView } from './NodesView';
import { UsersView } from './UsersView'; import { UsersView } from './UsersView';
import { KeysView } from './KeysView'; import { KeysView } from './KeysView';
// Right panel of the /headscale workspace — renders the section the nav selected. // Right panel of the /headscale workspace — renders the section named by the URL.
// //
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case // Every section except `servers` acts on whichever server is active; each handles the "none selected" case
// itself through ViewShell, so there is no gating to do here. // itself through ViewShell, so there is no gating to do here.
export const HeadscaleView = () => { export const HeadscaleView = () => {
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers'); const section = useHeadscaleSection();
switch (section) { switch (section) {
case 'nodes': case 'nodes':
@@ -1,13 +1,13 @@
import { Network } from 'lucide-react'; import { Network } from 'lucide-react';
import { useHeadscaleServers } from './useHeadscaleServers'; import { useHeadscaleServers } from './useHeadscaleServers';
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared'; import { HEADSCALE_SECTIONS } from './shared';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { useHeadscaleSection } from './useHeadscaleSection';
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is // Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous. // acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
export const HeadscaleViewHeader = () => { export const HeadscaleViewHeader = () => {
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers'); const section = useHeadscaleSection();
const { active } = useHeadscaleServers(); const { active } = useHeadscaleServers();
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale'; const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
@@ -4,9 +4,6 @@
// floor), so these types are stable across Headscale releases and the browser never learns the upstream // floor), so these types are stable across Headscale releases and the browser never learns the upstream
// version. See src/servers/sidecar/headscale/routes.ts. // version. See src/servers/sidecar/headscale/routes.ts.
/** Selected section, published by HeadscaleNav and consumed by HeadscaleView. */
export const HEADSCALE_SECTION_CHANNEL = 'headscale:section';
export const HEADSCALE_SECTIONS = [ export const HEADSCALE_SECTIONS = [
{ id: 'servers', label: 'Servers' }, { id: 'servers', label: 'Servers' },
{ id: 'nodes', label: 'Nodes' }, { id: 'nodes', label: 'Nodes' },
@@ -16,6 +13,15 @@ export const HEADSCALE_SECTIONS = [
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id']; export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
/** Where /headscale lands, and where an unrecognised section redirects to. */
export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';
export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
HEADSCALE_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${id}`;
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */ /** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
export type HeadscaleServer = { export type HeadscaleServer = {
id: number; id: number;
@@ -0,0 +1,12 @@
import { useParams } from 'react-router';
import { DEFAULT_HEADSCALE_SECTION, isHeadscaleSection, type HeadscaleSectionId } from './shared';
// The URL is the source of truth for which section is open — not a panel channel. See docs/navigation-audit.md:
// selection held in a channel means the id lives only in an onClick closure, so the section can't be linked to,
// opened in a new tab, or reached with the back button. HeadscaleScreen redirects anything unrecognised, so the
// fallback here is only for the instant before that lands.
export function useHeadscaleSection(): HeadscaleSectionId {
const { section } = useParams();
return isHeadscaleSection(section) ? section : DEFAULT_HEADSCALE_SECTION;
}
+3
View File
@@ -27,6 +27,9 @@ export * from './apps/Chat/types';
export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory'; export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory';
export type { SelectedSession } from './apps/ChatHistory'; export type { SelectedSession } from './apps/ChatHistory';
export { CodeEditorView } from './apps/CodeEditor'; export { CodeEditorView } from './apps/CodeEditor';
// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL.
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';
export type { HeadscaleSectionId } from './apps/Headscale/shared';
export { export {
useFilesAPI, useFilesAPI,
useTasks, useTasks,