offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,8 +63,6 @@ export function App() {
|
||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /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 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.
|
||||
|
||||
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);
|
||||
|
||||
// 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. Pinning the app types is `appTypes` below; this is
|
||||
// the part the framework can't do, because it is about a panel that is *missing* rather than wrong.
|
||||
const workspace = useMemo(() => {
|
||||
if (hasAppType(rawWorkspace.value, 'headscale-servers')) return rawWorkspace;
|
||||
return { ...rawWorkspace, value: defaultLayout };
|
||||
}, [rawWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||
rawWorkspace.setValue(workspace.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 (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{
|
||||
allowed: ['headscale-servers', 'headscale-nav', 'headscale-view'],
|
||||
fallback: 'headscale-view',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'headscale-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{
|
||||
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 +0,0 @@
|
||||
export * from './HeadscaleScreen';
|
||||
@@ -178,7 +178,6 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
||||
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
||||
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||
// things that disappears when uninstalled.
|
||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
|
||||
@@ -15,7 +15,6 @@ export * from './Calendar';
|
||||
export * from './Contacts';
|
||||
export * from './Music';
|
||||
export * from './Soulseek';
|
||||
export * from './Headscale';
|
||||
export * from './Photos';
|
||||
export * from './Jellyfin';
|
||||
export * from './Transmission';
|
||||
|
||||
@@ -27,7 +27,6 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
||||
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
||||
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
||||
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
||||
|
||||
Reference in New Issue
Block a user