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