breakglass: in-cluster emergency-recovery UI for the devvm
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Viktor wanted a web UI on the claude service to act as his breakglass when
the devvm is down: open it, have Claude SSH in to diagnose/repair, and
power-cycle the VM via the Proxmox host if needed. This is the app half
(the infra stack + host bootstrap live in the infra repo).

New, ISOLATED ASGI app under app/breakglass/ (never imports app.main, so the
untrusted-input agents — recruiter-triage, nextcloud-todos — can't share a
process with the root-on-devvm / PVE-reset SSH key):
- pve.py: the LLM-independent power-verb path (status|forensics|reset|stop|
  start|cycle on VM 102), whitelist-validated client-side, executed over the
  forced-command SSH key (list argv, no shell).
- agent_session.py: multi-turn streamed chat — claude -p --session-id /
  --resume with --output-format stream-json, translated to a small SSE
  vocabulary (session/text/tool/result/error/done).
- auth.py: edge Authentik header OR bearer; fail-closed.
- server.py: FastAPI (session/chat-SSE/pve-verb routes) + serves the Svelte UI.
- Svelte SPA (frontend/, built into app/breakglass/static/ and committed — no
  in-cluster build, per ADR-0002): streamed chat + danger-styled manual VM
  controls with confirm-on-mutate.
- agents/breakglass.md: narrow tools (Bash/Read/Grep/Glob, no web), taught the
  ssh devvm / ssh pve aliases and cycle-vs-reset.
- docker-entrypoint-breakglass.sh: ssh-agent bootstrap from the mounted key +
  ssh aliases, then uvicorn app.breakglass.server. The breakglass Deployment
  overrides the image CMD with this; the existing service is untouched.

26 new tests (verb whitelist incl. injection attempts, stream-json→SSE
translation, auth gating, route behaviour); full suite 58 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-12 21:36:05 +00:00
parent 694530135d
commit 4f361d91eb
28 changed files with 3889 additions and 0 deletions

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex, nofollow" />
<title>devvm breakglass</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1179
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
frontend/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "breakglass-ui",
"version": "0.1.0",
"private": true,
"description": "devvm breakglass — emergency recovery SPA (served by the in-cluster FastAPI app)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "7.1.2",
"svelte": "5.56.3",
"vite": "8.0.16"
}
}

272
frontend/src/App.svelte Normal file
View file

