348 lines
14 KiB
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>
|