Files
offscale/web/ConsoleView.tsx
pastilhas 95b84ea748 rebrand to OffScale, and fix what the first extraction missed
Offscale was the first plugin extracted and it was done before we knew what
"extracted" meant. Music, done last, is the standard. This brings offscale to it.

── The rebrand ──

The plugin was `offscale` to the platform and `headscale` to itself: sidecar
name and handles, the port announcement, the API proxy name, the React
components, every hook, the react-query keys, the panel ids and appTypes, and
the Postgres table. Now all of those say offscale.

The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and
Headscale is the server it manages. So every IDENTIFIER is offscale, while a
message like `headscale unreachable`, the `headscale apikeys create` hint and the
ACL assistant's prompt still say Headscale — because they are talking about the
remote server, and renaming them would make the code lie about what it reached.
495 occurrences became 180, and the 180 are all of that second kind.

── The live bug this uncovered ──

`headscaleSectionPath` built links to `/headscale/<section>`. The shell has no
such route — plugin routes come from `plugin.route`, which is `/offscale` — and
it redirects unknown paths to the home page. So every section link in the nav,
the console and the server picker silently went home. The extraction moved the
route and left the link builder behind.

Also live: ServersView told the user to run
`pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not
existed since the sidecar was renamed.

── The correctness fix music already had ──

api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips
`prefix.length` characters, so a literal is correct only for a first-party
publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale`
and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does.

── The rest ──

- assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops
  being a glyph badge.
- First tests: 21 of them, over the version floor and the protobuf normalisers.
  Those are the two places a Headscale release actually breaks this, and they had
  no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor
  first would refuse 1.0 as older than 0.29.
- OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts,
  which is not linkable and not published. Now a document, as MUSIC_API.md is.
- web/panels.ts re-exported three components. A plugin cannot export components;
  that was residue of the platform importing them before extraction.
- Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/,
  neither of which has existed since the extraction.

The crypto purpose moved headscale → offscale too, and the secret-store row was
renamed rather than left to create a fresh key — the material is preserved, so
this is reversible. Free to do only because offscale_servers had 0 rows; with one
stored API key it would have been a migration.
2026-08-15 18:41:52 +00:00

97 lines
4.0 KiB
TypeScript

import { useCallback } from 'react';
import { Link } from 'react-router';
import { Loader2, TerminalSquare } from 'lucide-react';
import { offscaleSectionPath } from './shared';
import { useOffscaleServers } from './useOffscaleServers';
import { TerminalView } from 'officerdev';
import { Button } from './Cards';
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
// answer (why headscale won't start, what the logs say, whether the disk is full).
//
// It is deliberately the SAME terminal every other panel uses, driven by nothing more than `ssh <host>` typed
// into a login shell. Officer holds no key, no password and no port: whatever `ssh` on this box can already
// reach, this can reach, and nothing more. If the connection needs a jump host or an odd port, that belongs in
// `~/.ssh/config` as a Host alias — which this field accepts by name.
//
// The session id is derived from the server id rather than minted per panel, so re-opening the Console lands
// back in the shell that is already running and mid-command, and switching servers is a different shell rather
// than the same one re-purposed. TerminalView suppresses its initial input when the sidecar replays a buffer,
// which is what stops a re-attach from typing a second `ssh` inside the first.
const consoleSessionId = (serverId: number) => `headscale-console-${serverId}`;
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<TerminalSquare className="h-6 w-6" />
</div>
{children}
</div>
);
export const ConsoleView = () => {
const { active, isLoading } = useOffscaleServers();
// The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants
// a stable callback and an inline arrow would remount its effect on every render.
const onConnectionChange = useCallback(() => {}, []);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers
</div>
);
}
if (!active) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to open its console.</p>
</div>
</Centred>
);
}
if (!active.sshHost) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No SSH address for {active.name}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">
Add one on the server to open a shell on the machine behind it. Use the machine's own address rather than
the Headscale hostname — the console is most useful exactly when that name has stopped answering.
</p>
</div>
<Link to={offscaleSectionPath('servers')}>
<Button variant="primary">Go to Servers</Button>
</Link>
</Centred>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<TerminalSquare className="h-3.5 w-3.5" />
<span className="truncate">
ssh <span className="font-mono text-zinc-300">{active.sshHost}</span> · {active.name}
</span>
</div>
<TerminalView
// Remount on a server switch: the session id is a mount-time argument, so without this the panel would
// keep showing the previous server's shell under the new server's name.
key={active.id}
className="min-h-0 flex-1 p-2"
sessionId={consoleSessionId(active.id)}
initialInput={`ssh ${active.sshHost}`}
onConnectionChange={onConnectionChange}
/>
</div>
);
};