@ -0,0 +1,272 @@
<script>
import { onMount } from 'svelte';
import { openSession } from './lib/api.js';
import Chat from './Chat.svelte';
import VmControls from './VmControls.svelte';
// ── session lifecycle ────────────────────────────────────────────────────
// sessionId is the id we POST with. The backend also reports an authoritative
// id in the first {kind:"session"} frame of a turn; Chat bubbles that up so
// the rail always shows what the agent is actually resuming.
let sessionId = $state('');
let sessionState = $state('connecting'); // connecting | ready | error
let sessionError = $state('');
let streaming = $state(false); // a chat turn is in flight (drives the rail dot)
async function newSession() {
sessionState = 'connecting';
sessionError = '';
try {
sessionId = await openSession();
sessionState = 'ready';
} catch (err) {
sessionState = 'error';
sessionError = err instanceof Error ? err.message : String(err);
}
}
onMount(newSession);
// Chat reports the live session id from the stream's session frame.
function onLiveSession(id) {
if (id) sessionId = id;
}
const shortId = $derived(sessionId ? sessionId.slice(0, 8) : '────────');
const dotState = $derived(
sessionState === 'error' ? 'error' : streaming ? 'busy' : sessionState === 'ready' ? 'ready' : 'idle'
);
</script>
<div class="shell">
<header class="rail">
<div class="rail-title">
<span class="glyph" aria-hidden="true">🔧</span>
<h1>devvm <span class="accent">breakglass</span></h1>
<span class="rail-tag">emergency recovery</span>
</div>
<div class="rail-status">
<span class="dot dot--{dotState}" aria-hidden="true"></span>
<span class="rail-session">
{#if sessionState === 'error'}
<span class="session-bad">session unavailable</span>
{:else if sessionState === 'connecting'}
<span class="session-meta">opening session…</span>
{:else}
<span class="session-label">session</span>
<code class="session-id" title={sessionId}>{shortId}</code>
{#if streaming}<span class="session-meta">· agent working</span>{/if}
{/if}
</span>
<button
class="new-session"
onclick={newSession}
disabled={streaming || sessionState === 'connecting'}
title={streaming ? 'wait for the current turn to finish' : 'start a fresh session'}
>
New session
</button>
</div>
</header>
{#if sessionState === 'error'}
<div class="rail-error" role="alert">
Could not reach the breakglass backend — {sessionError}. The cluster or
network may be down. The manual VM controls below still work independently
of the chat agent.
</div>
{/if}
<main class="grid">
<section class="col col--chat" aria-label="Recovery chat">
<Chat
{sessionId}
sessionReady={sessionState === 'ready'}
onLiveSession={onLiveSession}
onStreamingChange={(v) => (streaming = v)}
/>
</section>
<aside class="col col--controls" aria-label="Direct VM control">
<VmControls />
</aside>
</main>
</div>
<style>
.shell {
height: 100%;
display: flex;
flex-direction: column;
max-width: 1500px;
margin: 0 auto;
padding: 0 18px 18px;
}
/* ── status rail ─────────────────────────────────────────────────────── */
.rail {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 16px 4px 14px;
border-bottom: 1px solid var(--line);
}
.rail-title {
display: flex;
align-items: baseline;
gap: 12px;
}
.glyph {
font-size: 19px;
transform: translateY(2px);
filter: saturate(0.85);
}
h1 {
margin: 0;
font-family: var(--mono);
font-size: 19px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink);
}
.accent {
color: var(--cyan);
text-shadow: 0 0 18px rgba(61, 209, 214, 0.35);
}
.rail-tag {
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--ink-faint);
border: 1px solid var(--line-strong);
border-radius: 999px;
padding: 3px 9px;
}
.rail-status {
display: flex;
align-items: center;
gap: 14px;
font-family: var(--mono);
font-size: 13px;
}
.rail-session {
display: inline-flex;
align-items: baseline;
gap: 7px;
white-space: nowrap;
}
.session-label {
color: var(--ink-faint);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
}
.session-id {
color: var(--cyan);
font-family: var(--mono);
letter-spacing: 0.04em;
}
.session-meta {
color: var(--amber);
font-size: 12px;
}
.session-bad {
color: var(--danger-bright);
}
/* connection lamp */
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: none;
background: var(--ink-faint);
box-shadow: 0 0 0 0 transparent;
}
.dot--ready {
background: var(--cyan);
box-shadow: 0 0 10px 1px rgba(61, 209, 214, 0.6);
animation: breathe 3.4s ease-in-out infinite;
}
.dot--busy {
background: var(--amber);
box-shadow: 0 0 10px 1px rgba(245, 182, 87, 0.7);
animation: pulse 1s ease-in-out infinite;
}
.dot--error {
background: var(--danger);
box-shadow: 0 0 10px 1px var(--danger-glow);
}
@keyframes breathe {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@keyframes pulse {
0%, 100% { transform: scale(0.82); opacity: 0.7; }
50% { transform: scale(1.15); opacity: 1; }
}
.new-session {
background: var(--bg-2);
color: var(--ink-dim);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 7px 13px;
font-size: 12px;
letter-spacing: 0.02em;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.new-session:hover:not(:disabled) {
border-color: var(--cyan-dim);
color: var(--ink);
background: var(--bg-3);
}
.new-session:disabled {
opacity: 0.45;
}
.rail-error {
margin: 12px 0 0;
padding: 11px 14px;
border: 1px solid var(--danger-deep);
border-left-width: 3px;
background: rgba(255, 77, 77, 0.07);
color: #ffd5d5;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
}
/* ── layout ──────────────────────────────────────────────────────────── */
.grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 376px;
gap: 18px;
padding-top: 16px;
}
.col {
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
@media (max-width: 940px) {
.grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(0, auto);
overflow: auto;
}
.col--chat {
min-height: 60vh;
}
}
</style>

494
frontend/src/Chat.svelte Normal file
View file

@ -0,0 +1,494 @@
<script>
import { tick } from 'svelte';
import { streamChat } from './lib/api.js';
import ToolChip from './ToolChip.svelte';
let {
sessionId = '',
sessionReady = false,
onLiveSession = (/** @type {string} */ _id) => {},
onStreamingChange = (/** @type {boolean} */ _v) => {},
} = $props();
/**
* Message model. A user message is plain text. An assistant message is an
* ordered list of parts so streamed prose and tool chips interleave in the
* exact order the agent emitted them:
* { role:'assistant', parts:[{type:'text',text}|{type:'tool',name,command}],
* result?: {is_error, text, duration_ms}, error?: string }
* @type {Array<any>}
*/
let messages = $state([]);
let draft = $state('');
let streaming = $state(false);
let scroller; // the scroll viewport
let inputEl;
let pinnedToBottom = true; // auto-scroll only while the user is at the bottom
const canSend = $derived(sessionReady && !streaming && draft.trim().length > 0);
// ── scrolling ─────────────────────────────────────────────────────────────
function onScroll() {
if (!scroller) return;
const gap = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
pinnedToBottom = gap < 60;
}
async function scrollToBottom(force = false) {
if (!force && !pinnedToBottom) return;
await tick();
if (scroller) scroller.scrollTop = scroller.scrollHeight;
}
// ── streaming a turn ────────────────────────────────────────────────────────
function lastAssistant() {
return messages[messages.length - 1];
}
function appendText(text) {
const msg = lastAssistant();
const parts = msg.parts;
const tail = parts[parts.length - 1];
if (tail && tail.type === 'text') {
tail.text += text;
} else {
parts.push({ type: 'text', text });
}
messages = messages; // notify Svelte of the in-place mutation
}
function handleEvent(ev) {
switch (ev?.kind) {
case 'session':
onLiveSession(ev.session_id);
break;
case 'text':
if (ev.text) appendText(ev.text);
break;
case 'tool': {
// Bash carries a `command`; other tools just show their name.
const command =
ev.input && typeof ev.input.command === 'string' ? ev.input.command : '';
lastAssistant().parts.push({ type: 'tool', name: ev.name || 'tool', command });
messages = messages;
break;
}
case 'result':
lastAssistant().result = {
is_error: Boolean(ev.is_error),
text: typeof ev.result === 'string' ? ev.result : '',
duration_ms: typeof ev.duration_ms === 'number' ? ev.duration_ms : null,
};
messages = messages;
break;
case 'error':
lastAssistant().error = ev.error || 'unknown error';
messages = messages;
break;
case 'done':
// handled by the stream completing; nothing to render
break;
default:
break;
}
scrollToBottom();
}
async function send() {
const prompt = draft.trim();
if (!prompt || streaming || !sessionReady) return;
messages.push({ role: 'user', text: prompt });
messages.push({ role: 'assistant', parts: [] });
messages = messages;
draft = '';
streaming = true;
onStreamingChange(true);
pinnedToBottom = true;
await scrollToBottom(true);
try {
await streamChat({ session_id: sessionId, prompt }, handleEvent);
} catch (err) {
// Network/transport failure (backend down, connection dropped mid-stream).
const msg = lastAssistant();
if (msg && msg.role === 'assistant' && !msg.error) {
msg.error =
(err instanceof Error ? err.message : String(err)) +
' — the connection to the agent failed.';
messages = messages;
}
} finally {
streaming = false;
onStreamingChange(false);
await scrollToBottom();
inputEl?.focus();
}
}
function onKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
// Shift+Enter falls through to insert a newline.
}
function fmtDuration(ms) {
if (ms == null) return '';
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`;
}
const isEmpty = $derived(messages.length === 0);
</script>
<div class="chat">
<div class="chat-head">
<span class="chat-head-label">Recovery agent</span>
<span class="chat-head-hint">SSHes into the devvm to diagnose &amp; repair</span>
</div>
<div class="stream" bind:this={scroller} onscroll={onScroll}>
{#if isEmpty}
<div class="empty">
<div class="empty-mark"></div>
<p class="empty-title">The agent is standing by.</p>
<p class="empty-sub">
Describe the symptom — "devvm is unreachable", "disk full", "ssh hangs"
— and it will connect over SSH, investigate, and stream its work here.
For a hard power action when the agent can't help, use
<strong>Direct VM control</strong>.
</p>
</div>
{/if}
{#each messages as msg, i (i)}
{#if msg.role === 'user'}
<div class="row row--user">
<div class="bubble bubble--user">{msg.text}</div>
</div>
{:else}
<div class="row row--assistant">
<div class="bubble bubble--assistant">
{#if msg.parts.length === 0 && !msg.result && !msg.error}
<span class="thinking" aria-label="working">
<span></span><span></span><span></span>
</span>
{/if}
{#each msg.parts as part, j (j)}
{#if part.type === 'text'}
<span class="prose">{part.text}</span>
{:else}
<ToolChip name={part.name} command={part.command} />
{/if}
{/each}
{#if msg.error}
<div class="turn-note turn-note--error">{msg.error}</div>
{:else if msg.result}
<div class="turn-note {msg.result.is_error ? 'turn-note--error' : 'turn-note--ok'}">
<span class="turn-note-tag">{msg.result.is_error ? 'failed' : 'done'}</span>
{#if msg.result.text}<span class="turn-note-body">{msg.result.text}</span>{/if}
{#if msg.result.duration_ms != null}
<span class="turn-note-time">{fmtDuration(msg.result.duration_ms)}</span>
{/if}
</div>
{/if}
</div>
</div>
{/if}
{/each}
</div>
<form
class="composer"
onsubmit={(e) => {
e.preventDefault();
send();
}}
>
{#if streaming}
<div class="working-bar" aria-live="polite">
<span class="working-dots"><span></span><span></span><span></span></span>
agent working — streaming live
</div>
{/if}
<div class="composer-row">
<textarea
bind:this={inputEl}
bind:value={draft}
onkeydown={onKeydown}
placeholder={sessionReady
? 'Describe the problem… (Enter to send · Shift+Enter for a new line)'
: 'Waiting for a session…'}
rows="1"
disabled={!sessionReady || streaming}
spellcheck="false"
></textarea>
<button type="submit" class="send" disabled={!canSend}>
{streaming ? '…' : 'Send'}
</button>
</div>
</form>
</div>
<style>
.chat {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-panel);
overflow: hidden;
}
.chat-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 13px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
}
.chat-head-label {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--cyan);
}
.chat-head-hint {
font-size: 12px;
color: var(--ink-faint);
}
.stream {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 18px 8px;
display: flex;
flex-direction: column;
gap: 14px;
scroll-behavior: smooth;
}
/* empty state */
.empty {
margin: auto;
max-width: 460px;
text-align: center;
padding: 28px 12px;
color: var(--ink-dim);
}
.empty-mark {
font-size: 40px;
color: var(--cyan-dim);
line-height: 1;
margin-bottom: 14px;
text-shadow: 0 0 24px rgba(61, 209, 214, 0.25);
}
.empty-title {
font-family: var(--mono);
color: var(--ink);
font-size: 15px;
margin: 0 0 8px;
}
.empty-sub {
font-size: 13px;
line-height: 1.6;
color: var(--ink-faint);
margin: 0;
}
.empty-sub strong {
color: var(--ink-dim);
font-weight: 600;
}
.row {
display: flex;
}
.row--user {
justify-content: flex-end;
}
.row--assistant {
justify-content: flex-start;
}
.bubble {
max-width: 86%;
border-radius: 13px;
padding: 11px 14px;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.bubble--user {
background: linear-gradient(180deg, #15333a, #0f262c);
border: 1px solid var(--cyan-dim);
color: #d8f6f7;
border-bottom-right-radius: 4px;
white-space: pre-wrap;
font-family: var(--sans);
}
.bubble--assistant {
background: var(--bg-2);
border: 1px solid var(--line-strong);
border-bottom-left-radius: 4px;
color: var(--ink);
}
/* prose renders inline so text and tool chips share the same flow */
.prose {
white-space: pre-wrap;
}
/* in-flight assistant "thinking" dots */
.thinking,
.working-dots {
display: inline-flex;
gap: 4px;
align-items: center;
}
.thinking span,
.working-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--amber);
opacity: 0.4;
animation: blink 1.2s infinite ease-in-out;
}
.thinking span:nth-child(2),
.working-dots span:nth-child(2) {
animation-delay: 0.18s;
}
.thinking span:nth-child(3),
.working-dots span:nth-child(3) {
animation-delay: 0.36s;
}
@keyframes blink {
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
/* turn result / error footer inside the assistant bubble */
.turn-note {
margin-top: 10px;
padding: 7px 10px;
border-radius: var(--radius-sm);
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.turn-note--ok {
background: rgba(93, 219, 142, 0.07);
border: 1px solid var(--green-dim);
color: #bff5d3;
}
.turn-note--error {
background: rgba(255, 77, 77, 0.08);
border: 1px solid var(--danger-deep);
color: #ffd5d5;
}
.turn-note-tag {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid currentColor;
opacity: 0.85;
}
.turn-note-body {
flex: 1;
min-width: 0;
}
.turn-note-time {
margin-left: auto;
color: var(--ink-faint);
}
/* ── composer ─────────────────────────────────────────────────────────── */
.composer {
border-top: 1px solid var(--line);
padding: 12px;
background: linear-gradient(0deg, rgba(255, 255, 255, 0.012), transparent);
}
.working-bar {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--mono);
font-size: 12px;
color: var(--amber);
padding: 0 4px 9px;
letter-spacing: 0.02em;
}
.composer-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
textarea {
flex: 1;
resize: none;
max-height: 168px;
min-height: 44px;
background: var(--bg-2);
color: var(--ink);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 11px 13px;
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
field-sizing: content; /* progressive: auto-grows where supported */
}
textarea::placeholder {
color: var(--ink-faint);
}
textarea:focus {
border-color: var(--cyan-dim);
box-shadow: 0 0 0 3px rgba(61, 209, 214, 0.12);
}
textarea:disabled {
opacity: 0.55;
}
.send {
flex: none;
align-self: stretch;
min-width: 78px;
padding: 0 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--cyan-dim);
background: linear-gradient(180deg, #19474b, #103539);
color: #d8f6f7;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
transition: filter 0.15s, border-color 0.15s, opacity 0.15s;
}
.send:hover:not(:disabled) {
filter: brightness(1.22);
border-color: var(--cyan);
}
.send:disabled {
opacity: 0.4;
background: var(--bg-2);
border-color: var(--line-strong);
color: var(--ink-faint);
}
</style>

View file

@ -0,0 +1,54 @@
<script>
// A compact inline marker for a tool the agent ran mid-turn.
// ⚙ Bash: df -h (Bash → show the command)
// ⚙ Read (other tools → just the name)
let { name = 'tool', command = '' } = $props();
</script>
<span class="chip" title={command ? `${name}: ${command}` : name}>
<span class="cog" aria-hidden="true"></span>
<span class="name">{name}</span>
{#if command}
<span class="sep" aria-hidden="true">:</span>
<code class="cmd">{command}</code>
{/if}
</span>
<style>
.chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
max-width: 100%;
margin: 3px 4px 3px 0;
padding: 3px 9px;
border-radius: 6px;
background: var(--bg-3);
border: 1px solid var(--line-strong);
border-left: 2px solid var(--cyan-dim);
font-family: var(--mono);
font-size: 12px;
line-height: 1.45;
vertical-align: baseline;
}
.cog {
color: var(--cyan);
font-size: 11px;
transform: translateY(1px);
}
.name {
color: var(--ink);
font-weight: 600;
}
.sep {
color: var(--ink-faint);
}
.cmd {
color: var(--amber);
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
</style>

View file

@ -0,0 +1,562 @@
<script>
import { onMount } from 'svelte';
import { fetchVerbs, runVerb } from './lib/api.js';
// ── verb catalogue ──────────────────────────────────────────────────────
// The server is the source of truth for which verbs exist and which mutate.
// We layer presentation metadata (label, blurb) on top, preserving a sensible
// operator order: read-only first, then the escalating power actions.
const META = {
status: { label: 'status', blurb: 'qm status — is the VM up?' },
forensics: { label: 'forensics', blurb: 'capture live diagnostic state' },
start: { label: 'start', blurb: 'power on a stopped VM' },
stop: { label: 'stop', blurb: 'hard power-off (pulls the plug)' },
reset: { label: 'reset', blurb: 'warm reboot — reuses the QEMU process' },
cycle: {
label: 'cycle',
blurb: 'stop → start; applies staged config; fixes a wedged QEMU',
headline: true,
},
};
const ORDER = ['status', 'forensics', 'start', 'stop', 'reset', 'cycle'];
let loadState = $state('loading'); // loading | ready | error
let loadError = $state('');
let verbs = $state([]); // [{name, mutating, ...meta}]
let confirming = $state(''); // verb awaiting confirmation, or ''
let running = $state(''); // verb currently in flight, or ''
let output = $state(null); // { verb, exit_code, stdout, stderr, rejected }
let actionError = $state(''); // transport-level failure (backend unreachable)
const busy = $derived(running !== '');
onMount(async () => {
try {
const { verbs: names, mutating } = await fetchVerbs();
const mut = new Set(mutating);
const known = names.filter((n) => META[n]);
const ordered = [
...ORDER.filter((n) => known.includes(n)),
...known.filter((n) => !ORDER.includes(n)),
];
verbs = ordered.map((name) => ({
name,
mutating: mut.has(name),
...META[name],
}));
loadState = 'ready';
} catch (err) {
loadState = 'error';
loadError = err instanceof Error ? err.message : String(err);
}
});
const nonMutating = $derived(verbs.filter((v) => !v.mutating));
const mutating = $derived(verbs.filter((v) => v.mutating));
function clickVerb(v) {
if (busy) return;
if (v.mutating) {
confirming = confirming === v.name ? '' : v.name; // toggle the inline confirm
} else {
execute(v.name);
}
}
function cancelConfirm() {
confirming = '';
}
async function execute(verb) {
confirming = '';
actionError = '';
output = null;
running = verb;
try {
output = await runVerb(verb);
} catch (err) {
actionError = err instanceof Error ? err.message : String(err);
} finally {
running = '';
}
}
// styling helpers for the output panel
const outputFailed = $derived(
!!output && (output.rejected || (output.exit_code != null && output.exit_code !== 0))
);
</script>
<div class="panel">
<div class="panel-head">
<div class="panel-head-row">
<span class="hazard" aria-hidden="true"></span>
<h2>Direct VM control</h2>
</div>
<p class="panel-sub">No AI in the path — these reach the Proxmox host over a
forced-command SSH key and work even when the agent is down.</p>
</div>
{#if loadState === 'loading'}
<div class="loading">Loading controls…</div>
{:else if loadState === 'error'}
<div class="block-error" role="alert">
Couldn't load the VM controls — {loadError}.
<button class="retry" onclick={() => location.reload()}>Reload</button>
</div>
{:else}
<!-- read-only actions -->
<div class="group">
<div class="group-label">Inspect <span class="group-tag">read-only</span></div>
<div class="btn-row">
{#each nonMutating as v (v.name)}
<button
class="vbtn vbtn--safe"
onclick={() => clickVerb(v)}
disabled={busy}
title={v.blurb}
>
{#if running === v.name}<span class="spin" aria-hidden="true"></span>{/if}
<span class="vbtn-label">{v.label}</span>
</button>
{/each}
</div>
</div>
<!-- mutating / power actions -->
<div class="group">
<div class="group-label group-label--danger">
Power <span class="group-tag group-tag--danger">affects the running VM</span>
</div>
<div class="danger-list">
{#each mutating as v (v.name)}
<div class="danger-item {v.headline ? 'danger-item--headline' : ''}">
<button
class="vbtn vbtn--danger {v.headline ? 'vbtn--headline' : ''}"
onclick={() => clickVerb(v)}
disabled={busy}
aria-expanded={confirming === v.name}
>
{#if running === v.name}<span class="spin spin--danger" aria-hidden="true"></span>{/if}
<span class="vbtn-label">{v.label}</span>
{#if v.headline}<span class="headline-badge">recovery</span>{/if}
</button>
<p class="danger-blurb">{v.blurb}</p>
{#if confirming === v.name}
<div class="confirm" role="alertdialog" aria-label="Confirm {v.name}">
<span class="confirm-text">
Confirm <strong>{v.name}</strong>? This will affect the running VM
</span>
<div class="confirm-actions">
<button class="confirm-yes" onclick={() => execute(v.name)} disabled={busy}>
Confirm
</button>
<button class="confirm-no" onclick={cancelConfirm} disabled={busy}>
Cancel
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- output -->
{#if actionError}
<div class="block-error" role="alert">
⚠ Command failed to reach the host — {actionError}
</div>
{/if}
{#if output}
<div class="out {outputFailed ? 'out--fail' : 'out--ok'}">
<div class="out-head">
<code class="out-verb">{output.verb}</code>
{#if output.rejected}
<span class="out-status out-status--fail">rejected</span>
{:else}
<span class="out-status {outputFailed ? 'out-status--fail' : 'out-status--ok'}">
exit {output.exit_code}
</span>
{/if}
</div>
{#if output.stdout}
<pre class="out-pre">{output.stdout}</pre>
{/if}
{#if output.stderr}
<div class="out-stderr-label">stderr</div>
<pre class="out-pre out-pre--stderr">{output.stderr}</pre>
{/if}
{#if !output.stdout && !output.stderr}
<pre class="out-pre out-pre--empty">(no output)</pre>
{/if}
</div>
{/if}
{/if}
</div>
<style>
.panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg-1);
border: 1px solid var(--line);
/* a faint danger seam down the right edge marks this as the hot zone */
border-top: 2px solid var(--danger-deep);
border-radius: var(--radius);
box-shadow: var(--shadow-panel);
overflow-y: auto;
}
.panel-head {
padding: 14px 16px 12px;
border-bottom: 1px solid var(--line);
}
.panel-head-row {
display: flex;
align-items: center;
gap: 9px;
}
.hazard {
color: var(--danger);
font-size: 15px;
filter: drop-shadow(0 0 8px var(--danger-glow));
}
h2 {
margin: 0;
font-family: var(--mono);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink);
}
.panel-sub {
margin: 9px 0 0;
font-size: 11.5px;
line-height: 1.55;
color: var(--ink-faint);
}
.loading {
padding: 22px 16px;
font-family: var(--mono);
font-size: 12px;
color: var(--ink-faint);
}
.group {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.group-label {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--ink-faint);
margin-bottom: 11px;
}
.group-label--danger {
color: var(--danger-bright);
}
.group-tag {
font-size: 9.5px;
letter-spacing: 0.1em;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--line-strong);
color: var(--ink-faint);
}
.group-tag--danger {
border-color: var(--danger-deep);
color: var(--danger-bright);
background: rgba(255, 77, 77, 0.06);
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
/* shared button shape */
.vbtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 15px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: lowercase;
transition: filter 0.14s, border-color 0.14s, background 0.14s, transform 0.06s;
}
.vbtn:active:not(:disabled) {
transform: translateY(1px);
}
.vbtn:disabled {
opacity: 0.4;
}
.vbtn-label {
line-height: 1;
}
.vbtn--safe {
background: var(--bg-2);
color: var(--ink);
border: 1px solid var(--line-strong);
}
.vbtn--safe:hover:not(:disabled) {
border-color: var(--cyan-dim);
background: var(--bg-3);
}
/* danger actions read as hot the moment you look at them */
.danger-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.danger-item {
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.danger-item--headline {
padding: 11px;
border-color: var(--danger-deep);
background: rgba(255, 77, 77, 0.045);
}
.vbtn--danger {
width: 100%;
background: linear-gradient(180deg, rgba(255, 77, 77, 0.16), rgba(255, 77, 77, 0.07));
color: var(--danger-bright);
border: 1px solid var(--danger-deep);
/* hazard stripe down the leading edge */
border-left: 3px solid var(--danger);
text-shadow: 0 0 12px var(--danger-glow);
}
.vbtn--danger:hover:not(:disabled) {
background: linear-gradient(180deg, var(--danger), var(--danger-bright));
color: #1a0606;
border-color: var(--danger-bright);
text-shadow: none;
filter: drop-shadow(0 4px 14px var(--danger-glow));
}
.vbtn--headline {
padding: 12px 15px;
font-size: 14px;
}
.headline-badge {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 2px 7px;
border-radius: 999px;
background: var(--danger);
color: #1a0606;
font-weight: 700;
}
.danger-blurb {
margin: 7px 2px 0;
font-size: 11.5px;
line-height: 1.5;
color: var(--ink-faint);
}
.danger-item--headline .danger-blurb {
color: #f0b0b0;
}
/* inline confirm step */
.confirm {
margin-top: 10px;
padding: 11px 12px;
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
background: rgba(255, 77, 77, 0.1);
animation: confirm-in 0.16s ease-out;
}
@keyframes confirm-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.confirm-text {
display: block;
font-size: 12.5px;
line-height: 1.5;
color: #ffe0e0;
margin-bottom: 10px;
}
.confirm-text strong {
color: #fff;
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.confirm-actions {
display: flex;
gap: 9px;
}
.confirm-yes {
flex: 1;
padding: 9px;
border-radius: var(--radius-sm);
border: 1px solid var(--danger-bright);
background: var(--danger);
color: #1a0606;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.06em;
text-transform: uppercase;
transition: filter 0.14s;
}
.confirm-yes:hover:not(:disabled) {
filter: brightness(1.12);
}
.confirm-no {
flex: 1;
padding: 9px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-2);
color: var(--ink-dim);
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
transition: border-color 0.14s, color 0.14s;
}
.confirm-no:hover:not(:disabled) {
border-color: var(--ink-faint);
color: var(--ink);
}
.confirm-yes:disabled,
.confirm-no:disabled {
opacity: 0.5;
}
/* spinners */
.spin {
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid rgba(230, 237, 243, 0.25);
border-top-color: var(--cyan);
animation: spin 0.7s linear infinite;
flex: none;
}
.spin--danger {
border-color: rgba(255, 77, 77, 0.3);
border-top-color: var(--danger-bright);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* output panel */
.out {
margin: 14px 16px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-term);
overflow: hidden;
}
.out--ok {
border-color: var(--green-dim);
}
.out--fail {
border-color: var(--danger-deep);
}
.out-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 11px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.02);
}
.out-verb {
font-family: var(--mono);
font-size: 12px;
color: var(--ink);
letter-spacing: 0.04em;
}
.out-verb::before {
content: '$ pve ';
color: var(--ink-faint);
}
.out-status {
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 2px 7px;
border-radius: 4px;
border: 1px solid currentColor;
}
.out-status--ok {
color: var(--green);
}
.out-status--fail {
color: var(--danger-bright);
}
.out-pre {
margin: 0;
padding: 11px 12px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
color: #c7d6e2;
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 320px;
overflow-y: auto;
}
.out-stderr-label {
padding: 6px 12px 0;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--danger-bright);
}
.out-pre--stderr {
color: #f3b6b6;
}
.out-pre--empty {
color: var(--ink-faint);
font-style: italic;
}
.block-error {
margin: 14px 16px;
padding: 11px 13px;
border: 1px solid var(--danger-deep);
border-left: 3px solid var(--danger);
background: rgba(255, 77, 77, 0.07);
border-radius: var(--radius-sm);
color: #ffd5d5;
font-size: 12.5px;
line-height: 1.5;
}
.retry {
margin-left: 8px;
background: transparent;
border: 1px solid var(--danger-deep);
color: var(--danger-bright);
border-radius: 5px;
padding: 3px 9px;
font-size: 11px;
}
.retry:hover {
background: rgba(255, 77, 77, 0.12);
}
</style>

126
frontend/src/app.css Normal file
View file

@ -0,0 +1,126 @@
/*
devvm breakglass global theme
A recovery console: dark, high-contrast, terminal-adjacent. Calm by default;
danger is the only loud thing on the screen. No external fonts/CDNs system
monospace carries the identity, system sans carries readable prose.
*/
:root {
/* Surfaces — a near-black slate with cool undertone, layered for depth. */
--bg-0: #07090c; /* page base */
--bg-1: #0c1015; /* panel */
--bg-2: #11171e; /* raised panel / input */
--bg-3: #161d26; /* chips, hover */
--bg-term: #06080a; /* command-output panels */
/* Hairlines & text */
--line: #1d2630;
--line-strong: #2a3744;
--ink: #e6edf3; /* primary text */
--ink-dim: #9bb0c0; /* secondary text */
--ink-faint: #5d7185; /* labels, meta */
/* Accents */
--cyan: #3dd1d6; /* "system alive" — links, focus, session dot */
--cyan-dim: #1f6f72;
--amber: #f5b657; /* working / in-flight */
--green: #5ddb8e; /* healthy exit */
--green-dim: #1f5f3d;
/* Danger — reserved EXCLUSIVELY for mutating actions. Nothing else is red. */
--danger: #ff4d4d;
--danger-bright: #ff6363;
--danger-deep: #7a1717;
--danger-glow: rgba(255, 77, 77, 0.35);
--radius: 10px;
--radius-sm: 7px;
--mono: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code",
"Fira Code", Menlo, Consolas, "Liberation Mono", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.02) inset,
0 16px 40px -24px rgba(0, 0, 0, 0.9);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background-color: var(--bg-0);
/* Atmosphere: a soft cyan corner-glow over a faint scanline weave, so the
surface reads like backlit equipment rather than flat #000. */
background-image:
radial-gradient(120% 80% at 85% -10%, rgba(61, 209, 214, 0.07), transparent 55%),
radial-gradient(90% 70% at 10% 110%, rgba(245, 182, 87, 0.04), transparent 50%),
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.012) 0px,
rgba(255, 255, 255, 0.012) 1px,
transparent 1px,
transparent 3px
);
background-attachment: fixed;
color: var(--ink);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
#app {
height: 100%;
}
button {
font-family: var(--mono);
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
::selection {
background: rgba(61, 209, 214, 0.28);
}
/* Console scrollbars — thin, dark, unobtrusive. */
* {
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: 99px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: #3a4a5a;
background-clip: content-box;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}

92
frontend/src/lib/api.js Normal file
View file

@ -0,0 +1,92 @@
// Same-origin API client. Auth is handled entirely by the edge proxy
// (Authentik / basic-auth / bearer) — this UI never sends or stores a token.
import { readEventStream } from './sse.js';
/** Open a fresh chat session. @returns {Promise<string>} session_id */
export async function openSession() {
const res = await fetch('/api/session', {
method: 'POST',
headers: { 'content-type': 'application/json' },
});
if (!res.ok) {
throw new Error(`could not open a session (HTTP ${res.status})`);
}
const body = await res.json();
if (!body || typeof body.session_id !== 'string') {
throw new Error('session response missing session_id');
}
return body.session_id;
}
/**
* Run one chat turn. Streams events to onEvent until the backend sends
* {kind:"done"} and the connection closes. Pass an AbortSignal to cancel.
*
* @param {{session_id: string, prompt: string, model?: string, signal?: AbortSignal}} opts
* @param {(event: object) => void} onEvent
*/
export async function streamChat({ session_id, prompt, model, signal }, onEvent) {
const payload = { session_id, prompt };
if (model) payload.model = model;
const res = await fetch('/api/chat', {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'text/event-stream',
},
body: JSON.stringify(payload),
signal,
});
await readEventStream(res, onEvent);
}
/**
* List the PVE power verbs and which of them mutate VM state.
* @returns {Promise<{verbs: string[], mutating: string[]}>}
*/
export async function fetchVerbs() {
const res = await fetch('/api/pve/verbs');
if (!res.ok) {
throw new Error(`could not load VM controls (HTTP ${res.status})`);
}
const body = await res.json();
return {
verbs: Array.isArray(body.verbs) ? body.verbs : [],
mutating: Array.isArray(body.mutating) ? body.mutating : [],
};
}
/**
* Run a PVE power verb directly (no AI in the path). The backend returns 200
* on success and 502 when the verb's exit code is non-zero, but the JSON body
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases so we
* read the body regardless of HTTP status and let the caller style on
* exit_code / rejected.
*
* @param {string} verb
* @returns {Promise<{verb: string, exit_code: number|null, stdout: string, stderr: string, rejected: boolean}>}
*/
export async function runVerb(verb) {
const res = await fetch(`/api/pve/${encodeURIComponent(verb)}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
});
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
let body;
try {
body = await res.json();
} catch {
throw new Error(`VM control '${verb}' failed (HTTP ${res.status}, no body)`);
}
if (res.status === 400) {
throw new Error(body?.detail || `'${verb}' was rejected by the server`);
}
return {
verb: body.verb ?? verb,
exit_code: body.exit_code ?? null,
stdout: body.stdout ?? '',
stderr: body.stderr ?? '',
rejected: Boolean(body.rejected),
};
}

