Files
platform/plugins/offscale/web/PolicyView.tsx
T
pastilhasandClaude Opus 5 e13128846b 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>
2026-08-15 00:15:38 +00:00

211 lines
9.3 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react';
import { timeAgo } from './format';
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
import { PolicyAssistant } from './PolicyAssistant';
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
import { ViewShell } from './ViewShell';
// The tailnet's ACL document. A plain textarea on purpose — this is HuJSON, where the comments and the
// hand-kept alignment are half the document's value to whoever maintains it, and a rich editor that
// reformats or a client-side parser that disagrees with Headscale would both destroy more than they add.
//
// It opens READ-ONLY behind an Edit button. This is the document that decides which machine can reach
// which, it is usually being looked at rather than changed, and a textarea focused by a stray click is a
// way to alter it without meaning to. Edit mode also brings up the assistant, because "I do not know what
// this file should look like" is the actual reason this screen was hard to use.
//
// Validation is entirely Headscale's. It has the only parser that counts: it resolves groups, tags and
// host aliases, and it is what will actually enforce the result. Officer sends the text up untouched and
// shows the verdict verbatim — including the line and column, which is the whole reason to show it at all.
//
// Two failures, deliberately styled differently. A REJECTED document is a normal part of editing and stays
// inline next to the save button. A READ-ONLY server means this screen cannot do its job at all and says so
// at the top, permanently, because the owner needs to go and edit a file on the server instead.
/** Ctrl/Cmd-S while the textarea has focus. An ACL is long enough that reaching for the button breaks flow. */
function useSaveShortcut(onSave: () => void, enabled: boolean) {
const handler = useRef(onSave);
handler.current = onSave;
useEffect(() => {
if (!enabled) return;
const onKeyDown = (ev: KeyboardEvent) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 's') {
ev.preventDefault();
handler.current();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}
const ReadOnlyBanner = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-200">
<FileLock2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-amber-100">This server's policy is read-only</div>
<p className="mt-1 text-amber-200/80">
Headscale said: <span className="font-mono">{message}</span>
</p>
<p className="mt-1.5 text-amber-200/70">
It is reading its policy from a file on disk rather than from its database, so the API refuses writes — a save
here would be overwritten on the next restart anyway. Edit the file on the server (the Console section is one
way in) and reload it there. Everything below is still the live document, and still readable.
</p>
</div>
</div>
);
const Rejected = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs leading-relaxed text-red-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-red-200">Headscale rejected this policy</div>
{/* Verbatim, monospaced: it usually carries a line and column, and re-wording it would throw that away. */}
<pre className="mt-1 font-mono text-[11px] whitespace-pre-wrap text-red-300/90">{message}</pre>
<p className="mt-1.5 text-red-300/70">Nothing was saved — the tailnet is still running the previous policy.</p>
</div>
</div>
);
export const PolicyView = () => {
const { policy, isLoading, error, save } = useHeadscalePolicy();
const [draft, setDraft] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [failure, setFailure] = useState<PolicySaveFailure | null>(null);
// Sticky for the session: once a server has refused a write, every later save would refuse identically,
// and re-discovering that by pressing save again is not information.
const [readOnly, setReadOnly] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
// The fetched document seeds the editor once. After that the draft owns the text — a refetch must never
// reach in and replace what someone is typing.
const text = draft ?? policy?.policy ?? '';
const dirty = draft !== null && draft !== (policy?.policy ?? '');
const submit = async () => {
if (!dirty || readOnly || save.isPending) return;
setFailure(null);
try {
await save.mutateAsync(text);
setDraft(null);
setSavedAt(Date.now());
// A clean save is the end of the edit, not the start of the next one — back to reading.
setEditing(false);
} catch (err) {
const parsed = policySaveFailure(err);
setFailure(parsed);
if (parsed.kind === 'readOnly') setReadOnly(parsed.message);
}
};
useSaveShortcut(() => void submit(), editing && dirty && !readOnly);
const revert = () => {
setDraft(null);
setFailure(null);
};
/** Leaving edit mode throws the draft away — there is nowhere else for unsaved text to go. */
const stopEditing = () => {
revert();
setEditing(false);
};
return (
<ViewShell isLoading={isLoading} error={error} label="the access policy">
<div className="mx-auto flex max-w-4xl flex-col gap-3">
<SectionHeader
title="Access policy"
subtitle="HuJSON — JSON with comments and trailing commas. Headscale validates it on save; nothing is stored unless it passes."
action={
editing ? (
<div className="flex items-center gap-2">
<Button onClick={stopEditing} disabled={save.isPending}>
<RotateCcw className="h-3.5 w-3.5" />
{dirty ? 'Discard' : 'Done'}
</Button>
<Button
variant="primary"
onClick={() => void submit()}
disabled={!dirty || !!readOnly || save.isPending}
>
{save.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{save.isPending ? 'Validating' : 'Save'}
</Button>
</div>
) : (
<Button
onClick={() => setEditing(true)}
disabled={!!readOnly}
title={readOnly ? 'This server will not accept written policies' : undefined}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)
}
/>
{readOnly && <ReadOnlyBanner message={readOnly} />}
{failure?.kind === 'rejected' && <Rejected message={failure.message} />}
{failure?.kind === 'unknown' && <ErrorNote>{failure.message}</ErrorNote>}
{editing && (
<PolicyAssistant
policy={text}
onApply={(proposed) => {
setDraft(proposed);
setFailure(null);
setSavedAt(null);
}}
disabled={save.isPending}
/>
)}
<Card>
<textarea
value={text}
onChange={(ev) => {
setDraft(ev.target.value);
setFailure(null);
setSavedAt(null);
}}
spellCheck={false}
autoComplete="off"
readOnly={!editing}
placeholder={'{\n "acls": [\n { "action": "accept", "src": ["*"], "dst": ["*:*"] },\n ],\n}'}
className={`block h-[28rem] w-full resize-y p-4 font-mono text-[12px] leading-relaxed outline-none placeholder:text-zinc-700 ${
editing ? 'bg-black/40 text-zinc-200' : 'bg-black/20 text-zinc-400'
}`}
/>
<div className="flex flex-wrap items-center gap-3 border-t border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<span>
{text.split('\n').length} lines · {text.length} characters
</span>
<span className="ml-auto flex items-center gap-3">
{savedAt !== null && !dirty && (
<span className="flex items-center gap-1 text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
Saved and accepted
</span>
)}
{dirty && <span className="text-amber-400">Unsaved changes</span>}
{policy?.updatedAt && <span>Last changed {timeAgo(policy.updatedAt)}</span>}
</span>
</div>
</Card>
<p className="px-1 text-[11px] leading-relaxed text-zinc-600">
This document decides which node may reach which. A policy that saves cleanly can still cut a machine off —
Headscale checks that the document is valid, not that it is what you meant.
{editing ? ' Ctrl/Cmd-S saves.' : ' Press Edit to change it.'}
</p>
</div>
</ViewShell>
);
};