Files
platform/plugins/offscale/web/PolicyAssistant.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

178 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react';
import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react';
import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy';
import { collapseUnchanged, diffCounts, diffLines } from './diff';
import { Button, Card, ErrorNote } from './Cards';
// Ask for a policy change in English; read the diff; decide.
//
// The whole point of this panel is the middle step. The model is good at the grammar — HuJSON, tagOwners,
// the src/dst shapes — and has no idea which of the owner's machines matter, so its proposal is a draft to
// be read, not an answer to be trusted. Nothing here writes to Headscale: Apply puts the text in the editor
// above and the existing Save button is still the only thing that leaves the browser.
//
// The diff is against what is CURRENTLY in the editor, which is also what was sent up, so it always shows
// exactly what accepting would change on screen — including edits the owner made and hasn't saved.
const EXAMPLES = [
'let everyone reach the machines tagged tag:server on port 22',
'stop the phones from reaching anything except the DNS server',
'add a group for family with just my own user in it',
];
const DiffBody = ({ before, after }: { before: string; after: string }) => {
const lines = useMemo(() => diffLines(before, after), [before, after]);
const rows = useMemo(() => collapseUnchanged(lines), [lines]);
const { added, removed } = useMemo(() => diffCounts(lines), [lines]);
if (!added && !removed) {
return <p className="px-3 py-2.5 text-[11px] text-zinc-500">No change the proposal matches what you have.</p>;
}
return (
<>
<div className="flex items-center gap-3 border-b border-white/10 px-3 py-1.5 text-[11px]">
<span className="text-emerald-400">+{added}</span>
<span className="text-red-400">{removed}</span>
<span className="text-zinc-600">unchanged lines collapsed</span>
</div>
<div className="max-h-72 overflow-auto p-1 font-mono text-[11px] leading-relaxed">
{rows.map((row, index) =>
row === null ? (
<div key={index} className="px-2 py-1 text-center text-zinc-700 select-none">
</div>
) : (
<div
key={index}
className={`px-2 whitespace-pre-wrap ${
row.kind === 'add'
? 'bg-emerald-500/10 text-emerald-300'
: row.kind === 'remove'
? 'bg-red-500/10 text-red-300'
: 'text-zinc-500'
}`}
>
{row.kind === 'add' ? '+' : row.kind === 'remove' ? '' : ' '} {row.text}
</div>
),
)}
</div>
</>
);
};
type PolicyAssistantProps = {
/** The text on screen right now. Sent up as the base, and diffed against. */
policy: string;
/** Accepting a proposal — puts it in the editor's draft. Never saves. */
onApply: (policy: string) => void;
disabled?: boolean;
};
export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => {
const assist = useHeadscalePolicyAssist();
const [prompt, setPrompt] = useState('');
// The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and
// a second ask doesn't briefly show the previous answer against the new base.
const [proposal, setProposal] = useState<{ explanation: string; policy: string } | null>(null);
const ask = async () => {
const request = prompt.trim();
if (!request || assist.isPending) return;
setProposal(null);
try {
setProposal(await assist.mutateAsync({ prompt: request, policy }));
} catch {
// Rendered from `assist.error` below — mutateAsync rejecting is the same failure twice.
}
};
const apply = () => {
if (!proposal) return;
onApply(proposal.policy);
setProposal(null);
setPrompt('');
assist.reset();
};
return (
<Card>
<div className="flex items-center gap-2 border-b border-white/10 px-3 py-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-zinc-200">Describe the change</span>
<span className="ml-auto text-[11px] text-zinc-600">Proposes a document never saves it</span>
</div>
<div className="flex flex-col gap-2 p-3">
<textarea
value={prompt}
onChange={(ev) => setPrompt(ev.target.value)}
onKeyDown={(ev) => {
// Enter sends: this is a one-line instruction far more often than a paragraph, and shift-enter
// is still there for the times it isn't.
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
void ask();
}
}}
rows={2}
spellCheck={false}
disabled={disabled}
placeholder="e.g. give my laptop SSH access to everything tagged tag:server"
className="w-full resize-y rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm leading-relaxed text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50 disabled:opacity-50"
/>
<div className="flex flex-wrap items-center gap-2">
{!prompt.trim() &&
EXAMPLES.map((example) => (
<button
key={example}
type="button"
onClick={() => setPrompt(example)}
disabled={disabled}
className="cursor-pointer rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-500 transition-colors hover:border-white/20 hover:text-zinc-300 disabled:opacity-40"
>
{example}
</button>
))}
<div className="ml-auto">
<Button variant="primary" onClick={() => void ask()} disabled={!prompt.trim() || assist.isPending}>
{assist.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
{assist.isPending ? 'Drafting…' : 'Ask'}
</Button>
</div>
</div>
{assist.error && <ErrorNote>{assistFailure(assist.error)}</ErrorNote>}
</div>
{proposal && (
<div className="border-t border-white/10">
{proposal.explanation && (
<p className="px-3 py-2.5 text-xs leading-relaxed whitespace-pre-wrap text-zinc-300">
{proposal.explanation}
</p>
)}
<div className="border-t border-white/10">
<DiffBody before={policy} after={proposal.policy} />
</div>
<div className="flex items-center gap-2 border-t border-white/10 px-3 py-2">
<span className="text-[11px] text-zinc-600">Applying only fills the editor you still press Save.</span>
<span className="ml-auto flex items-center gap-2">
<Button onClick={() => setProposal(null)}>
<X className="h-3.5 w-3.5" />
Discard
</Button>
<Button variant="primary" onClick={apply}>
<Check className="h-3.5 w-3.5" />
Apply to editor
</Button>
</span>
</div>
</div>
)}
</Card>
);
};