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
@@ -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 },
],
};