fester/ui/timeline.html

195 lines
7.3 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 — Timeline</title>
<link rel="stylesheet" href="/style.css">
<style>
body { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
#wrap { flex: 1; display: grid; grid-template-columns: 280px 1fr; gap: 12px; padding: 12px; overflow: hidden; }
#wrap > section { display: flex; flex-direction: column; min-height: 0; }
#wrap > section > .body { flex: 1; overflow: auto; min-height: 0; }
.node-link {
display: block;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
cursor: pointer;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-1);
}
.node-link:hover { background: var(--bg-3); }
.node-link.active { background: var(--bg-4); border-left: 3px solid var(--accent); padding-left: 7px; }
.node-link .name { font-weight: 600; color: var(--fg-0); }
.node-link .meta { font-size: 10px; color: var(--fg-3); }
.tl-event {
display: grid;
grid-template-columns: 80px 100px 80px 1fr;
gap: 12px;
align-items: baseline;
padding: 6px 10px;
border-bottom: 1px solid var(--line);
font-size: 11px;
}
.tl-event:hover { background: var(--bg-3); }
.tl-event .ts { color: var(--fg-3); font-family: var(--font-mono); }
.tl-event .type { font-weight: 600; }
.tl-event .type.task_update { color: var(--warn); }
.tl-event .type.node_update { color: var(--info); }
.tl-event .type.cache_update { color: var(--accent); }
.tl-event .type.failure { color: var(--err); }
.tl-event .type.debug { color: var(--fg-2); }
.tl-event .type.pipeline_update { color: #ab47bd; }
.tl-event .action { font-family: var(--font-mono); color: var(--fg-1); }
.tl-event .msg { color: var(--fg-2); word-break: break-word; }
</style>
</head>
<body data-fester-shell="timeline">
<div id="wrap">
<!-- LEFT: node picker -->
<section class="panel">
<div class="head">
<h3>Nodes</h3>
<button class="ghost" id="refresh"></button>
</div>
<div class="body" style="padding:0;">
<div id="node-list">
<div class="dim center" style="padding:20px;">Loading…</div>
</div>
</div>
</section>
<!-- RIGHT: event timeline -->
<section class="panel">
<div class="head">
<h3>Events for <span id="current-node" class="mono" style="color:var(--accent);"></span></h3>
<div class="row" style="gap:6px;">
<span class="badge" id="event-count">0</span>
<button class="ghost" id="rewind-btn" title="Snapshot at index">⏮ Rewind</button>
</div>
</div>
<div class="body" style="padding:0;">
<div id="timeline">
<div class="dim center" style="padding:20px;">Select a node</div>
</div>
</div>
</section>
</div>
<script src="/ui/app.js"></script>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
let currentNode = null;
let currentEvents = [];
async function loadNodes() {
try {
const data = await Fester.api('/api/nodes');
const list = $('node-list');
const nodes = data.nodes || [];
if (!nodes.length) {
list.innerHTML = '<div class="dim center" style="padding:20px;">No nodes</div>';
return;
}
list.innerHTML = nodes.map(n => `
<div class="node-link" data-node="${n.name}">
<div class="name">${n.name}</div>
<div class="meta">${n.host || '—'} · ${n.state || 'unknown'}</div>
</div>
`).join('');
list.querySelectorAll('.node-link').forEach(el => {
el.addEventListener('click', () => selectNode(el.dataset.node));
});
} catch (e) {
$('node-list').innerHTML = `<div class="dim center" style="padding:20px;">Error: ${e.message}</div>`;
}
}
async function selectNode(name) {
currentNode = name;
$('current-node').textContent = name;
// Highlight in list
document.querySelectorAll('.node-link').forEach(el => {
el.classList.toggle('active', el.dataset.node === name);
});
await loadTimeline();
}
async function loadTimeline() {
if (!currentNode) return;
const tl = $('timeline');
tl.innerHTML = '<div class="dim center" style="padding:20px;">Loading…</div>';
try {
const data = await Fester.api(`/timeline/${encodeURIComponent(currentNode)}`);
currentEvents = data.events || [];
$('event-count').textContent = currentEvents.length;
if (!currentEvents.length) {
tl.innerHTML = '<div class="dim center" style="padding:20px;">No events for this node</div>';
return;
}
tl.innerHTML = currentEvents.slice().reverse().map(e => {
const ts = e.timestamp || e.ts || 0;
const type = e.type || 'event';
const action = e.action || (e.data || {}).action || '—';
const state = e.state || (e.data || {}).state || '';
const meta = e.meta || {};
const msg = [
state ? `state=${state}` : '',
e.node ? `node=${e.node}` : '',
meta.critical ? '⚠ critical' : '',
meta.cache ? `cache=${meta.cache}` : '',
meta.target ? `target=${meta.target}` : '',
meta.build_id ? `build=${meta.build_id}` : '',
e.reason ? `reason=${e.reason}` : '',
].filter(Boolean).join(' · ');
return `
<div class="tl-event">
<span class="ts">${Fester.fmtTime(ts)}</span>
<span class="type ${type}">${type}</span>
<span class="action">${action}</span>
<span class="msg">${msg || '—'}</span>
</div>
`;
}).join('');
} catch (e) {
tl.innerHTML = `<div class="dim center" style="padding:20px;">Error: ${e.message}</div>`;
}
}
$('refresh').addEventListener('click', () => {
loadNodes();
if (currentNode) loadTimeline();
});
$('rewind-btn').addEventListener('click', async () => {
if (!currentNode) return;
const idx = prompt('Rewind to event index:', '0');
if (idx === null) return;
try {
const snap = await Fester.api(`/timeline/rewind/${parseInt(idx)}`);
alert(`Snapshot at index ${snap.index}:\n` +
`Events: ${snap.snapshot.event_count}\n` +
`Nodes: ${snap.snapshot.nodes.map(n => n.name).join(', ')}\n` +
`Actions: ${snap.snapshot.actions.map(a => `${a.name}=${a.state}`).join(', ')}`);
} catch (e) {
Fester.toast(`Rewind failed: ${e.message}`, 'err');
}
});
// Auto-refresh every 5s
loadNodes();
setInterval(() => { if (currentNode) loadTimeline(); }, 5000);
})();
</script>
</body>
</html>