664 lines
26 KiB
HTML
664 lines
26 KiB
HTML
<!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 & 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,'&').replace(/</g,'<')
|
|
.replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// ── 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>
|