150
frontend/src/lib/sse.js Normal file
View file

@ -0,0 +1,150 @@
// SSE frame parsing — the load-bearing core of the breakglass UI.
//
// The /api/chat endpoint returns a text/event-stream that we read with
// fetch() + response.body.getReader() (NOT EventSource, which cannot POST).
// The backend emits one frame per event as:
//
// data: {json}\n\n
//
// getReader() hands us bytes at arbitrary boundaries: a single frame can be
// split across reads, and one read can contain several frames. So we keep a
// rolling text buffer, split it on the blank-line frame delimiter, and only
// hand back the JSON payload of *complete* frames. Per the SSE spec a frame may
// carry multiple `data:` lines (joined with "\n"); the backend emits single
// line JSON today, but we handle the general case so a future multi-line
// payload can't silently corrupt the stream.
/**
* Parse a single SSE event block (the text between blank lines) into its data
* payload string, or null if the block carries no `data:` field (e.g. a bare
* comment or a `:` heartbeat).
* @param {string} block
* @returns {string|null}
*/
export function dataFromEventBlock(block) {
const dataLines = [];
for (const rawLine of block.split('\n')) {
const line = rawLine.replace(/\r$/, '');
if (line.startsWith(':')) continue; // SSE comment / heartbeat
if (line === 'data:' || line === 'data') {
dataLines.push('');
} else if (line.startsWith('data:')) {
// Spec: a single leading space after the colon is stripped.
let v = line.slice('data:'.length);
if (v.startsWith(' ')) v = v.slice(1);
dataLines.push(v);
}
// field lines we don't care about (event:, id:, retry:) are ignored
}
if (dataLines.length === 0) return null;
return dataLines.join('\n');
}
/**
* A stateful splitter that turns an arbitrary sequence of decoded text chunks
* into a sequence of complete SSE event-block strings. Frames are delimited by
* a blank line; we tolerate both "\n\n" and "\r\n\r\n".
*/
export class SSEFrameSplitter {
constructor() {
this.buffer = '';
}
/**
* Feed a decoded text chunk; returns the event blocks that are now complete.
* Any trailing partial frame stays buffered for the next chunk.
* @param {string} chunk
* @returns {string[]} complete event blocks (text between delimiters)
*/
push(chunk) {
this.buffer += chunk;
const blocks = [];
// Normalise CRLF delimiters to LF so a single split rule covers both.
let idx;
// Process every complete frame currently in the buffer.
while ((idx = this._nextDelimiter()) !== -1) {
const block = this.buffer.slice(0, idx.start);
this.buffer = this.buffer.slice(idx.end);
if (block.length > 0) blocks.push(block);
}
return blocks;
}
/**
* On stream end, return whatever complete-looking content remains. A
* well-behaved backend always terminates the last frame with a blank line,
* so this is usually empty but if the connection closed mid-trailing-frame
* with a parseable block, surface it rather than dropping data.
* @returns {string[]}
*/
flush() {
const rest = this.buffer.trim();
this.buffer = '';
return rest ? [rest] : [];
}
_nextDelimiter() {
// Find the earliest of "\n\n", "\r\n\r\n", "\r\r".
const candidates = [
{ token: '\r\n\r\n', i: this.buffer.indexOf('\r\n\r\n') },
{ token: '\n\n', i: this.buffer.indexOf('\n\n') },
{ token: '\r\r', i: this.buffer.indexOf('\r\r') },
].filter((c) => c.i !== -1);
if (candidates.length === 0) return -1;
candidates.sort((a, b) => a.i - b.i);
const { token, i } = candidates[0];
return { start: i, end: i + token.length };
}
}
/**
* Read an SSE Response body to completion, invoking onEvent for every parsed
* JSON event object. Resolves when the stream ends. Throws if the response is
* not ok or has no readable body (caller shows the error inline).
*
* @param {Response} response a fetch() Response with a streaming body
* @param {(event: object) => void} onEvent called per parsed JSON event
*/
export async function readEventStream(response, onEvent) {
if (!response.ok) {
throw new Error(`server returned ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error('response has no readable body (streaming unsupported)');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const splitter = new SSEFrameSplitter();
const handleBlock = (block) => {
const payload = dataFromEventBlock(block);
if (payload == null || payload.trim() === '') return;
let obj;
try {
obj = JSON.parse(payload);
} catch {
// A malformed frame must not abort an in-progress recovery stream;
// skip it and keep reading.
return;
}
onEvent(obj);
};
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
for (const block of splitter.push(text)) handleBlock(block);
}
} finally {
reader.releaseLock?.();
}
// Drain any trailing bytes the decoder held, then any final frame.
const tail = decoder.decode();
if (tail) {
for (const block of splitter.push(tail)) handleBlock(block);
}
for (const block of splitter.flush()) handleBlock(block);
}

View file

@ -0,0 +1,152 @@
// Standalone test of the SSE frame parser — no test framework, just node.
// Run: node src/lib/sse.test.mjs (exits non-zero on any failure)
//
// These pin the protocol described in the API contract: frames are
// `data: {json}\n\n`, the event `kind` is one of session/text/tool/result/
// error/done, and bytes arrive at arbitrary boundaries via getReader().
import { SSEFrameSplitter, dataFromEventBlock, readEventStream } from './sse.js';
let failures = 0;
function ok(name, cond) {
if (cond) {
console.log(` ok ${name}`);
} else {
failures++;
console.error(`FAIL ${name}`);
}
}
function eq(name, got, want) {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(`${name} (got ${g})`, g === w);
}
// --- dataFromEventBlock ---------------------------------------------------
eq(
'extracts JSON payload from a data: line',
dataFromEventBlock('data: {"kind":"text","text":"hi"}'),
'{"kind":"text","text":"hi"}'
);
eq(
'strips exactly one space after the colon',
dataFromEventBlock('data: leading-space-kept'),
' leading-space-kept'
);
eq('ignores comment/heartbeat lines', dataFromEventBlock(': keep-alive'), null);
eq(
'joins multi-line data fields with newline',
dataFromEventBlock('data: line1\ndata: line2'),
'line1\nline2'
);
// --- SSEFrameSplitter: whole frames --------------------------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push('data: {"kind":"session","session_id":"abc"}\n\n');
eq('one complete frame yields one block', blocks, [
'data: {"kind":"session","session_id":"abc"}',
]);
}
// --- SSEFrameSplitter: multiple frames in one chunk ----------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push(
'data: {"kind":"text","text":"a"}\n\ndata: {"kind":"text","text":"b"}\n\n'
);
eq('two frames in one chunk yield two blocks', blocks.length, 2);
eq('first block', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"a"}');
eq('second block', dataFromEventBlock(blocks[1]), '{"kind":"text","text":"b"}');
}
// --- SSEFrameSplitter: frame split across chunks -------------------------
{
const s = new SSEFrameSplitter();
let blocks = s.push('data: {"kind":"te');
eq('partial frame yields nothing yet', blocks, []);
blocks = s.push('xt","text":"split"}\n\n');
eq('completing the frame yields it whole', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"split"}');
}
// --- SSEFrameSplitter: delimiter split across chunks ---------------------
{
const s = new SSEFrameSplitter();
let blocks = s.push('data: {"kind":"done"}\n');
eq('frame held while delimiter incomplete', blocks, []);
blocks = s.push('\n');
eq('frame released once blank line completes', dataFromEventBlock(blocks[0]), '{"kind":"done"}');
}
// --- SSEFrameSplitter: CRLF delimiters -----------------------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push('data: {"kind":"text","text":"crlf"}\r\n\r\n');
eq('CRLF-delimited frame parses', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"crlf"}');
}
// --- end-to-end via readEventStream over a mock streaming Response --------
function mockResponse(chunks) {
const enc = new TextEncoder();
let i = 0;
return {
ok: true,
status: 200,
body: {
getReader() {
return {
read() {
if (i < chunks.length) {
return Promise.resolve({ value: enc.encode(chunks[i++]), done: false });
}
return Promise.resolve({ value: undefined, done: true });
},
releaseLock() {},
};
},
},
};
}
await (async () => {
// A realistic turn, deliberately chopped at ugly boundaries:
// - the session frame split mid-JSON
// - two text frames glued together
// - a tool frame
// - a result frame and the terminal done frame in one chunk
const chunks = [
'data: {"kind":"sess',
'ion","session_id":"S1"}\n\n',
'data: {"kind":"text","text":"checking "}\n\ndata: {"kind":"text","text":"disk"}\n\n',
'data: {"kind":"tool","name":"Bash","input":{"command":"df -h"}}\n\n',
'data: {"kind":"result","is_error":false,"result":"ok","duration_ms":12}\n\ndata: {"kind":"done"}\n\n',
];
const events = [];
await readEventStream(mockResponse(chunks), (e) => events.push(e));
eq('event count', events.length, 6);
eq('1: session id', events[0], { kind: 'session', session_id: 'S1' });
eq('2: first text', events[1], { kind: 'text', text: 'checking ' });
eq('3: second text', events[2], { kind: 'text', text: 'disk' });
eq('4: tool kind+name', { kind: events[3].kind, name: events[3].name }, { kind: 'tool', name: 'Bash' });
eq('4: tool command', events[3].input.command, 'df -h');
eq('5: result', events[4], { kind: 'result', is_error: false, result: 'ok', duration_ms: 12 });
eq('6: done terminal', events[5], { kind: 'done' });
})();
// malformed frame in the middle must be skipped, not abort the stream
await (async () => {
const chunks = [
'data: {"kind":"text","text":"before"}\n\n',
'data: {this is not json}\n\n',
'data: {"kind":"done"}\n\n',
];
const events = [];
await readEventStream(mockResponse(chunks), (e) => events.push(e));
eq('malformed frame skipped, stream continues', events.map((e) => e.kind), ['text', 'done']);
})();
if (failures) {
console.error(`\n${failures} assertion(s) FAILED`);
process.exit(1);
}
console.log('\nall SSE parser assertions passed');

9
frontend/src/main.js Normal file
View file

@ -0,0 +1,9 @@
import './app.css';
import App from './App.svelte';
import { mount } from 'svelte';
const app = mount(App, {
target: document.getElementById('app'),
});
export default app;

View file

@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};

20
frontend/vite.config.js Normal file
View file

@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// The compiled SPA is emitted into the FastAPI app's static dir. FastAPI serves
// app/breakglass/static/index.html at "/" and mounts the directory, so the
// build output must be plain static files (plain Svelte, not SvelteKit).
//
// base: './' makes every asset reference relative, so the bundle loads no
// matter what path the edge proxy mounts the app under.
export default defineConfig({
plugins: [svelte()],
base: './',
build: {
outDir: '../app/breakglass/static',
emptyOutDir: true,
// Keep the asset graph small and predictable for an air-gapped cluster:
// no remote chunks, no CDN — everything bundled here.
assetsInlineLimit: 0,
},
});