fester/ui/live_dag.html

404 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 — Live DAG</title>
<link rel="stylesheet" href="/style.css">
<style>
body { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
#stage {
position: relative;
flex: 1;
background:
radial-gradient(circle at 1px 1px, rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 24px 24px,
var(--bg-0);
overflow: hidden;
min-height: 0;
}
#graph {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
}
/* floating inspector */
#inspector {
position: absolute;
top: 12px;
right: 12px;
width: 320px;
max-height: calc(100vh - 100px);
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
z-index: 10;
overflow: hidden;
display: flex;
flex-direction: column;
}
#inspector .head {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: var(--bg-3);
display: flex;
justify-content: space-between;
align-items: center;
}
#inspector .body { padding: 10px; overflow-y: auto; }
/* floating legend */
#legend {
position: absolute;
bottom: 12px;
left: 12px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 12px;
font-size: 11px;
z-index: 10;
}
#legend .row { display: flex; align-items: center; gap: 8px; margin: 2px 0; }
#legend .swatch { width: 12px; height: 12px; border-radius: 3px; border: 2px solid; }
/* floating controls */
#controls {
position: absolute;
top: 12px;
left: 12px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 8px;
z-index: 10;
display: flex;
gap: 6px;
align-items: center;
}
#controls button { padding: 4px 10px; font-size: 11px; }
@media (max-width: 720px) {
#inspector { width: calc(100% - 24px); right: 12px; left: 12px; }
#legend { display: none; }
}
</style>
</head>
<body data-fester-shell="live">
<div id="stage">
<div id="graph"></div>
<svg id="edges" class="edges">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z"></path>
</marker>
</defs>
</svg>
<div id="controls">
<button id="btn-relayout" title="Re-layout">⇄ Relayout</button>
<button id="btn-reset" title="Clear graph">⟲ Reset</button>
<button id="btn-fit" title="Fit to view">⊕ Fit</button>
<input id="search" type="search" placeholder="filter nodes…" style="width:140px;font-size:11px;padding:3px 8px;">
<label style="display:flex;align-items:center;gap:4px;font-size:10px;color:var(--fg-2);cursor:pointer;text-transform:none;letter-spacing:0;margin:0;">
<input type="checkbox" id="toggle-critical" checked style="width:auto;">
<span>critical</span>
</label>
<label style="display:flex;align-items:center;gap:4px;font-size:10px;color:var(--fg-2);cursor:pointer;text-transform:none;letter-spacing:0;margin:0;">
<input type="checkbox" id="toggle-failed" checked style="width:auto;">
<span>failed</span>
</label>
</div>
<div id="inspector" class="hidden">
<div class="head">
<strong id="insp-title">Node</strong>
<button class="ghost" id="insp-close"></button>
</div>
<div class="body">
<pre class="json" id="insp-body"></pre>
</div>
</div>
<div id="legend">
<div class="row"><span class="swatch" style="border-color:var(--fg-3);"></span> Pending</div>
<div class="row"><span class="swatch" style="border-color:var(--warn);"></span> Running</div>
<div class="row"><span class="swatch" style="border-color:var(--ok);"></span> Done</div>
<div class="row"><span class="swatch" style="border-color:var(--err);"></span> Failed</div>
<div class="row"><span class="swatch" style="border-color:var(--info);"></span> Cache hit</div>
<div class="row"><span class="swatch" style="border-color:var(--accent); outline:2px solid var(--accent);"></span> Critical path</div>
</div>
</div>
<script src="/ui/app.js"></script>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const graphEl = $('graph');
const edgesSvg = $('edges');
/* ---------- Layered DAG layout (Sugiyama-lite) ---------- */
function computeLayout(nodes, edges) {
if (!nodes.length) return;
// build adjacency
const byId = {};
nodes.forEach(n => { byId[n.id] = n; n._deps = []; n._dependents = []; });
edges.forEach(e => {
if (byId[e.from] && byId[e.to]) {
byId[e.to]._deps.push(e.from);
byId[e.from]._dependents.push(e.to);
}
});
// assign layers via longest path from roots
const roots = nodes.filter(n => n._deps.length === 0);
const layers = new Map();
function assignLayer(id, depth) {
const existing = layers.get(id) ?? -1;
if (depth > existing) layers.set(id, depth);
byId[id]._dependents.forEach(d => assignLayer(d, depth + 1));
}
if (roots.length) {
roots.forEach(r => assignLayer(r.id, 0));
} else {
// no roots found (cyclic?), assign by index
nodes.forEach((n, i) => layers.set(n.id, i % 4));
}
// group by layer
const byLayer = {};
nodes.forEach(n => {
const l = layers.get(n.id) ?? 0;
(byLayer[l] = byLayer[l] || []).push(n);
});
const layerKeys = Object.keys(byLayer).map(Number).sort((a, b) => a - b);
const stageW = graphEl.clientWidth || window.innerWidth;
const stageH = graphEl.clientHeight || window.innerHeight;
const layerGap = Math.max(80, (stageH - 80) / Math.max(1, layerKeys.length));
layerKeys.forEach((l, li) => {
const layerNodes = byLayer[l];
const nodeGap = Math.max(140, (stageW - 80) / Math.max(1, layerNodes.length));
layerNodes.forEach((n, i) => {
const totalW = (layerNodes.length - 1) * nodeGap;
const startX = (stageW - totalW) / 2;
n.x = startX + i * nodeGap - 60;
n.y = 40 + li * layerGap;
});
});
}
/* ---------- Rendering ---------- */
function renderNode(node) {
let el = document.getElementById(`dag-${cssId(node.id)}`);
if (!el) {
el = document.createElement('div');
el.className = 'dag-node';
el.id = `dag-${cssId(node.id)}`;
el.addEventListener('click', () => inspect(node));
graphEl.appendChild(el);
}
// update class for state
el.className = 'dag-node ' + (node.state || 'pending');
if (node.cache === 'hit' || node.cache) el.classList.add('cache');
if (node.heatmap || (node.heat != null && node.heat >= 80)) el.classList.add('heat');
if (node.critical) el.classList.add('critical');
el.innerHTML = `
<div class="label">${node.name || node.id}</div>
<div class="meta">${node.target || ''}${node.node ? ' · ' + node.node : ''}</div>
`;
if (node.x != null) {
el.style.left = node.x + 'px';
el.style.top = node.y + 'px';
}
}
function cssId(s) {
return String(s).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function drawEdges(nodes, edges) {
// clear existing edge lines (keep defs)
Array.from(edgesSvg.querySelectorAll('line, path')).forEach(n => n.remove());
edges.forEach(edge => {
const fromEl = document.getElementById(`dag-${cssId(edge.from)}`);
const toEl = document.getElementById(`dag-${cssId(edge.to)}`);
if (!fromEl || !toEl) return;
const fr = fromEl.getBoundingClientRect();
const tr = toEl.getBoundingClientRect();
const sr = edgesSvg.getBoundingClientRect();
const x1 = fr.left + fr.width / 2 - sr.left;
const y1 = fr.bottom - sr.top;
const x2 = tr.left + tr.width / 2 - sr.left;
const y2 = tr.top - sr.top;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
if (edge.critical) line.classList.add('critical');
line.setAttribute('marker-end', 'url(#arrow)');
edgesSvg.appendChild(line);
});
}
/* ---------- Inspector ---------- */
function inspect(node) {
$('inspector').classList.remove('hidden');
$('insp-title').textContent = node.name || node.id;
$('insp-body').textContent = JSON.stringify(node, null, 2);
}
$('insp-close').addEventListener('click', () => $('inspector').classList.add('hidden'));
/* ---------- Buttons ---------- */
function relayout() {
const snap = Fester.targets.get();
computeLayout(snap.nodes, snap.edges);
snap.nodes.forEach(renderNode);
drawEdges(snap.nodes, snap.edges);
}
$('btn-relayout').addEventListener('click', relayout);
$('btn-reset').addEventListener('click', () => {
Fester.targets.reset();
graphEl.innerHTML = '';
Array.from(edgesSvg.querySelectorAll('line, path')).forEach(n => n.remove());
});
$('btn-fit').addEventListener('click', relayout);
/* ---------- Search + filter ---------- */
let searchQuery = '';
let showCritical = true;
let showFailed = true;
function applyFilter() {
graphEl.querySelectorAll('.dag-node').forEach(el => {
const text = (el.textContent || '').toLowerCase();
const isCritical = el.classList.contains('critical');
const isFailed = el.classList.contains('failed');
let visible = true;
if (searchQuery && !text.includes(searchQuery.toLowerCase())) visible = false;
if (isCritical && !showCritical) visible = false;
if (isFailed && !showFailed) visible = false;
el.style.opacity = visible ? '' : '0.2';
});
}
$('search').addEventListener('input', (e) => {
searchQuery = e.target.value;
applyFilter();
});
$('toggle-critical').addEventListener('change', (e) => {
showCritical = e.target.checked;
applyFilter();
});
$('toggle-failed').addEventListener('change', (e) => {
showFailed = e.target.checked;
applyFilter();
});
/* ---------- Subscribe to target updates ---------- */
Fester.targets.onUpdate(snap => {
// first time we get nodes, layout them
const needsLayout = snap.nodes.some(n => n.x == null);
if (needsLayout) computeLayout(snap.nodes, snap.edges);
snap.nodes.forEach(renderNode);
drawEdges(snap.nodes, snap.edges);
applyFilter();
});
/* ---------- Bridge WS events into Fester.targets ----------
Handles both legacy short type names (node/pipeline) and the
canonical EventType values (node_update/task_update/etc). */
Fester.subscribe(event => {
const type = event.type;
// top-level fields (schema.FesterEvent shape)
const d = {
node: event.node,
action: event.action,
state: event.state,
target: event.target,
cache: event.cache,
critical: event.critical,
heat: event.heat ?? event.meta?.heat,
jobs: event.jobs ?? event.meta?.jobs,
deps: event.deps ?? event.meta?.deps,
reason: event.reason,
};
// also accept nested data{} shape (legacy)
if (event.data && typeof event.data === 'object') {
Object.assign(d, event.data);
}
if (type === 'node' || type === 'node_update') {
Fester.targets.update({
nodes: [{
id: d.node || d.name || d.id,
name: d.name || d.node || d.id,
state: d.state || 'online',
heat: d.heat,
jobs: d.jobs,
node: d.node,
target: d.target,
heatmap: d.heatmap,
}],
});
} else if (type === 'pipeline' || type === 'pipeline_update' || type === 'task_update') {
const action = d.action || d.id;
if (!action) return;
const critical = d.critical ?? (event.meta?.critical);
const deps = d.deps ?? (event.meta?.deps) ?? [];
Fester.targets.update({
nodes: [{
id: action,
name: action,
state: d.state || 'pending',
node: d.node,
target: d.target,
cache: d.cache,
critical: critical,
}],
edges: deps.map(dep => ({ from: dep, to: action, critical })),
});
} else if (type === 'cache' || type === 'cache_update') {
if (d.action) {
Fester.targets.update({
nodes: [{ id: d.action, cache: d.state === 'hit' ? 'hit' : d.state }],
});
}
} else if (type === 'failure') {
if (d.action) {
Fester.targets.update({
nodes: [{ id: d.action, state: 'failed', reason: d.reason }],
});
}
} else if (type === 'heatmap') {
Fester.targets.update({ nodes: [{ id: d.id, heatmap: d.heatmap, heat: d.heat }] });
}
});
/* ---------- Resize handler ---------- */
let resizeT;
window.addEventListener('resize', () => {
clearTimeout(resizeT);
resizeT = setTimeout(relayout, 200);
});
})();
</script>
</body>
</html>