Initial commit from Martins Github

This commit is contained in:
Jake Kasper
2026-04-23 13:50:31 -05:00
parent 488a0d01ef
commit 3228db3097
30 changed files with 4377 additions and 1 deletions

663
app/ui/actions.html Normal file
View File

@@ -0,0 +1,663 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P5G Marvis Actions</title>
<style>
:root {
--bg: #0a0d14;
--surface: #0f1520;
--card: #131a28;
--border: #1e2a40;
--text: #e2e8f0;
--muted: #5a6a88;
--blue: #3b82f6;
--purple: #7c3aed;
--cyan: #06b6d4;
--green: #10b981;
--yellow: #f59e0b;
--red: #ef4444;
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 14px;
display: flex; flex-direction: column;
}
/* ── Top bar ────────────────────────────────────────────────────────────── */
.topbar {
height: 50px; background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 14px;
padding: 0 20px; flex-shrink: 0;
}
.logo-mark {
width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;
background: linear-gradient(135deg,var(--blue),var(--purple));
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #fff;
}
.marvis-word { font-size: 16px; font-weight: 800; letter-spacing: 0.06em; color: #fff; }
.org-selector {
display: flex; align-items: center; gap: 5px;
background: rgba(255,255,255,0.06); border: 1px solid var(--border);
border-radius: 6px; padding: 3px 10px; font-size: 12px;
color: var(--muted); cursor: pointer; user-select: none;
}
.org-selector span { color: var(--text); font-weight: 500; }
.topbar-right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.btn-ask {
padding: 5px 14px; border-radius: 7px; font-size: 12px; font-weight: 600;
background: rgba(255,255,255,0.07); border: 1px solid var(--border);
color: var(--text); text-decoration: none; cursor: pointer;
display: flex; align-items: center; gap: 6px;
}
.btn-ask:hover { background: rgba(255,255,255,0.12); }
.btn-ai {
padding: 5px 14px; border-radius: 7px; font-size: 12px; font-weight: 600;
background: linear-gradient(135deg,var(--blue),var(--purple));
border: none; color: #fff; cursor: pointer;
display: flex; align-items: center; gap: 6px;
}
.btn-ai.idle {
background: rgba(255,255,255,0.07);
border: 1px solid var(--border); color: var(--muted);
}
.refresh-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--green);
flex-shrink: 0;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
.refresh-dot.live { animation: pulse 2.5s infinite; }
.ts { font-size: 11px; color: var(--muted); }
/* ── Main scroll area ───────────────────────────────────────────────────── */
.main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
.main::-webkit-scrollbar { width: 4px; }
.main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
/* ── Viz section ────────────────────────────────────────────────────────── */
.viz-wrap {
position: relative; padding: 16px 20px 0;
flex-shrink: 0;
}
.actions-heading {
text-align: center; font-size: 11px; font-weight: 700;
letter-spacing: 0.18em; text-transform: uppercase;
color: var(--muted); margin-bottom: -6px; position: relative; z-index: 1;
}
#tree-svg { display: block; width: 100%; height: 260px; }
.other-link {
text-align: center; font-size: 12px; color: var(--cyan);
padding: 2px 0 4px; cursor: pointer; text-decoration: underline;
display: none;
}
/* ── Controls row ───────────────────────────────────────────────────────── */
.controls-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 20px; border-top: 1px solid var(--border);
flex-shrink: 0;
}
.time-filter {
display: flex; align-items: center; gap: 0;
border: 1.5px solid var(--yellow); border-radius: 7px;
overflow: hidden;
}
.time-btn {
padding: 4px 12px; font-size: 12px; cursor: pointer; background: transparent;
color: var(--yellow); border: none; border-right: 1px solid var(--yellow);
font-family: var(--font);
}
.time-btn:last-child { border-right: none; }
.time-btn.active { background: rgba(245,158,11,0.15); font-weight: 700; }
.time-btn:hover:not(.active) { background: rgba(245,158,11,0.07); }
.spacer { flex: 1; }
.loading-text { font-size: 11px; color: var(--muted); display: none; }
/* ── Detail panel ───────────────────────────────────────────────────────── */
.detail-panel {
margin: 0 20px 0; border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; display: none; flex-shrink: 0;
}
.detail-header {
padding: 10px 16px; display: flex; align-items: center; gap: 10px;
border-bottom: 1px solid var(--border);
}
.detail-cat-badge {
font-size: 10px; padding: 2px 10px; border-radius: 20px;
font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
}
.detail-count { margin-left: auto; font-size: 20px; font-weight: 800; }
.detail-sub { font-size: 11px; color: var(--muted); }
.detail-issues { max-height: 180px; overflow-y: auto; }
.detail-issues::-webkit-scrollbar { width: 4px; }
.detail-issues::-webkit-scrollbar-thumb { background: var(--border); border-radius:4px; }
.issue-row {
display: grid; grid-template-columns: 20px 52px 1fr auto;
gap: 10px; align-items: start;
padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,0.04);
transition: background .12s;
}
.issue-row:last-child { border-bottom: none; }
.issue-row:hover { background: rgba(255,255,255,0.03); }
.sev-dot {
width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; flex-shrink: 0;
}
.issue-nf {
font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
padding: 2px 7px; border-radius: 5px; margin-top: 1px;
background: rgba(255,255,255,0.07); color: var(--text);
width: fit-content; white-space: nowrap;
}
.issue-body {}
.issue-desc { font-size: 13px; font-weight: 500; line-height: 1.4; }
.issue-rem { font-size: 11px; color: var(--muted); margin-top: 3px; line-height: 1.4; }
.issue-count {
font-size: 20px; font-weight: 800; color: var(--muted);
white-space: nowrap; padding-top: 0;
}
.issue-source {
font-size: 10px; padding: 1px 6px; border-radius: 4px;
background: rgba(255,255,255,0.06); color: var(--muted);
margin-top: 4px; display: inline-block;
}
/* ── Chart section ──────────────────────────────────────────────────────── */
.chart-section {
margin: 8px 20px 12px; flex-shrink: 0;
}
.chart-title {
font-size: 13px; font-weight: 600; margin-bottom: 4px; color: var(--text);
}
.chart-legend {
display: flex; gap: 14px; margin-bottom: 5px; flex-wrap: wrap;
}
.legend-item {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: var(--muted);
}
.legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
#chart-svg { display: block; width: 100%; }
/* ── Empty state ────────────────────────────────────────────────────────── */
#emptyState {
flex: 1; flex-direction: column;
align-items: center; justify-content: center;
padding: 48px 20px; gap: 10px; text-align: center; display: none;
}
.empty-icon { font-size: 48px; opacity: .4; }
.empty-title { font-size: 18px; font-weight: 700; color: var(--green); }
.empty-sub { font-size: 13px; color: var(--muted); max-width: 320px; line-height: 1.6; }
</style>
</head>
<body>
<!-- ── Top bar ──────────────────────────────────────────────────────────── -->
<div class="topbar">
<div class="logo-mark"></div>
<span class="marvis-word">MARVIS</span>
<div class="org-selector">org <span>P5G Core</span></div>
<div class="topbar-right">
<div class="refresh-dot live" id="refreshDot"></div>
<span class="ts" id="tsLabel">--</span>
<a class="btn-ask" href="/core/marvis/">⌘ Ask a Question</a>
<button class="btn-ai" id="aiBtn" onclick="toggleAI()">✦ AI Driven ≡</button>
</div>
</div>
<!-- ── Main ──────────────────────────────────────────────────────────────── -->
<div class="main">
<!-- Tree visualization -->
<div class="viz-wrap">
<div class="actions-heading">ACTIONS</div>
<svg id="tree-svg" viewBox="0 0 900 260" preserveAspectRatio="xMidYMid meet"></svg>
<div class="other-link" id="otherLink"></div>
</div>
<!-- Controls row -->
<div class="controls-row">
<div class="time-filter">
<button class="time-btn active" onclick="setTime(this,'30m')">Last 30 min</button>
<button class="time-btn" onclick="setTime(this,'1h')">Last 1 h</button>
<button class="time-btn" onclick="setTime(this,'6h')">Last 6 h</button>
</div>
<div class="spacer"></div>
<span class="loading-text" id="loadingText">Analyzing logs…</span>
</div>
<!-- Empty state (shown when total==0) -->
<div id="emptyState" style="display:none;flex-direction:column;align-items:center;
justify-content:center;padding:48px 20px;gap:10px;text-align:center;flex:1;">
<div class="empty-icon"></div>
<div class="empty-title">No Actions Required</div>
<div class="empty-sub">P5G Marvis is continuously analyzing your network logs and
Prometheus metrics. No issues have been detected.</div>
</div>
<!-- Detail panel (shown on category click) -->
<div class="detail-panel" id="detailPanel">
<div class="detail-header" id="detailHeader"></div>
<div class="detail-issues" id="detailIssues"></div>
</div>
<!-- Time-series chart -->
<div class="chart-section" id="chartSection">
<div class="chart-title" id="chartTitle">Actions Over Time</div>
<div class="chart-legend" id="chartLegend"></div>
<svg id="chart-svg" height="100" viewBox="0 0 900 100"
preserveAspectRatio="none"></svg>
</div>
</div><!-- /main -->
<script>
// ── State ──────────────────────────────────────────────────────────────────
let actionsData = null;
let history = [];
let selectedCat = null;
let aiMode = true;
let timeWindow = '30m';
// ── SVG geometry constants ────────────────────────────────────────────────
const W = 900, H = 260;
const OV_CX = 450, OV_CY = 64;
const OV_HW = 108, OV_HH = 30;
const L_JX = 205;
const R_JX = 695;
const TRUNK_Y = OV_CY;
const CAT_YS = [90, 155, 218];
const L_PILL_CX = 88;
const R_PILL_CX = 812;
const PILL_W = 148;
const PILL_H = 34;
const PILL_RX = 17;
const CAT_DEF = [
{ name:'Registration', color:'#3b82f6', side:'left' },
{ name:'Authentication', color:'#f59e0b', side:'left' },
{ name:'Security', color:'#ef4444', side:'left' },
{ name:'Sessions', color:'#7c3aed', side:'right' },
{ name:'Connectivity', color:'#06b6d4', side:'right' },
{ name:'Policy', color:'#10b981', side:'right' },
];
const NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs) {
const e = document.createElementNS(NS, tag);
Object.entries(attrs).forEach(([k,v]) => e.setAttribute(k, String(v)));
return e;
}
function svgTxt(x, y, content, attrs) {
const e = document.createElementNS(NS, 'text');
e.setAttribute('x', x); e.setAttribute('y', y);
if (attrs) Object.entries(attrs).forEach(([k,v]) => e.setAttribute(k, String(v)));
e.textContent = content;
return e;
}
// ── Build SVG action tree ─────────────────────────────────────────────────
function buildTree(data) {
const svg = document.getElementById('tree-svg');
while (svg.firstChild) svg.removeChild(svg.firstChild);
const catMap = {};
if (data && data.categories) data.categories.forEach(c => catMap[c.name] = c);
const cats = CAT_DEF.map(d => ({
...d,
count: catMap[d.name] ? catMap[d.name].count : 0,
issues: catMap[d.name] ? catMap[d.name].issues : [],
}));
const leftCats = cats.filter(c => c.side === 'left');
const rightCats = cats.filter(c => c.side === 'right');
const total = (data && data.total) ? data.total : 0;
// ── defs: gradient for centre oval ──────────────────────────────────────
const defs = svgEl('defs', {});
const grad = svgEl('linearGradient', { id:'ovalGrad', x1:'0%', y1:'0%', x2:'100%', y2:'100%' });
grad.append(
svgEl('stop', { offset:'0%', 'stop-color':'#3b82f6' }),
svgEl('stop', { offset:'100%', 'stop-color':'#7c3aed' })
);
defs.append(grad);
svg.append(defs);
const DIM = '#1a2540';
// ── Structural backbone ──────────────────────────────────────────────────
// Left: horizontal trunk → vertical spine
svg.append(svgEl('line', { x1: OV_CX-OV_HW, y1: TRUNK_Y, x2: L_JX, y2: TRUNK_Y,
stroke: DIM, 'stroke-width':'1.5' }));
svg.append(svgEl('line', { x1: L_JX, y1: TRUNK_Y, x2: L_JX, y2: CAT_YS[2],
stroke: DIM, 'stroke-width':'1.5' }));
// Right: horizontal trunk → vertical spine
svg.append(svgEl('line', { x1: OV_CX+OV_HW, y1: TRUNK_Y, x2: R_JX, y2: TRUNK_Y,
stroke: DIM, 'stroke-width':'1.5' }));
svg.append(svgEl('line', { x1: R_JX, y1: TRUNK_Y, x2: R_JX, y2: CAT_YS[2],
stroke: DIM, 'stroke-width':'1.5' }));
// ── Category pills ───────────────────────────────────────────────────────
function drawCat(cat, idx) {
const left = cat.side === 'left';
const cat_y = CAT_YS[idx];
const pcx = left ? L_PILL_CX : R_PILL_CX;
const sx = left ? L_JX : R_JX;
const pl = pcx - PILL_W/2;
const pr = pcx + PILL_W/2;
const tx2 = left ? pr : pl;
const active = cat.count > 0;
const sel = selectedCat && selectedCat.name === cat.name;
// Tick line from spine to pill edge
svg.append(svgEl('line', {
x1: sx, y1: cat_y, x2: tx2, y2: cat_y,
stroke: active ? cat.color : DIM,
'stroke-width': active ? '2' : '1.5',
}));
// Junction dot for active categories
if (active) {
svg.append(svgEl('circle', { cx: sx, cy: cat_y, r:'4', fill: cat.color }));
}
// Pill group (clickable)
const g = svgEl('g', { style:'cursor:pointer;', class:'cat-pill' });
g.addEventListener('click', () => onCatClick(cat));
g.append(svgEl('rect', {
x: pl, y: cat_y - PILL_H/2, width: PILL_W, height: PILL_H, rx: PILL_RX,
fill: sel ? cat.color+'28' : active ? '#161d2e' : '#101520',
stroke: sel ? cat.color : active ? cat.color+'70' : '#1a2540',
'stroke-width': sel ? '2' : active ? '1.5' : '1',
}));
const label = active ? cat.count + ' ' + cat.name : cat.name;
g.append(svgTxt(pcx, cat_y + 5, label, {
'text-anchor': 'middle',
'font-family': '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'font-size': '12', 'font-weight': active ? '600' : '400',
fill: active ? '#e2e8f0' : '#3d4f6a',
}));
svg.append(g);
}
leftCats.forEach( (c,i) => drawCat(c,i) );
rightCats.forEach((c,i) => drawCat(c,i) );
// ── Centre oval ──────────────────────────────────────────────────────────
if (total > 0) {
svg.append(svgEl('rect', {
x: OV_CX-OV_HW-5, y: OV_CY-OV_HH-5,
width: OV_HW*2+10, height: OV_HH*2+10, rx: OV_HH+5,
fill: 'none', stroke: 'rgba(59,130,246,0.25)', 'stroke-width':'3',
}));
}
svg.append(svgEl('rect', {
x: OV_CX-OV_HW, y: OV_CY-OV_HH,
width: OV_HW*2, height: OV_HH*2, rx: OV_HH,
fill: 'url(#ovalGrad)',
}));
svg.append(svgTxt(OV_CX, OV_CY-6, 'ACTIONS', {
'text-anchor':'middle', 'font-size':'10', 'font-weight':'700',
'letter-spacing':'0.14em', fill:'rgba(255,255,255,0.65)',
'font-family':'-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
}));
svg.append(svgTxt(OV_CX, OV_CY+17, String(total), {
'text-anchor':'middle', 'font-size':'26', 'font-weight':'800',
fill:'#ffffff',
'font-family':'-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
}));
// Other actions link (categories NOT in CAT_DEF)
const knownNames = new Set(CAT_DEF.map(d => d.name));
const extra = data && data.categories
? data.categories.filter(c => !knownNames.has(c.name))
: [];
const otherLink = document.getElementById('otherLink');
if (extra.length) {
otherLink.textContent = extra.length + ' Other Action' + (extra.length>1?'s':'');
otherLink.style.display = 'block';
} else {
otherLink.style.display = 'none';
}
// Empty state
document.getElementById('emptyState').style.display = total === 0 ? 'flex' : 'none';
}
// ── Category click ────────────────────────────────────────────────────────
function onCatClick(cat) {
if (selectedCat && selectedCat.name === cat.name) {
selectedCat = null;
document.getElementById('detailPanel').style.display = 'none';
} else {
selectedCat = cat;
renderDetail(cat);
document.getElementById('detailPanel').style.display = 'block';
}
buildTree(actionsData);
renderChart();
}
function renderDetail(cat) {
document.getElementById('detailPanel').style.borderColor = cat.color + '50';
document.getElementById('detailHeader').innerHTML = `
<span class="detail-cat-badge"
style="background:${cat.color}22;color:${cat.color};border:1px solid ${cat.color}60">
${cat.name}
</span>
<span class="detail-sub">Issues detected by log &amp; metrics analysis</span>
<span class="detail-count" style="color:${cat.color}">${cat.count}</span>`;
const sevColor = { critical:'var(--red)', warning:'var(--yellow)', info:'var(--cyan)' };
const rows = cat.issues.length
? cat.issues.map(iss => `
<div class="issue-row">
<div class="sev-dot" style="background:${sevColor[iss.severity]||'var(--muted)'}"></div>
<div class="issue-nf">${esc(iss.nf)}</div>
<div class="issue-body">
<div class="issue-desc">${esc(iss.description)}</div>
<div class="issue-rem">⤷ ${esc(iss.remediation||'')}</div>
<span class="issue-source">${esc(iss.source||'log')}</span>
</div>
<div class="issue-count" style="color:${cat.color}">${iss.count}</div>
</div>`).join('')
: `<div style="padding:14px 16px;font-size:13px;color:var(--muted)">
No individual issues found in this category.</div>`;
document.getElementById('detailIssues').innerHTML = rows;
}
function esc(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Time chart ────────────────────────────────────────────────────────────
function renderChart() {
const svg = document.getElementById('chart-svg');
const legend = document.getElementById('chartLegend');
const title = document.getElementById('chartTitle');
while (svg.firstChild) svg.removeChild(svg.firstChild);
const cW=900, cH=100, padL=34, padR=8, padT=6, padB=22;
const iW = cW-padL-padR, iH = cH-padT-padB;
// Filter by time window
const wMs = timeWindow==='30m' ? 30*60e3 : timeWindow==='1h' ? 60*60e3 : 6*3600e3;
let pts = history.filter(p => Date.now()-new Date(p.time).getTime() <= wMs);
if (!pts.length) pts = history.slice(-10);
if (!pts.length) {
svg.append(svgTxt(cW/2, cH/2, 'No history yet — data accumulates every 30 s', {
'text-anchor':'middle','font-size':'12',fill:'#3d4f6a',
'font-family':'inherit',
}));
legend.innerHTML = '';
return;
}
const n = pts.length;
const series = selectedCat
? [{ name: selectedCat.name, color: selectedCat.color,
vals: pts.map(p => (p.by_category && p.by_category[selectedCat.name]) || 0) }]
: CAT_DEF.map(d => ({
name: d.name, color: d.color,
vals: pts.map(p => (p.by_category && p.by_category[d.name]) || 0),
}));
const maxV = Math.max(1, ...series.flatMap(s => s.vals));
// Update title
if (selectedCat) {
title.textContent = selectedCat.count + ' ' + selectedCat.name
+ ' Action' + (selectedCat.count!==1?'s':'');
} else {
title.textContent = 'Actions Over Time';
}
legend.innerHTML = (selectedCat ? [selectedCat] : CAT_DEF)
.map(d => `<div class="legend-item">
<div class="legend-dot" style="background:${d.color}"></div>
<span>${d.name}</span></div>`).join('');
// Grid lines + y-labels
[0, Math.ceil(maxV/2), maxV].forEach(v => {
const y = padT + iH - (v/maxV)*iH;
svg.append(svgEl('line', { x1:padL, y1:y, x2:cW-padR, y2:y,
stroke:'#1a2540','stroke-width':'1' }));
svg.append(svgTxt(padL-4, y+4, String(v), {
'text-anchor':'end','font-size':'9',fill:'#3d4f6a','font-family':'inherit',
}));
});
const barW = Math.max(2, Math.floor((iW/n)*0.55));
if (series.length > 1) {
// Stacked bars
for (let i=0; i<n; i++) {
const x = padL + (n>1 ? (i/(n-1))*iW : iW/2);
let bot=0;
series.forEach(s => {
const v = s.vals[i]; if (!v) return;
const bh = (v/maxV)*iH;
const y = padT + iH - ((bot+v)/maxV)*iH;
svg.append(svgEl('rect', {
x:x-barW/2, y:y, width:barW, height:bh,
fill:s.color+'cc', rx:2,
}));
bot+=v;
});
}
} else {
const s = series[0];
const ptStr = s.vals.map((v,i) => {
const x = padL + (n>1 ? (i/(n-1))*iW : iW/2);
const y = padT + iH - (v/maxV)*iH;
return x+','+y;
});
const lx = padL + (n>1 ? iW : iW/2);
svg.append(svgEl('path', {
d: 'M '+padL+','+(padT+iH)+' L '+ptStr.join(' L ')+' L '+lx+','+(padT+iH)+' Z',
fill: s.color+'22',
}));
svg.append(svgEl('polyline', {
points: ptStr.join(' '),
fill:'none', stroke:s.color, 'stroke-width':'2',
}));
s.vals.forEach((v,i) => {
if (!v) return;
const x = padL + (n>1 ? (i/(n-1))*iW : iW/2);
const bh=(v/maxV)*iH;
svg.append(svgEl('rect', {
x:x-barW/2, y:padT+iH-bh, width:barW, height:bh,
fill:s.color+'cc', rx:2,
}));
});
}
// X-axis time labels
[0, Math.floor((n-1)/2), n-1].filter((v,i,a)=>a.indexOf(v)===i).forEach(i => {
if (!pts[i]) return;
const x = padL + (n>1 ? (i/(n-1))*iW : iW/2);
const d = new Date(pts[i].time);
const lbl = d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0');
svg.append(svgTxt(x, cH-3, lbl, {
'text-anchor':'middle','font-size':'9',fill:'#3d4f6a','font-family':'inherit',
}));
});
}
// ── Controls ──────────────────────────────────────────────────────────────
function setTime(btn, win) {
timeWindow = win;
document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderChart();
}
function toggleAI() {
aiMode = !aiMode;
const btn = document.getElementById('aiBtn');
btn.className = aiMode ? 'btn-ai' : 'btn-ai idle';
btn.textContent = aiMode ? '✦ AI Driven ≡' : '≡ Manual Mode';
}
// ── Fetch ─────────────────────────────────────────────────────────────────
async function fetchActions() {
document.getElementById('loadingText').style.display = 'inline';
document.getElementById('refreshDot').classList.remove('live');
try {
const base = (window.location.pathname.endsWith('/')
? window.location.pathname.replace(/\/$/, '')
: window.location.pathname.split('/').slice(0,-1).join('/') || '');
const apiBase = base.replace(/\/actions$/, '');
const [ar, hr] = await Promise.all([
fetch(apiBase + '/api/actions'),
fetch(apiBase + '/api/actions/history'),
]);
actionsData = await ar.json();
history = (await hr.json()).history || [];
const d = new Date(actionsData.timestamp);
document.getElementById('tsLabel').textContent =
d.toLocaleTimeString([], { hour:'2-digit', minute:'2-digit', second:'2-digit' });
// Re-sync selected category with fresh data
if (selectedCat) {
const fresh = actionsData.categories.find(c => c.name === selectedCat.name);
if (fresh) selectedCat = Object.assign({}, selectedCat, fresh);
}
buildTree(actionsData);
if (selectedCat) renderDetail(selectedCat);
renderChart();
} catch(e) {
console.error('fetch actions failed:', e);
} finally {
document.getElementById('loadingText').style.display = 'none';
document.getElementById('refreshDot').classList.add('live');
}
}
// ── Init ──────────────────────────────────────────────────────────────────
buildTree(null);
fetchActions();
setInterval(fetchActions, 30000);
</script>
</body>
</html>