Files
firecracker-orchestrator/orchestrator/web/terminal.html
Honza Novak fb1db7c9ea feat: multi-distro support and tagged golden snapshots
Add Alpine, Debian, and Ubuntu rootfs support to `init [distro]`.
Golden snapshots are now namespaced under `golden/<tag>/` so multiple
baselines can coexist. `spawn [tag] [N]` selects which snapshot to
clone from. Systemd-based distros (Debian, Ubuntu) get a fc-net-init
systemd unit; Alpine keeps its inittab-based init.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:48:43 +00:00

359 lines
10 KiB
HTML

<!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; }
#spawn-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
#tag-select {
background: #1a1a1a;
border: 1px solid #444;
border-radius: 4px;
color: #8be;
padding: 0.4rem 0.8rem;
font-family: monospace;
font-size: 0.9rem;
outline: none;
transition: border-color .15s, background .15s;
cursor: pointer;
}
#tag-select:hover { background: #222; }
#tag-select:focus { border-color: #8be; }
#tag-select: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>
<div id="spawn-controls">
<select id="tag-select"></select>
<button id="spawn-btn">+ Spawn clone</button>
</div>
<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 tagSelect = document.getElementById('tag-select');
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ net: true, tag: tagSelect.value }),
})
.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}`);
});
});
function refreshTags() {
fetch('/tags')
.then(r => r.json())
.then(tags => {
tagSelect.innerHTML = '';
if (!tags || tags.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No golden VMs';
tagSelect.appendChild(opt);
tagSelect.disabled = true;
spawnBtn.disabled = true;
return;
}
tagSelect.disabled = false;
spawnBtn.disabled = false;
tags.forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
if (t === 'default' || t === 'alpine') opt.selected = true;
tagSelect.appendChild(opt);
});
})
.catch(e => {
console.error("fetch tags failed:", e);
});
}
refreshList();
refreshTags();
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>