293 lines
11 KiB
HTML
293 lines
11 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 — Cause Graph</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: 320px 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; }
|
|
|
|
.lookup-row { display: flex; gap: 6px; }
|
|
.lookup-row input { flex: 1; }
|
|
|
|
.chain-list { list-style: none; padding: 0; margin: 0; }
|
|
.chain-list > li {
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--line);
|
|
cursor: pointer;
|
|
}
|
|
.chain-list > li:hover { background: var(--bg-3); }
|
|
.chain-list > li.active { background: var(--bg-4); border-left: 3px solid var(--accent); padding-left: 9px; }
|
|
.chain-list .reason { font-weight: 600; color: var(--fg-0); font-size: 12px; }
|
|
.chain-list .time { font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); }
|
|
.chain-list .state { font-size: 11px; color: var(--fg-2); margin-top: 2px; }
|
|
|
|
#graph {
|
|
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;
|
|
}
|
|
|
|
.cnode {
|
|
position: absolute;
|
|
min-width: 130px;
|
|
max-width: 200px;
|
|
padding: 8px 10px;
|
|
background: var(--bg-3);
|
|
border: 1px solid var(--line-2);
|
|
border-radius: var(--radius);
|
|
font-size: 11px;
|
|
z-index: 2;
|
|
}
|
|
.cnode.root { border-color: var(--accent); box-shadow: 0 0 12px rgba(255,213,79,0.3); }
|
|
.cnode .label { font-weight: 600; color: var(--fg-0); }
|
|
.cnode .reason { font-size: 10px; color: var(--fg-2); margin-top: 2px; font-family: var(--font-mono); }
|
|
.cnode .time { font-size: 10px; color: var(--fg-3); margin-top: 2px; }
|
|
|
|
svg.edges line, svg.edges path { stroke: var(--line-2); stroke-width: 1.5; fill: none; }
|
|
svg.edges line.causal { stroke: var(--accent); stroke-width: 2; }
|
|
|
|
@media (max-width: 900px) {
|
|
#wrap { grid-template-columns: 1fr; }
|
|
body { overflow: auto; height: auto; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body data-fester-shell="cause">
|
|
|
|
<div id="wrap">
|
|
|
|
<!-- LEFT: lookup + chain list -->
|
|
<section class="panel">
|
|
<div class="head"><h3>Lookup</h3></div>
|
|
<div class="body">
|
|
<div class="lookup-row">
|
|
<input id="node-input" type="text" placeholder="node name">
|
|
</div>
|
|
<div class="lookup-row" style="margin-top:6px;">
|
|
<input id="action-input" type="text" placeholder="action (optional)">
|
|
<button id="lookup" class="primary">Explain</button>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<h3>Blast Radius</h3>
|
|
<div class="lookup-row" style="margin-bottom:6px;">
|
|
<input id="blast-action" type="text" placeholder="action name">
|
|
<button id="blast-btn" class="primary">Compute</button>
|
|
</div>
|
|
<div id="blast-output" style="font-size:11px;"></div>
|
|
|
|
<hr>
|
|
|
|
<h3>Causal Chain</h3>
|
|
<ul class="chain-list" id="chain">
|
|
<li class="dim center">Enter a node name and click Explain</li>
|
|
</ul>
|
|
|
|
<hr>
|
|
|
|
<h3>Recent Events</h3>
|
|
<button class="ghost full" id="load-recent" style="margin-bottom:8px;">Load last 50 events</button>
|
|
<pre class="json" id="recent" style="max-height:200px;">—</pre>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- RIGHT: graph -->
|
|
<section class="panel">
|
|
<div class="head">
|
|
<h3>Cause Graph</h3>
|
|
<span class="dim" id="status">idle</span>
|
|
</div>
|
|
<div class="body" style="padding:0;">
|
|
<div id="graph">
|
|
<svg id="edges" class="edges"></svg>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<script src="/ui/app.js"></script>
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
let currentChain = [];
|
|
|
|
async function lookup() {
|
|
const node = $('node-input').value.trim();
|
|
const action = $('action-input').value.trim() || null;
|
|
if (!node) { Fester.toast('Enter a node name', 'warn'); return; }
|
|
|
|
$('status').textContent = 'loading...';
|
|
try {
|
|
const path = `/api/cause/explain/${encodeURIComponent(node)}` + (action ? `?action=${encodeURIComponent(action)}` : '');
|
|
const data = await Fester.api(path);
|
|
currentChain = data.chain || [];
|
|
renderChain();
|
|
renderGraph(node, action);
|
|
$('status').textContent = `${currentChain.length} events`;
|
|
} catch (e) {
|
|
Fester.toast(`Lookup failed: ${e.message}`, 'err');
|
|
$('status').textContent = 'error';
|
|
}
|
|
}
|
|
|
|
function renderChain() {
|
|
const list = $('chain');
|
|
if (!currentChain.length) {
|
|
list.innerHTML = '<li class="dim center">No causal events for this node</li>';
|
|
return;
|
|
}
|
|
list.innerHTML = currentChain.map((c, i) => `
|
|
<li data-idx="${i}">
|
|
<div class="reason">${c.reason || 'event'}</div>
|
|
<div class="time">${Fester.fmtTime(c.time)} · ${Fester.fmtRel(c.time)}</div>
|
|
${c.action ? `<div class="state">action: ${c.action} · state: ${c.state || '—'}</div>` : ''}
|
|
${c.node ? `<div class="state">node: ${c.node}</div>` : ''}
|
|
</li>
|
|
`).join('');
|
|
}
|
|
|
|
function renderGraph(rootNode, rootAction) {
|
|
const graph = $('graph');
|
|
const svg = $('edges');
|
|
// Clear existing nodes (keep svg)
|
|
graph.querySelectorAll('.cnode').forEach(n => n.remove());
|
|
Array.from(svg.querySelectorAll('line')).forEach(n => n.remove());
|
|
|
|
if (!currentChain.length) return;
|
|
|
|
// Build node list — root is the queried node, others are cause nodes
|
|
const W = graph.clientWidth || 800;
|
|
const H = graph.clientHeight || 400;
|
|
const cx = W / 2;
|
|
const cy = H / 2;
|
|
|
|
// Root
|
|
const rootEl = document.createElement('div');
|
|
rootEl.className = 'cnode root';
|
|
rootEl.style.left = (cx - 80) + 'px';
|
|
rootEl.style.top = (cy - 30) + 'px';
|
|
rootEl.innerHTML = `
|
|
<div class="label">${rootNode}${rootAction ? ' / ' + rootAction : ''}</div>
|
|
<div class="reason">query target</div>
|
|
`;
|
|
graph.appendChild(rootEl);
|
|
|
|
// Arrange causes radially around root
|
|
const n = currentChain.length;
|
|
const radius = Math.min(W, H) / 3.2;
|
|
currentChain.forEach((c, i) => {
|
|
const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
|
|
const x = cx + Math.cos(angle) * radius;
|
|
const y = cy + Math.sin(angle) * radius;
|
|
|
|
const el = document.createElement('div');
|
|
el.className = 'cnode';
|
|
el.style.left = (x - 65) + 'px';
|
|
el.style.top = (y - 20) + 'px';
|
|
el.innerHTML = `
|
|
<div class="label">${c.action || c.node || 'event'}</div>
|
|
<div class="reason">${c.reason || '—'}</div>
|
|
<div class="time">${Fester.fmtTime(c.time)}</div>
|
|
`;
|
|
graph.appendChild(el);
|
|
|
|
// Draw edge from this cause to root
|
|
setTimeout(() => {
|
|
const r1 = el.getBoundingClientRect();
|
|
const r2 = rootEl.getBoundingClientRect();
|
|
const sr = svg.getBoundingClientRect();
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
line.setAttribute('x1', r1.left + r1.width / 2 - sr.left);
|
|
line.setAttribute('y1', r1.top + r1.height / 2 - sr.top);
|
|
line.setAttribute('x2', r2.left + r2.width / 2 - sr.left);
|
|
line.setAttribute('y2', r2.top + r2.height / 2 - sr.top);
|
|
line.classList.add('causal');
|
|
svg.appendChild(line);
|
|
}, 50);
|
|
});
|
|
}
|
|
|
|
async function loadRecent() {
|
|
try {
|
|
const data = await Fester.api('/api/cause/events?limit=50');
|
|
$('recent').textContent = JSON.stringify(data.events.slice(-10).reverse(), null, 2);
|
|
} catch (e) {
|
|
$('recent').textContent = e.message;
|
|
}
|
|
}
|
|
|
|
async function computeBlast() {
|
|
const action = $('blast-action').value.trim();
|
|
if (!action) { Fester.toast('Enter an action name', 'warn'); return; }
|
|
const out = $('blast-output');
|
|
out.innerHTML = '<span class="dim">computing...</span>';
|
|
try {
|
|
const data = await Fester.api(`/api/propagation/${encodeURIComponent(action)}`);
|
|
const direct = data.direct_dependents || [];
|
|
const transitive = data.transitive_impact || [];
|
|
const cls = transitive.length > 5 ? 'err' : transitive.length > 0 ? 'warn' : 'ok';
|
|
out.innerHTML = `
|
|
<div style="margin-bottom:6px;">
|
|
<span class="badge ${cls}">${data.impact_count} impacted</span>
|
|
<span class="dim">of ${data.total_actions_known} known</span>
|
|
</div>
|
|
${direct.length ? `
|
|
<div class="dim" style="margin-bottom:2px;">Direct dependents:</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px;">
|
|
${direct.map(d => `<span class="badge">${d}</span>`).join('')}
|
|
</div>
|
|
` : '<div class="dim">no direct dependents</div>'}
|
|
${transitive.length ? `
|
|
<div class="dim" style="margin-bottom:2px;">Transitive impact:</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:4px;">
|
|
${transitive.map(d => `<span class="badge warn">${d}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
Fester.toast(`Blast radius: ${data.impact_count} actions`, cls === 'err' ? 'err' : 'ok');
|
|
} catch (e) {
|
|
out.innerHTML = `<span class="dim" style="color:var(--err);">${e.message}</span>`;
|
|
}
|
|
}
|
|
|
|
async function autoloadFirstNode() {
|
|
// Pick the first online node from /api/nodes instead of hardcoding
|
|
try {
|
|
const data = await Fester.api('/api/nodes');
|
|
const first = (data.nodes || []).find(n => n.state === 'online') || (data.nodes || [])[0];
|
|
if (first) {
|
|
$('node-input').value = first.name;
|
|
lookup();
|
|
}
|
|
} catch (e) {
|
|
// fall back to manual entry
|
|
}
|
|
}
|
|
|
|
$('lookup').addEventListener('click', lookup);
|
|
$('load-recent').addEventListener('click', loadRecent);
|
|
$('blast-btn').addEventListener('click', computeBlast);
|
|
$('node-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') lookup(); });
|
|
$('action-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') lookup(); });
|
|
$('blast-action').addEventListener('keydown', (e) => { if (e.key === 'Enter') computeBlast(); });
|
|
|
|
// Auto-lookup on load — pick first node from config
|
|
autoloadFirstNode();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|