breakglass UI v2: attachable sessions (tmux model) + mobile-first redesign
Full audit-driven rework. Keeps the proven SSE-translation + verb logic; everything else upgraded for phone-primary use.
Backend — server owns the session, clients attach (Viktor's tmux idea):
- session.py: SessionManager + Session with an event log, subscriber pub/sub, and turns that run DETACHED (keep going if the client disconnects).
- GET /api/session/{id}/stream = attach (SSE): replays the transcript then tails live; per-event id: lines so an EventSource auto-reconnect resumes from Last-Event-ID (free re-attach). POST /{id}/prompt starts a detached turn; POST /{id}/cancel = Stop. Replaces the old one-shot /api/chat.
- agent_session trimmed to the argv + translate_event helpers; 21 new/updated tests (replay, Last-Event-ID resume, broadcast, detached turn, resume, cancel, routes) — 53 green.
Frontend — mobile-first via the frontend-design skill (emergency-console aesthetic):
- EventSource attach (native auto-reconnect, zero client reconnect logic); transcript.js folds events->messages with id-dedupe so replays never double-render (30 unit assertions).
- Installable PWA: manifest + icons (wrench/break-glass mark) + apple-mobile-web-app meta + theme-color; viewport-fit=cover + safe-area; 100dvh; 16px composer (no iOS zoom).
- One-tap diagnosis presets (Triage / Memory-OOM / Disk / Services / QEMU-wedged) mapped to the devvm's real failure modes; Stop button while a turn runs.
- Foldable VM-control sheet, cycle the dominant recovery action w/ confirm, output capped 46vh.
- a11y: fixed --ink-faint contrast 3.6:1 -> 6.1:1 (WCAG AA); >=44px tap targets. Deleted the obsolete fetch-reader sse.js (EventSource replaces it).
Verified: 53 backend tests + 30 transcript assertions; Playwright @390x844 (input on-screen y=721-821, presets/sheet/fold/cap); local integration smoke vs the real backend (attach->caught-up, 404, verbs, PWA served).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9d8afdd884
commit
5b5daa4bea
30 changed files with 1961 additions and 968 deletions
|
|
@ -1,8 +1,41 @@
|
|||
// 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';
|
||||
// Same-origin API client for the breakglass UI.
|
||||
//
|
||||
// Auth is handled entirely by the edge proxy (Authentik / basic-auth / bearer):
|
||||
// this UI never sends or stores a token, and builds no login screen.
|
||||
//
|
||||
// The chat uses the tmux/attach model. The conversation lives SERVER-SIDE; we
|
||||
// only persist the session_id locally and ATTACH to it over an EventSource. The
|
||||
// browser's native EventSource auto-reconnects and sends Last-Event-ID, and the
|
||||
// server resumes from there — so there is ZERO reconnect logic here. We just
|
||||
// render events idempotently by id (see transcript.js).
|
||||
|
||||
/** Open a fresh chat session. @returns {Promise<string>} session_id */
|
||||
const SESSION_KEY = 'breakglass.session_id';
|
||||
|
||||
/** Read the persisted session id, or '' if none. */
|
||||
export function loadSessionId() {
|
||||
try {
|
||||
return localStorage.getItem(SESSION_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the session id (best-effort; private-mode storage may throw). */
|
||||
export function saveSessionId(id) {
|
||||
try {
|
||||
if (id) localStorage.setItem(SESSION_KEY, id);
|
||||
else localStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
/* ignore — storage is a convenience, not a requirement */
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the persisted session id (the "New session" archive step). */
|
||||
export function clearSessionId() {
|
||||
saveSessionId('');
|
||||
}
|
||||
|
||||
/** Open a fresh server-side session. @returns {Promise<string>} session_id */
|
||||
export async function openSession() {
|
||||
const res = await fetch('/api/session', {
|
||||
method: 'POST',
|
||||
|
|
@ -19,30 +52,89 @@ export async function openSession() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Run one chat turn. Streams events to onEvent until the backend sends
|
||||
* {kind:"done"} and the connection closes. Pass an AbortSignal to cancel.
|
||||
* Attach to a session's event stream. Returns the live EventSource so the
|
||||
* caller can close() it. Events arrive as:
|
||||
* - default `message` events: .data is JSON {kind, id, ...}
|
||||
* - a named `caught-up` event once the replay is drained (.data is {})
|
||||
* - native `error` events while reconnecting (EventSource retries itself)
|
||||
*
|
||||
* @param {{session_id: string, prompt: string, model?: string, signal?: AbortSignal}} opts
|
||||
* @param {(event: object) => void} onEvent
|
||||
* @param {string} sessionId
|
||||
* @param {{
|
||||
* onEvent: (e: object) => void,
|
||||
* onCaughtUp?: () => void,
|
||||
* onOpen?: () => void,
|
||||
* onError?: (e: Event) => void,
|
||||
* }} handlers
|
||||
* @returns {EventSource}
|
||||
*/
|
||||
export async function streamChat({ session_id, prompt, model, signal }, onEvent) {
|
||||
const payload = { session_id, prompt };
|
||||
if (model) payload.model = model;
|
||||
export function attachStream(sessionId, { onEvent, onCaughtUp, onOpen, onError }) {
|
||||
const es = new EventSource(`/api/session/${encodeURIComponent(sessionId)}/stream`);
|
||||
|
||||
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);
|
||||
es.onopen = () => onOpen?.();
|
||||
|
||||
es.onmessage = (e) => {
|
||||
if (!e || typeof e.data !== 'string' || e.data === '') return;
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(e.data);
|
||||
} catch {
|
||||
// A malformed frame must not abort an in-progress recovery stream.
|
||||
return;
|
||||
}
|
||||
// EventSource exposes the SSE `id:` line as e.lastEventId. The server also
|
||||
// embeds id in the JSON; prefer the JSON id, fall back to lastEventId.
|
||||
if ((obj.id == null || obj.id === '') && e.lastEventId) obj.id = e.lastEventId;
|
||||
onEvent(obj);
|
||||
};
|
||||
|
||||
es.addEventListener('caught-up', () => onCaughtUp?.());
|
||||
|
||||
es.onerror = (e) => {
|
||||
// EventSource auto-reconnects on a transient drop (readyState CONNECTING);
|
||||
// we only surface a hard, terminal failure (readyState CLOSED).
|
||||
onError?.(e);
|
||||
};
|
||||
|
||||
return es;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the PVE power verbs and which of them mutate VM state.
|
||||
* Start a turn. Output arrives via the attach stream, NOT this response.
|
||||
* @param {{session_id: string, prompt: string, model?: string}} opts
|
||||
* @returns {Promise<{status:'started'|'busy'|'gone'}>}
|
||||
* started — accepted; busy — 409 (a turn already runs); gone — 404 (re-create).
|
||||
*/
|
||||
export async function sendPrompt({ session_id, prompt, model }) {
|
||||
const payload = { prompt };
|
||||
if (model) payload.model = model;
|
||||
const res = await fetch(`/api/session/${encodeURIComponent(session_id)}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 409) return { status: 'busy' };
|
||||
if (res.status === 404) return { status: 'gone' };
|
||||
if (!res.ok) throw new Error(`could not start the turn (HTTP ${res.status})`);
|
||||
return { status: 'started' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the in-flight turn (the Stop button).
|
||||
* @param {string} sessionId
|
||||
* @returns {Promise<boolean>} whether a turn was cancelled
|
||||
*/
|
||||
export async function cancelTurn(sessionId) {
|
||||
const res = await fetch(`/api/session/${encodeURIComponent(sessionId)}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`could not stop the turn (HTTP ${res.status})`);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return Boolean(body.cancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the PVE power verbs and which mutate VM state.
|
||||
* @returns {Promise<{verbs: string[], mutating: string[]}>}
|
||||
*/
|
||||
export async function fetchVerbs() {
|
||||
|
|
@ -58,27 +150,26 @@ export async function fetchVerbs() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @param {string} verb
|
||||
* @returns {Promise<{verb: string, exit_code: number|null, stdout: string, stderr: string, rejected: boolean}>}
|
||||
* @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)`);
|
||||
}
|
||||
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
|
||||
if (res.status === 400) {
|
||||
throw new Error(body?.detail || `'${verb}' was rejected by the server`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue