fester/ui/sessions.html

348 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fester — Sessions</title>
<link rel="stylesheet" href="/style.css">
<style>
body { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
#wrap { flex: 1; padding: 16px; overflow: auto; }
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
.toolbar h1 { margin: 0; flex: 1; }
table { font-size: 12px; }
.badge.id { font-family: var(--font-mono); }
</style>
</head>
<body data-fester-shell="sessions">
<div id="wrap">
<div class="toolbar">
<h1>Sessions</h1>
<button id="refresh" class="ghost">⟳ Refresh</button>
<button id="new-session" class="primary">+ Start New Replay Session</button>
</div>
<div class="panel">
<div class="head">
<h3>Replay Sessions</h3>
<span class="dim" id="count"></span>
</div>
<div class="body" style="padding:0;">
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Source</th>
<th>Events</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody id="tbody">
<tr><td colspan="5" class="dim center">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<div class="panel" style="margin-top:16px;">
<div class="head"><h3>Recent Builds</h3></div>
<div class="body" style="padding:0;">
<table>
<thead>
<tr>
<th>Build ID</th>
<th>Command</th>
<th>Dir</th>
<th>Status</th>
<th>Started</th>
<th>Duration</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="builds-tbody">
<tr><td colspan="7" class="dim center">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<div class="panel" style="margin-top:16px;">
<div class="head">
<h3>Active Action Sessions (tmux)</h3>
<button class="ghost" id="refresh-actions"></button>
</div>
<div class="body" style="padding:0;">
<table>
<thead>
<tr>
<th>Session</th>
<th>Started</th>
<th>Current Cmd</th>
<th>Attached</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="actions-tbody">
<tr><td colspan="5" class="dim center">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<div class="panel" style="margin-top:16px;">
<div class="head">
<h3>Storage Snapshots (qcow2)</h3>
<button class="ghost" id="refresh-snapshots"></button>
</div>
<div class="body" style="padding:0;">
<table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Created</th>
<th>Virtual Size</th>
</tr>
</thead>
<tbody id="snapshots-tbody">
<tr><td colspan="4" class="dim center">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- tmux output modal -->
<div id="tmux-modal" class="hidden" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:1000;display:flex;align-items:center;justify-content:center;">
<div class="panel" style="width:80vw;max-width:900px;max-height:80vh;">
<div class="head">
<h3 id="tmux-title">tmux output</h3>
<button class="ghost" id="tmux-close"></button>
</div>
<div class="body" style="padding:0;">
<pre id="tmux-output" class="json" style="max-height:60vh;overflow:auto;border-radius:0;font-size:11px;background:#000;color:#0f0;"></pre>
</div>
</div>
</div>
<script src="/ui/app.js"></script>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
async function loadSessions() {
try {
const data = await Fester.api('/api/sessions');
const tbody = $('tbody');
$('count').textContent = (data.sessions || []).length + ' total';
if (!data.sessions || !data.sessions.length) {
tbody.innerHTML = '<tr><td colspan="5" class="dim center">No sessions yet — click "Start New Replay Session"</td></tr>';
return;
}
tbody.innerHTML = data.sessions.map(s => `
<tr>
<td><span class="badge id">${s.id}</span></td>
<td>${s.source || 'live'}</td>
<td>${s.events ?? 0}</td>
<td>${Fester.fmtRel(s.created_at)}</td>
<td><a class="btn" href="/ui/replay.html?session=${s.id}">▶ Open in Replay</a></td>
</tr>
`).join('');
} catch (e) {
$('tbody').innerHTML = `<tr><td colspan="5" class="dim center">Error: ${e.message}</td></tr>`;
}
}
async function loadBuilds() {
try {
const data = await Fester.api('/api/builds');
const tbody = $('builds-tbody');
if (!data.builds || !data.builds.length) {
tbody.innerHTML = '<tr><td colspan="7" class="dim center">No builds yet</td></tr>';
return;
}
tbody.innerHTML = data.builds.slice().reverse().map(b => {
const dur = b.ended_at ? (b.ended_at - b.started_at).toFixed(1) + 's' : '—';
const statusCls = b.status === 'complete' ? 'ok' : b.status === 'failed' ? 'err' : 'warn';
const isRunning = b.status === 'running';
const cancelBtn = isRunning
? `<button class="danger" data-cancel="${b.build_id}" style="padding:3px 8px;font-size:10px;">Cancel</button>`
: `<button class="ghost" data-replay="${b.build_id}" style="padding:3px 8px;font-size:10px;">▶ Replay</button>`;
return `
<tr>
<td><span class="badge id">${b.build_id}</span></td>
<td class="mono">${(b.cmd || '').slice(0, 40)}</td>
<td class="mono">${(b.dir || '').slice(0, 30)}</td>
<td><span class="badge ${statusCls}">${b.status}</span></td>
<td>${Fester.fmtRel(b.started_at)}</td>
<td>${dur}</td>
<td>${cancelBtn}</td>
</tr>
`;
}).join('');
// Wire cancel buttons
tbody.querySelectorAll('[data-cancel]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.cancel;
try {
await Fester.api(`/api/builds/${id}/cancel`, { method: 'POST' });
Fester.toast(`Build ${id} cancelled`, 'warn');
loadBuilds();
} catch (e) {
Fester.toast(`Cancel failed: ${e.message}`, 'err');
}
});
});
// Wire replay buttons (start a replay session scoped to that build's events)
tbody.querySelectorAll('[data-replay]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.replay;
try {
const res = await Fester.api('/replay/start', {
method: 'POST',
body: JSON.stringify({ journal: id }),
});
Fester.toast(`Replay session ${res.session_id} started`, 'ok');
setTimeout(() => window.location.href = `/ui/replay.html?session=${res.session_id}`, 500);
} catch (e) {
Fester.toast(`Replay start failed: ${e.message}`, 'err');
}
});
});
} catch (e) {
$('builds-tbody').innerHTML = `<tr><td colspan="7" class="dim center">Error: ${e.message}</td></tr>`;
}
}
async function newSession() {
try {
const res = await Fester.api('/replay/start', {
method: 'POST',
body: JSON.stringify({ journal: 'live' }),
});
Fester.toast(`Session ${res.session_id} created (${res.event_count} events)`, 'ok');
loadSessions();
} catch (e) {
Fester.toast(`Failed: ${e.message}`, 'err');
}
}
$('refresh').addEventListener('click', () => { loadSessions(); loadBuilds(); loadActions(); loadSnapshots(); });
$('new-session').addEventListener('click', newSession);
$('refresh-actions').addEventListener('click', loadActions);
$('refresh-snapshots').addEventListener('click', loadSnapshots);
$('tmux-close').addEventListener('click', () => $('tmux-modal').classList.add('hidden'));
async function loadActions() {
try {
const data = await Fester.api('/api/actions/active');
const tbody = $('actions-tbody');
if (!data.available) {
tbody.innerHTML = `<tr><td colspan="5" class="dim center">tmux not installed</td></tr>`;
return;
}
const sessions = data.sessions || [];
if (!sessions.length) {
tbody.innerHTML = '<tr><td colspan="5" class="dim center">No active action sessions</td></tr>';
return;
}
tbody.innerHTML = sessions.map(s => {
const created = s.created_at ? Fester.fmtRel(s.created_at) : '—';
const attached = s.attached ? '<span class="badge ok">yes</span>' : '<span class="badge">no</span>';
const actionName = s.name.replace(/^fester-/, '').replace(/-[a-f0-9]{8}$/, '');
return `
<tr>
<td><span class="badge id">${s.name}</span></td>
<td>${created}</td>
<td class="mono">${s.current_command || '—'}</td>
<td>${attached}</td>
<td>
<button class="ghost" data-tmux-view="${actionName}" style="padding:3px 8px;font-size:10px;">View Output</button>
<button class="danger" data-tmux-kill="${actionName}" style="padding:3px 8px;font-size:10px;">Kill</button>
</td>
</tr>
`;
}).join('');
// Wire view buttons
tbody.querySelectorAll('[data-tmux-view]').forEach(btn => {
btn.addEventListener('click', async () => {
const name = btn.dataset.tmuxView;
await showTmuxOutput(name);
});
});
tbody.querySelectorAll('[data-tmux-kill]').forEach(btn => {
btn.addEventListener('click', async () => {
const name = btn.dataset.tmuxKill;
try {
await Fester.api(`/api/actions/${encodeURIComponent(name)}/tmux/kill`, { method: 'POST' });
Fester.toast(`Killed session for ${name}`, 'warn');
loadActions();
} catch (e) {
Fester.toast(`Kill failed: ${e.message}`, 'err');
}
});
});
} catch (e) {
$('actions-tbody').innerHTML = `<tr><td colspan="5" class="dim center">Error: ${e.message}</td></tr>`;
}
}
async function showTmuxOutput(actionName) {
$('tmux-title').textContent = `tmux output: ${actionName}`;
$('tmux-output').textContent = 'Loading…';
$('tmux-modal').classList.remove('hidden');
try {
const res = await Fester.api(`/api/actions/${encodeURIComponent(actionName)}/tmux/output?lines=2000`);
if (!res.exists) {
$('tmux-output').textContent = `(no tmux session for ${actionName})`;
} else {
$('tmux-output').textContent = res.output || '(empty output)';
}
} catch (e) {
$('tmux-output').textContent = `Error: ${e.message}`;
}
}
async function loadSnapshots() {
try {
const data = await Fester.api('/api/storage/snapshots');
const tbody = $('snapshots-tbody');
const snaps = data.snapshots || [];
if (!snaps.length) {
tbody.innerHTML = '<tr><td colspan="4" class="dim center">No snapshots (enable qcow2_freeze in storage config)</td></tr>';
return;
}
tbody.innerHTML = snaps.map(s => {
const sizeMB = (s.size_bytes / 1024 / 1024).toFixed(1) + ' MB';
const created = s.created_at ? Fester.fmtRel(s.created_at) : '—';
const vsize = s.info?.['virtual-size']
? (s.info['virtual-size'] / 1024 / 1024 / 1024).toFixed(1) + ' GB'
: '—';
return `
<tr>
<td class="mono">${s.name}</td>
<td>${sizeMB}</td>
<td>${created}</td>
<td>${vsize}</td>
</tr>
`;
}).join('');
} catch (e) {
$('snapshots-tbody').innerHTML = `<tr><td colspan="4" class="dim center">Error: ${e.message}</td></tr>`;
}
}
loadSessions();
loadBuilds();
loadActions();
loadSnapshots();
setInterval(loadBuilds, 5000);
setInterval(loadActions, 5000);
})();
</script>
</body>
</html>