fester/ui/cause.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>