feat: add web terminal UI with WebSocket console and clone management API
Introduces a browser-based terminal interface backed by xterm.js, served
directly from the binary via an embedded HTML asset.
New HTTP server (`serve [addr]`, default :8080):
GET / — xterm.js terminal UI; ?id=N selects a clone
GET /clones — JSON list of running clone IDs
POST /clones — spawn a new clone; returns {"id": N}
DELETE /clones/{id} — destroy a clone by ID
GET /ws/{id} — WebSocket console for clone {id}
binary frames = raw PTY I/O
text frames = JSON resize {"rows":N,"cols":M}
Supporting changes:
- orchestrator: add SpawnSingle() and KillClone(id) for per-clone lifecycle
management from the HTTP layer
- console: add a resize sideband Unix socket (console-resize.sock) that
accepts newline-delimited JSON {"rows","cols"} messages and applies them
to the PTY master via pty.Setsize; the WebSocket handler writes to this
socket on text frames so browser window resizes propagate into the VM
- deps: add gorilla/websocket v1.5.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
298
orchestrator/web/terminal.html
Normal file
298
orchestrator/web/terminal.html
Normal file
@@ -0,0 +1,298 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>fc-orch console</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5/css/xterm.css"/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8/lib/xterm-addon-fit.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: #0d0d0d;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* ── index / clone picker ── */
|
||||
#index {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
}
|
||||
#index h1 { margin: 0; font-size: 1.4rem; color: #8be; }
|
||||
|
||||
#clone-list {
|
||||
list-style: none;
|
||||
padding: 0; margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
}
|
||||
.clone-entry {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.clone-entry a {
|
||||
display: block;
|
||||
padding: .4rem 1rem;
|
||||
color: #8be;
|
||||
text-decoration: none;
|
||||
transition: background .15s;
|
||||
}
|
||||
.clone-entry a:hover { background: #1e2e3e; }
|
||||
.clone-entry button.destroy {
|
||||
padding: .4rem .6rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid #444;
|
||||
color: #c44;
|
||||
cursor: pointer;
|
||||
font-size: .8rem;
|
||||
transition: background .15s;
|
||||
}
|
||||
.clone-entry button.destroy:hover { background: #2a1a1a; }
|
||||
.clone-entry button.destroy:disabled { color: #555; cursor: default; }
|
||||
|
||||
#index .none { color: #666; font-size: .9rem; }
|
||||
|
||||
#spawn-btn {
|
||||
padding: .45rem 1.2rem;
|
||||
background: #1a2e1a;
|
||||
border: 1px solid #4c4;
|
||||
border-radius: 4px;
|
||||
color: #4c4;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: .9rem;
|
||||
transition: background .15s, opacity .15s;
|
||||
}
|
||||
#spawn-btn:hover:not(:disabled) { background: #243e24; }
|
||||
#spawn-btn:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
#error-msg {
|
||||
color: #c44;
|
||||
font-size: .85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── terminal view ── */
|
||||
#terminal-wrap {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
#topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
padding: .35rem .75rem;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #222;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#topbar .title { font-size: .85rem; color: #8be; }
|
||||
#status {
|
||||
font-size: .75rem;
|
||||
padding: .15rem .5rem;
|
||||
border-radius: 3px;
|
||||
background: #1a2a1a;
|
||||
color: #4c4;
|
||||
}
|
||||
#status.disconnected { background: #2a1a1a; color: #c44; }
|
||||
#terminal-container { flex: 1; overflow: hidden; padding: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- clone picker (shown when no ?id= param) -->
|
||||
<div id="index">
|
||||
<h1>fc-orch console</h1>
|
||||
<ul id="clone-list"></ul>
|
||||
<p class="none" id="no-clones" style="display:none">No running clones.</p>
|
||||
<button id="spawn-btn">+ Spawn clone</button>
|
||||
<p id="error-msg"></p>
|
||||
</div>
|
||||
|
||||
<!-- terminal (shown when ?id=N) -->
|
||||
<div id="terminal-wrap">
|
||||
<div id="topbar">
|
||||
<span class="title" id="topbar-title"></span>
|
||||
<span id="status">connecting…</span>
|
||||
<a href="/" style="margin-left:auto;font-size:.75rem;color:#666;text-decoration:none">← all clones</a>
|
||||
</div>
|
||||
<div id="terminal-container"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get('id');
|
||||
|
||||
// ── index view ──────────────────────────────────────────────
|
||||
if (!id) {
|
||||
const ul = document.getElementById('clone-list');
|
||||
const noneEl = document.getElementById('no-clones');
|
||||
const spawnBtn = document.getElementById('spawn-btn');
|
||||
const errEl = document.getElementById('error-msg');
|
||||
|
||||
function showError(msg) {
|
||||
errEl.textContent = msg;
|
||||
errEl.style.display = '';
|
||||
}
|
||||
function clearError() {
|
||||
errEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function addCloneEntry(c) {
|
||||
noneEl.style.display = 'none';
|
||||
const li = document.createElement('li');
|
||||
li.className = 'clone-entry';
|
||||
li.dataset.id = c;
|
||||
li.innerHTML =
|
||||
`<a href="/?id=${c}">clone ${c}</a>` +
|
||||
`<button class="destroy" title="Destroy clone ${c}">✕</button>`;
|
||||
li.querySelector('.destroy').addEventListener('click', () => destroyClone(c, li));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
function refreshList() {
|
||||
fetch('/clones')
|
||||
.then(r => r.json())
|
||||
.then(clones => {
|
||||
ul.innerHTML = '';
|
||||
if (!clones || clones.length === 0) {
|
||||
noneEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
noneEl.style.display = 'none';
|
||||
clones.forEach(addCloneEntry);
|
||||
})
|
||||
.catch(() => { noneEl.style.display = ''; });
|
||||
}
|
||||
|
||||
function destroyClone(cid, li) {
|
||||
const btn = li.querySelector('.destroy');
|
||||
btn.disabled = true;
|
||||
clearError();
|
||||
fetch(`/clones/${cid}`, { method: 'DELETE' })
|
||||
.then(r => {
|
||||
if (!r.ok) return r.text().then(t => { throw new Error(t); });
|
||||
li.remove();
|
||||
if (ul.children.length === 0) noneEl.style.display = '';
|
||||
})
|
||||
.catch(e => {
|
||||
btn.disabled = false;
|
||||
showError(`destroy failed: ${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
spawnBtn.addEventListener('click', () => {
|
||||
spawnBtn.disabled = true;
|
||||
spawnBtn.textContent = 'Spawning…';
|
||||
clearError();
|
||||
fetch('/clones', { method: 'POST' })
|
||||
.then(r => {
|
||||
if (!r.ok) return r.text().then(t => { throw new Error(t); });
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
location.href = `/?id=${data.id}`;
|
||||
})
|
||||
.catch(e => {
|
||||
spawnBtn.disabled = false;
|
||||
spawnBtn.textContent = '+ Spawn clone';
|
||||
showError(`spawn failed: ${e.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
refreshList();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── terminal view ────────────────────────────────────────────
|
||||
document.getElementById('index').style.display = 'none';
|
||||
const wrap = document.getElementById('terminal-wrap');
|
||||
wrap.style.display = 'flex';
|
||||
document.getElementById('topbar-title').textContent = `clone ${id}`;
|
||||
document.title = `clone ${id} — fc-orch`;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: '#0d0d0d',
|
||||
foreground: '#d0d0d0',
|
||||
cursor: '#8be',
|
||||
selectionBackground: '#2a4a6a',
|
||||
},
|
||||
});
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal-container'));
|
||||
fitAddon.fit();
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
function setStatus(text, ok) {
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = ok ? '' : 'disconnected';
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/${id}`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected', true);
|
||||
sendResize();
|
||||
term.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = e => {
|
||||
if (e.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(e.data));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('disconnected', false);
|
||||
term.write('\r\n\x1b[31m[connection closed]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
ws.onerror = () => setStatus('error', false);
|
||||
|
||||
// Keystrokes → VM (binary frame so the server can distinguish from resize)
|
||||
term.onData(data => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const bytes = new TextEncoder().encode(data);
|
||||
ws.send(bytes.buffer);
|
||||
}
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
function sendResize() {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
|
||||
}
|
||||
}
|
||||
|
||||
term.onResize(() => sendResize());
|
||||
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => { fitAddon.fit(); }, 50);
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user