195 lines
7.3 KiB
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>
|