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.
178 lines
7.2 KiB
TypeScript
178 lines
7.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react';
|
||
import { useOffscalePolicyAssist, assistFailure } from './useOffscalePolicy';
|
||
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 = useOffscalePolicyAssist();
|
||
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>
|
||
);
|
||
};
|