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>

337
app/ui/index.html Normal file
View File

@@ -0,0 +1,337 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P5G Marvis</title>
<style>
:root {
--bg: #0f1117;
--surface: #161b27;
--card: #1e2535;
--border: #2a3148;
--text: #e2e8f0;
--muted: #7a8499;
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.15);
--blue: #3b82f6;
--green: #10b981;
--yellow: #f59e0b;
--red: #ef4444;
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 14px; height: 100%;
display: flex; flex-direction: column; overflow: hidden;
}
/* ── Header ─────────────────────────────────────────────────────── */
header {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0 20px; height: 52px;
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
}
.logo {
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
background: linear-gradient(135deg, var(--purple), var(--blue));
display: flex; align-items: center; justify-content: center; font-size: 16px;
}
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
header h1 span { color: var(--muted); font-weight: 400; }
.pill {
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
background: var(--purple-dim); color: var(--purple); border: 1px solid var(--purple);
letter-spacing: 0.04em;
}
.conn { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
.dot.err { background: var(--red); animation: none; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
.dot { animation: pulse 2.5s infinite; }
/* ── Layout ─────────────────────────────────────────────────────── */
.layout {
display: grid; grid-template-columns: 320px 1fr; flex: 1; overflow: hidden;
}
/* ── Left panel ─────────────────────────────────────────────────── */
.left {
background: var(--surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
}
.section { padding: 14px 16px; border-bottom: 1px solid var(--border); }
.section-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .1em; color: var(--muted); margin-bottom: 12px;
display: flex; align-items: center; justify-content: space-between;
}
.refresh-btn {
background: none; border: none; color: var(--muted); cursor: pointer;
font-size: 13px; padding: 1px 4px; border-radius: 4px; transition: color .15s;
}
.refresh-btn:hover { color: var(--text); }
/* NF grid */
.nf-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; }
.nf-card {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 9px 6px; text-align: center; border-left: 3px solid var(--border);
transition: border-color .2s;
}
.nf-card.up { border-left-color: var(--green); }
.nf-card.down { border-left-color: var(--red); }
.nf-name { font-size: 11px; font-weight: 700; color: var(--muted); }
.nf-state { font-size: 9px; margin-top: 3px; text-transform: uppercase; letter-spacing:.05em; }
.nf-card.up .nf-state { color: var(--green); }
.nf-card.down .nf-state { color: var(--red); }
/* Alerts panel */
.alerts-scroll { flex: 1; overflow-y: auto; padding: 14px 16px; }
.alerts-scroll::-webkit-scrollbar { width: 4px; }
.alerts-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.no-alerts { text-align: center; padding: 24px 0; color: var(--muted); font-size: 13px; }
.ok-icon { font-size: 26px; margin-bottom: 6px; }
.alert-row {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 9px 12px; margin-bottom: 7px; border-left: 3px solid var(--yellow);
}
.alert-row.critical { border-left-color: var(--red); }
.alert-row-name { font-size: 12px; font-weight: 600; }
.alert-row-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
/* ── Chat panel ─────────────────────────────────────────────────── */
.chat { display: flex; flex-direction: column; overflow: hidden; }
.messages {
flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px;
}
.messages::-webkit-scrollbar { width: 4px; }
.messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.msg { display: flex; gap: 10px; max-width: 84%; }
.msg.user { align-self: flex-end; flex-direction: row-reverse; }
.avatar {
width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 13px;
}
.msg.ai .avatar { background: linear-gradient(135deg,var(--purple),var(--blue)); }
.msg.user .avatar { background: var(--card); border: 1px solid var(--border); }
.bubble {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 10px 14px; font-size: 13.5px; line-height: 1.55;
}
.msg.user .bubble { background: var(--purple); border-color: var(--purple); }
.bubble strong { color: var(--text); }
.bubble code { background: rgba(255,255,255,.08); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.ts { font-size: 10px; color: var(--muted); margin-top: 4px; }
.msg.user .ts { text-align: right; }
/* Typing indicator */
.typing { display: flex; gap: 5px; padding: 6px 2px; align-items: center; }
.typing b { width: 7px; height: 7px; background: var(--muted); border-radius: 50%; animation: bounce 1.2s infinite; }
.typing b:nth-child(2){animation-delay:.2s}.typing b:nth-child(3){animation-delay:.4s}
@keyframes bounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-7px)}}
/* Suggestions */
.chips { display: flex; gap: 6px; padding: 0 20px 10px; overflow-x: auto; flex-shrink: 0; }
.chips::-webkit-scrollbar { display: none; }
.chip {
background: var(--card); border: 1px solid var(--border); border-radius: 20px;
color: var(--text); padding: 5px 13px; font-size: 12px; cursor: pointer;
white-space: nowrap; transition: border-color .15s, background .15s;
}
.chip:hover { border-color: var(--purple); background: var(--purple-dim); }
/* Input bar */
.input-bar {
padding: 14px 20px; border-top: 1px solid var(--border);
background: var(--surface); display: flex; gap: 10px; flex-shrink: 0; align-items: center;
}
.msg-input {
flex: 1; background: var(--card); border: 1px solid var(--border);
border-radius: 24px; color: var(--text); padding: 9px 18px;
font-size: 13.5px; font-family: var(--font); outline: none; transition: border-color .15s;
}
.msg-input:focus { border-color: var(--purple); }
.msg-input::placeholder { color: var(--muted); }
.send {
background: linear-gradient(135deg,var(--purple),var(--blue)); border: none;
border-radius: 50%; width: 40px; height: 40px; color: #fff; font-size: 15px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: opacity .15s; flex-shrink: 0;
}
.send:hover { opacity: .85; }
.send:disabled { opacity: .35; cursor: default; }
@media (max-width: 680px) {
.layout { grid-template-columns: 1fr; }
.left { max-height: 260px; }
}
</style>
</head>
<body>
<header>
<div class="logo"></div>
<h1>P5G Marvis <span>/ Network AI</span></h1>
<div class="pill">AI</div>
<div class="conn"><div class="dot" id="dot"></div><span id="connLabel">Connecting…</span></div>
</header>
<div class="layout">
<!-- Left panel -->
<div class="left">
<div class="section">
<div class="section-title">
Network Functions
<button class="refresh-btn" onclick="refresh()" title="Refresh"></button>
</div>
<div class="nf-grid" id="nfGrid">
<div class="nf-card"><div class="nf-name">···</div></div>
</div>
</div>
<div class="alerts-scroll">
<div class="section-title" style="margin-bottom:10px">Active Alerts</div>
<div id="alertsContent"><div style="color:var(--muted);font-size:12px">Loading…</div></div>
</div>
</div>
<!-- Chat panel -->
<div class="chat">
<div class="messages" id="messages"></div>
<div class="chips">
<button class="chip" onclick="ask(this)">Network health overview</button>
<button class="chip" onclick="ask(this)">Any active alerts?</button>
<button class="chip" onclick="ask(this)">UPF status</button>
<button class="chip" onclick="ask(this)">SMF session analysis</button>
<button class="chip" onclick="ask(this)">Subscriber issues?</button>
<button class="chip" onclick="ask(this)">What can you do?</button>
</div>
<div class="input-bar">
<input class="msg-input" id="inp" placeholder="Ask about your P5G network…"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}">
<button class="send" id="sendBtn" onclick="send()"></button>
</div>
</div>
</div>
<script>
// ── Utilities ──────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const ts = () => new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
function md(text) {
// minimal markdown: **bold**, `code`, newlines
return text
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>');
}
function addMsg(role, html, isTyping=false) {
const wrap = $('messages');
const el = document.createElement('div');
el.className = `msg ${role}`;
if (isTyping) el.id = 'typing';
const av = role==='ai' ? '✦' : '👤';
el.innerHTML = `
<div class="avatar">${av}</div>
<div>
<div class="bubble">${isTyping ? '<div class="typing"><b></b><b></b><b></b></div>' : html}</div>
${isTyping ? '' : `<div class="ts">${ts()}</div>`}
</div>`;
wrap.appendChild(el);
wrap.scrollTop = wrap.scrollHeight;
return el;
}
// ── Network status ─────────────────────────────────────────────────────────
async function loadNFs() {
try {
const d = await (await fetch('./api/network/status')).json();
const grid = $('nfGrid');
grid.innerHTML = '';
(d.nfs||[]).forEach(nf => {
const c = document.createElement('div');
c.className = `nf-card ${nf.state}`;
c.title = nf.instance || nf.name;
c.innerHTML = `<div class="nf-name">${nf.name}</div>
<div class="nf-state">${nf.state==='up'?'● up':nf.state==='down'?'● dn':'○ n/a'}</div>`;
grid.appendChild(c);
});
$('dot').className = 'dot';
$('connLabel').textContent = 'Live';
} catch {
$('dot').className = 'dot err';
$('connLabel').textContent = 'Unreachable';
$('nfGrid').innerHTML = '<div style="color:var(--muted);font-size:12px;grid-column:1/-1">Cannot reach API</div>';
}
}
async function loadAlerts() {
try {
const d = await (await fetch('./api/alerts')).json();
const el = $('alertsContent');
if (!d.alerts || d.alerts.length === 0) {
el.innerHTML = '<div class="no-alerts"><div class="ok-icon">✓</div>No active alerts</div>';
} else {
el.innerHTML = d.alerts.slice(0,10).map(a =>
`<div class="alert-row ${a.severity||'warning'}">
<div class="alert-row-name">${a.name}</div>
<div class="alert-row-desc">${a.summary||a.instance||''}</div>
</div>`
).join('');
}
} catch {
$('alertsContent').innerHTML = '<div style="color:var(--muted);font-size:12px">Cannot reach alerts API</div>';
}
}
async function refresh() { await Promise.all([loadNFs(), loadAlerts()]); }
// ── Chat ──────────────────────────────────────────────────────────────────
async function send() {
const inp = $('inp');
const text = inp.value.trim();
if (!text) return;
inp.value = '';
$('sendBtn').disabled = true;
addMsg('user', md(text));
addMsg('ai', '', true);
try {
const res = await fetch('./api/query', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({query: text})
});
const data = await res.json();
document.getElementById('typing')?.remove();
addMsg('ai', md(data.response || 'No response.'));
} catch {
document.getElementById('typing')?.remove();
addMsg('ai', '⚠️ Cannot reach the Marvis API. Is the service running?');
}
$('sendBtn').disabled = false;
inp.focus();
}
function ask(btn) { $('inp').value = btn.textContent; send(); }
// ── Init ──────────────────────────────────────────────────────────────────
(async () => {
addMsg('ai', md(
"Hello! I'm **P5G Marvis** — your AI assistant for HPE Private 5G.\n\n" +
"I monitor your network functions in real time, surface active alerts, and answer " +
"natural language questions about your network.\n\n" +
"_Loading network state…_"
));
await refresh();
setInterval(refresh, 30000);
})();
</script>
</body>
</html>

693
app/ui/overview.html Normal file
View File

@@ -0,0 +1,693 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P5G Marvis Insights</title>
<style>
:root {
--bg: #0f1117;
--surface: #161b27;
--card: #1e2535;
--border: #2a3148;
--text: #e2e8f0;
--muted: #7a8499;
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.15);
--blue: #3b82f6;
--blue-dim: rgba(59,130,246,0.15);
--green: #10b981;
--green-dim: rgba(16,185,129,0.12);
--yellow: #f59e0b;
--yellow-dim: rgba(245,158,11,0.12);
--red: #ef4444;
--red-dim: rgba(239,68,68,0.12);
--orange: #f97316;
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 14px; height: 100%;
display: flex; flex-direction: column; overflow: hidden;
}
/* ── Header ────────────────────────────────────────────────────── */
header {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0 20px; height: 52px;
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
}
.logo {
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
background: linear-gradient(135deg, var(--purple), var(--blue));
display: flex; align-items: center; justify-content: center; font-size: 16px;
}
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
header h1 span { color: var(--muted); font-weight: 400; }
.pill {
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
background: var(--blue-dim); color: var(--blue); border: 1px solid var(--blue);
letter-spacing: 0.04em;
}
.hdr-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.conn { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
.dot { animation: pulse 2.5s infinite; }
.dot.err { background: var(--red); animation: none; }
.stat-badge {
font-size: 11px; color: var(--muted);
background: var(--card); border: 1px solid var(--border);
padding: 3px 10px; border-radius: 20px;
}
.stat-badge b { color: var(--text); }
/* ── Toolbar ───────────────────────────────────────────────────── */
.toolbar {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 10px 20px; display: flex; align-items: center; gap: 10px; flex-shrink: 0;
}
.search-wrap { position: relative; flex: 1; max-width: 300px; }
.search-wrap svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px; color: var(--muted); pointer-events: none;
}
.search {
width: 100%; background: var(--card); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); padding: 7px 12px 7px 32px;
font-size: 13px; font-family: var(--font); outline: none;
transition: border-color .15s;
}
.search:focus { border-color: var(--blue); }
.search::placeholder { color: var(--muted); }
.filter-group { display: flex; gap: 6px; }
.filter-btn {
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
color: var(--muted); padding: 6px 12px; font-size: 12px; cursor: pointer;
transition: all .15s; font-family: var(--font); white-space: nowrap;
}
.filter-btn:hover, .filter-btn.active { border-color: var(--blue); color: var(--text); background: var(--blue-dim); }
.filter-btn.active { font-weight: 600; }
.tb-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.refresh-btn {
background: none; border: 1px solid var(--border); color: var(--muted);
cursor: pointer; font-size: 14px; padding: 5px 10px; border-radius: 6px;
transition: all .15s; font-family: var(--font);
}
.refresh-btn:hover { color: var(--text); border-color: var(--blue); }
/* ── Table ─────────────────────────────────────────────────────── */
.table-wrap {
flex: 1; overflow-y: auto; padding: 16px 20px;
}
.table-wrap::-webkit-scrollbar { width: 5px; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
table {
width: 100%; border-collapse: collapse;
font-size: 13px;
}
thead th {
background: var(--surface); color: var(--muted);
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; padding: 10px 14px; text-align: left;
border-bottom: 1px solid var(--border); white-space: nowrap;
position: sticky; top: 0; z-index: 10;
cursor: pointer; user-select: none;
}
thead th:hover { color: var(--text); }
thead th .sort-icon { margin-left: 4px; opacity: .4; }
thead th.sorted .sort-icon { opacity: 1; color: var(--blue); }
tbody tr {
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background .12s;
}
tbody tr:hover { background: rgba(59,130,246,0.06); }
tbody tr:last-child { border-bottom: none; }
td { padding: 11px 14px; vertical-align: middle; }
/* Status badge */
.status {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: 600; padding: 3px 9px;
border-radius: 20px; text-transform: uppercase; letter-spacing: .04em;
}
.status.connected { background: var(--green-dim); color: var(--green); }
.status.idle { background: var(--yellow-dim); color: var(--yellow); }
.status.limited { background: var(--red-dim); color: var(--red); }
/* Signal bars */
.signal { display: flex; align-items: flex-end; gap: 2px; height: 16px; }
.signal-bar {
width: 4px; border-radius: 1px;
background: var(--border);
}
.signal-bar.on { background: var(--green); }
.signal.medium .signal-bar.on { background: var(--yellow); }
.signal.low .signal-bar.on { background: var(--red); }
.signal-bar:nth-child(1) { height: 4px; }
.signal-bar:nth-child(2) { height: 7px; }
.signal-bar:nth-child(3) { height: 11px; }
.signal-bar:nth-child(4) { height: 16px; }
/* Traffic mini-bar */
.traffic { display: flex; flex-direction: column; gap: 3px; min-width: 80px; }
.traffic-row { display: flex; align-items: center; gap: 5px; font-size: 11px; }
.traffic-label { color: var(--muted); width: 16px; }
.traffic-val { color: var(--text); min-width: 52px; }
.traffic-bar-wrap { flex: 1; height: 3px; background: var(--border); border-radius: 2px; }
.traffic-bar { height: 100%; border-radius: 2px; background: var(--blue); }
.traffic-bar.up { background: var(--green); }
/* Device cell */
.dev { display: flex; align-items: center; gap: 10px; }
.dev-icon {
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
background: var(--card); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; font-size: 15px;
}
.dev-name { font-weight: 600; font-size: 13px; }
.dev-imsi { font-size: 11px; color: var(--muted); margin-top: 1px; }
/* IMEI/IP mono */
.mono { font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 12px; }
/* gNB cell */
.gnb { display: flex; flex-direction: column; gap: 2px; }
.gnb-name { font-size: 12px; font-weight: 600; color: var(--blue); }
.gnb-cell { font-size: 11px; color: var(--muted); }
/* Last seen */
.last-seen { font-size: 12px; color: var(--muted); white-space: nowrap; }
.last-seen.recent { color: var(--green); }
/* Empty state */
.empty { text-align: center; padding: 48px; color: var(--muted); }
.empty-icon { font-size: 36px; margin-bottom: 12px; }
/* ── Modal overlay ─────────────────────────────────────────────── */
.overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.65); backdrop-filter: blur(4px);
z-index: 100; align-items: center; justify-content: center;
padding: 20px;
}
.overlay.open { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 16px; width: 100%; max-width: 680px;
max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 60px rgba(0,0,0,.5);
animation: slideUp .2s ease;
}
@keyframes slideUp { from { transform: translateY(18px); opacity:0; } to { transform:none; opacity:1; } }
.modal::-webkit-scrollbar { width: 5px; }
.modal::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.modal-hdr {
padding: 18px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
}
.modal-hdr .dev-icon { width: 40px; height: 40px; font-size: 20px; }
.modal-hdr-titles { flex: 1; }
.modal-hdr-titles h2 { font-size: 16px; font-weight: 700; }
.modal-hdr-titles p { font-size: 12px; color: var(--muted); margin-top: 2px; }
.modal-close {
background: none; border: none; color: var(--muted); font-size: 20px;
cursor: pointer; padding: 4px 8px; line-height: 1; border-radius: 6px;
transition: all .15s;
}
.modal-close:hover { color: var(--text); background: var(--card); }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 18px; }
.m-section-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .1em; color: var(--muted); margin-bottom: 10px;
}
.m-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
}
.m-grid.cols3 { grid-template-columns: repeat(3, 1fr); }
.m-kv {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 12px;
}
.m-kv-key { font-size: 10px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
.m-kv-val { font-size: 13px; font-weight: 600; margin-top: 3px; }
.m-kv-val.mono { font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 12px; }
.m-kv-val.green { color: var(--green); }
.m-kv-val.yellow { color: var(--yellow); }
.m-kv-val.red { color: var(--red); }
.m-kv-val.blue { color: var(--blue); }
/* Sparkline / traffic chart */
.chart-wrap {
background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 14px;
}
.chart-title { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
.chart-title .chart-legend { display: flex; gap: 12px; }
.chart-legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
.legend-dot.dl { background: var(--blue); }
.legend-dot.ul { background: var(--green); }
canvas#trafficChart { width: 100% !important; height: 80px; }
/* Modal action buttons */
.m-actions { display: flex; gap: 8px; padding-top: 4px; }
.m-btn {
flex: 1; padding: 9px 16px; border-radius: 8px; font-size: 13px;
font-weight: 600; cursor: pointer; border: 1px solid var(--border);
font-family: var(--font); transition: all .15s;
}
.m-btn.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
.m-btn.primary:hover { opacity: .85; }
.m-btn.danger { background: var(--red-dim); border-color: var(--red); color: var(--red); }
.m-btn.danger:hover { background: var(--red); color: #fff; }
.m-btn.secondary { background: var(--card); color: var(--muted); }
.m-btn.secondary:hover { border-color: var(--blue); color: var(--text); }
</style>
</head>
<body>
<header>
<div class="logo"></div>
<h1>P5G Marvis <span>/ Connected Clients</span></h1>
<div class="pill">INSIGHTS</div>
<div class="hdr-right">
<div class="stat-badge">Connected: <b id="cntConnected"></b></div>
<div class="stat-badge">Idle: <b id="cntIdle"></b></div>
<div class="stat-badge">Issues: <b id="cntIssues"></b></div>
<div class="conn"><div class="dot" id="dot"></div><span id="connLabel">Loading…</span></div>
</div>
</header>
<div class="toolbar">
<div class="search-wrap">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="8.5" cy="8.5" r="5.5"/><path d="M15 15l-3-3"/>
</svg>
<input class="search" id="searchInp" placeholder="Search by device, IMSI, IP, gNB…" oninput="filterTable()">
</div>
<div class="filter-group">
<button class="filter-btn active" id="f-all" onclick="setFilter('all')">All</button>
<button class="filter-btn" id="f-connected" onclick="setFilter('connected')">Connected</button>
<button class="filter-btn" id="f-idle" onclick="setFilter('idle')">Idle</button>
<button class="filter-btn" id="f-limited" onclick="setFilter('limited')">Issues</button>
</div>
<div class="tb-right">
<button class="refresh-btn" onclick="renderTable()">↻ Refresh</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th onclick="sortBy('name')">Device <span class="sort-icon"></span></th>
<th onclick="sortBy('ip')">Assigned IP <span class="sort-icon"></span></th>
<th onclick="sortBy('gnb')">Connected Radio <span class="sort-icon"></span></th>
<th>Signal</th>
<th>Traffic (DL/UL)</th>
<th onclick="sortBy('lastSeen')">Last Activity <span class="sort-icon"></span></th>
<th>APN / DNN</th>
<th onclick="sortBy('status')">Status <span class="sort-icon"></span></th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<div class="empty" id="emptyState" style="display:none">
<div class="empty-icon">📡</div>
<div>No clients match your filter</div>
</div>
</div>
<!-- Modal -->
<div class="overlay" id="overlay" onclick="closeModal(event)">
<div class="modal" id="modal">
<div class="modal-hdr">
<div class="dev-icon" id="m-icon"></div>
<div class="modal-hdr-titles">
<h2 id="m-title"></h2>
<p id="m-subtitle"></p>
</div>
<div id="m-status-badge"></div>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-body">
<!-- UE Identity -->
<div>
<div class="m-section-title">UE Identity</div>
<div class="m-grid" id="m-identity"></div>
</div>
<!-- Session -->
<div>
<div class="m-section-title">PDU Session</div>
<div class="m-grid" id="m-session"></div>
</div>
<!-- Radio -->
<div>
<div class="m-section-title">Radio Link</div>
<div class="m-grid cols3" id="m-radio"></div>
</div>
<!-- Traffic chart -->
<div class="chart-wrap">
<div class="chart-title">
Throughput (last 60 s)
<div class="chart-legend">
<div class="chart-legend-item"><div class="legend-dot dl"></div>Downlink</div>
<div class="chart-legend-item"><div class="legend-dot ul"></div>Uplink</div>
</div>
</div>
<canvas id="trafficChart"></canvas>
</div>
<!-- Actions -->
<div class="m-actions">
<button class="m-btn secondary" onclick="closeModal()">Close</button>
<button class="m-btn primary">📋 View Session Log</button>
<button class="m-btn danger" id="m-disconnect-btn">⊘ Force Disconnect</button>
</div>
</div>
</div>
</div>
<script>
// ─── Mock data ───────────────────────────────────────────────────────────────
const CLIENTS = [
{
id: 1, icon: '📱', name: 'UE-Alpha-001', imsi: '315010000000001', imei: '353456789012301', iccid: '8901315010000000018',
ip: '10.45.0.11', gnb: 'gNB-North-01', cellId: 'Cell-NR-0101', rsrp: -72, rsrq: -9, sinr: 18,
dl: 48.2, ul: 12.4, dlMax: 80, ulMax: 30, apn: 'internet', pduType: 'IPv4',
sessionId: 'PDU-0x4A3F', upf: 'UPF-01', lastSeen: 4, status: 'connected',
connectedSince: '06:42:11', sliceId: 'SST:1 SD:000001'
},
{
id: 2, icon: '💻', name: 'UE-Beta-007', imsi: '315010000000007', imei: '353456789012302', iccid: '8901315010000000074',
ip: '10.45.0.22', gnb: 'gNB-North-01', cellId: 'Cell-NR-0102', rsrp: -85, rsrq: -12, sinr: 11,
dl: 9.1, ul: 2.8, dlMax: 80, ulMax: 30, apn: 'ims', pduType: 'IPv4v6',
sessionId: 'PDU-0x4A40', upf: 'UPF-01', lastSeen: 12, status: 'connected',
connectedSince: '05:15:33', sliceId: 'SST:1 SD:000001'
},
{
id: 3, icon: '🖥️', name: 'UE-Camera-03', imsi: '315010000000003', imei: '353456789012303', iccid: '8901315010000000031',
ip: '10.45.0.55', gnb: 'gNB-East-02', cellId: 'Cell-NR-0201', rsrp: -68, rsrq: -8, sinr: 22,
dl: 124.7, ul: 88.3, dlMax: 150, ulMax: 100, apn: 'surveillance', pduType: 'IPv4',
sessionId: 'PDU-0x4A41', upf: 'UPF-02', lastSeen: 2, status: 'connected',
connectedSince: '07:00:01', sliceId: 'SST:2 SD:000010'
},
{
id: 4, icon: '🤖', name: 'UE-Robot-A12', imsi: '315010000000012', imei: '353456789012304', iccid: '8901315010000000128',
ip: '10.45.0.78', gnb: 'gNB-East-02', cellId: 'Cell-NR-0202', rsrp: -91, rsrq: -14, sinr: 7,
dl: 1.2, ul: 0.4, dlMax: 80, ulMax: 30, apn: 'robotics', pduType: 'IPv4',
sessionId: 'PDU-0x4A42', upf: 'UPF-02', lastSeen: 180, status: 'idle',
connectedSince: '04:28:55', sliceId: 'SST:3 SD:000020'
},
{
id: 5, icon: '📡', name: 'UE-Sensor-19', imsi: '315010000000019', imei: '353456789012305', iccid: '8901315010000000197',
ip: '10.45.0.99', gnb: 'gNB-South-03', cellId: 'Cell-NR-0301', rsrp: -74, rsrq: -10, sinr: 15,
dl: 0.3, ul: 0.8, dlMax: 80, ulMax: 30, apn: 'sensors.iot', pduType: 'IPv4',
sessionId: 'PDU-0x4A43', upf: 'UPF-03', lastSeen: 25, status: 'connected',
connectedSince: '06:55:44', sliceId: 'SST:3 SD:000020'
},
{
id: 6, icon: '🔧', name: 'UE-Maint-05', imsi: '315010000000005', imei: '353456789012306', iccid: '8901315010000000055',
ip: '10.45.0.130', gnb: 'gNB-South-03', cellId: 'Cell-NR-0302', rsrp: -103, rsrq: -17, sinr: 2,
dl: 0.1, ul: 0.0, dlMax: 80, ulMax: 30, apn: 'internet', pduType: 'IPv4',
sessionId: 'PDU-0x4A44', upf: 'UPF-03', lastSeen: 620, status: 'limited',
connectedSince: '03:11:22', sliceId: 'SST:1 SD:000001'
},
{
id: 7, icon: '📲', name: 'UE-Admin-02', imsi: '315010000000002', imei: '353456789012307', iccid: '8901315010000000025',
ip: '10.45.0.14', gnb: 'gNB-North-01', cellId: 'Cell-NR-0101', rsrp: -65, rsrq: -7, sinr: 25,
dl: 22.6, ul: 5.1, dlMax: 80, ulMax: 30, apn: 'admin', pduType: 'IPv4',
sessionId: 'PDU-0x4A45', upf: 'UPF-01', lastSeen: 8, status: 'connected',
connectedSince: '06:50:00', sliceId: 'SST:1 SD:000001'
},
{
id: 8, icon: '🏭', name: 'UE-PLC-08', imsi: '315010000000008', imei: '353456789012308', iccid: '8901315010000000082',
ip: '10.45.0.200', gnb: 'gNB-West-04', cellId: 'Cell-NR-0401', rsrp: -78, rsrq: -11, sinr: 14,
dl: 6.8, ul: 3.3, dlMax: 80, ulMax: 30, apn: 'industrial', pduType: 'IPv4',
sessionId: 'PDU-0x4A46', upf: 'UPF-04', lastSeen: 60, status: 'idle',
connectedSince: '05:40:17', sliceId: 'SST:2 SD:000010'
},
{
id: 9, icon: '🚁', name: 'UE-Drone-D4', imsi: '315010000000024', imei: '353456789012309', iccid: '8901315010000000243',
ip: '10.45.0.244', gnb: 'gNB-West-04', cellId: 'Cell-NR-0402', rsrp: -70, rsrq: -9, sinr: 19,
dl: 31.4, ul: 18.7, dlMax: 80, ulMax: 30, apn: 'uav.ctrl', pduType: 'IPv4',
sessionId: 'PDU-0x4A47', upf: 'UPF-04', lastSeen: 3, status: 'connected',
connectedSince: '07:01:55', sliceId: 'SST:2 SD:000010'
},
{
id: 10, icon: '📊', name: 'UE-Monitor-11', imsi: '315010000000011', imei: '353456789012310', iccid: '8901315010000000116',
ip: '10.45.0.181', gnb: 'gNB-East-02', cellId: 'Cell-NR-0203', rsrp: -88, rsrq: -13, sinr: 9,
dl: 4.2, ul: 1.1, dlMax: 80, ulMax: 30, apn: 'monitoring', pduType: 'IPv4v6',
sessionId: 'PDU-0x4A48', upf: 'UPF-02', lastSeen: 45, status: 'idle',
connectedSince: '04:55:30', sliceId: 'SST:1 SD:000001'
}
];
// ─── State ────────────────────────────────────────────────────────────────
let activeFilter = 'all';
let sortKey = 'status';
let sortAsc = true;
// ─── Helpers ─────────────────────────────────────────────────────────────
function fmtLastSeen(secs) {
if (secs < 30) return 'Just now';
if (secs < 120) return `${secs}s ago`;
if (secs < 3600) return `${Math.round(secs/60)}m ago`;
return `${Math.round(secs/3600)}h ago`;
}
function signalStrength(rsrp) {
if (rsrp >= -80) return 'high';
if (rsrp >= -95) return 'medium';
return 'low';
}
function signalBars(rsrp) {
const s = signalStrength(rsrp);
const bars = rsrp >= -80 ? 4 : rsrp >= -90 ? 3 : rsrp >= -100 ? 2 : 1;
return `<div class="signal ${s}">${[1,2,3,4].map(i=>`<div class="signal-bar${i<=bars?' on':''}"></div>`).join('')}</div>`;
}
function fmtMbps(v) {
if (v >= 1000) return (v/1000).toFixed(1) + ' Gbps';
return v.toFixed(1) + ' Mbps';
}
function maskedImsi(imsi) { return imsi.slice(0,5) + '·····' + imsi.slice(-4); }
// ─── Table ────────────────────────────────────────────────────────────────
function getVisible() {
const q = document.getElementById('searchInp').value.toLowerCase();
return CLIENTS
.filter(c => activeFilter === 'all' || c.status === activeFilter)
.filter(c => !q || [c.name,c.imsi,c.ip,c.gnb,c.apn].some(v=>v.toLowerCase().includes(q)))
.sort((a,b) => {
const va = a[sortKey], vb = b[sortKey];
return sortAsc ? (va > vb ? 1 : -1) : (va < vb ? 1 : -1);
});
}
function renderTable() {
const rows = getVisible();
const tbody = document.getElementById('tableBody');
document.getElementById('emptyState').style.display = rows.length ? 'none' : 'block';
// update header badges
document.getElementById('cntConnected').textContent = CLIENTS.filter(c=>c.status==='connected').length;
document.getElementById('cntIdle').textContent = CLIENTS.filter(c=>c.status==='idle').length;
document.getElementById('cntIssues').textContent = CLIENTS.filter(c=>c.status==='limited').length;
document.getElementById('dot').className = 'dot';
document.getElementById('connLabel').textContent = `${CLIENTS.length} UEs tracked`;
tbody.innerHTML = rows.map(c => {
const dlPct = Math.min(100, (c.dl/c.dlMax)*100);
const ulPct = Math.min(100, (c.ul/c.ulMax)*100);
const lsClass = c.lastSeen < 30 ? 'recent' : '';
return `<tr onclick="openModal(${c.id})">
<td>
<div class="dev">
<div class="dev-icon">${c.icon}</div>
<div>
<div class="dev-name">${c.name}</div>
<div class="dev-imsi">${maskedImsi(c.imsi)}</div>
</div>
</div>
</td>
<td class="mono">${c.ip}</td>
<td>
<div class="gnb">
<div class="gnb-name">${c.gnb}</div>
<div class="gnb-cell">${c.cellId}</div>
</div>
</td>
<td title="RSRP ${c.rsrp} dBm SINR ${c.sinr} dB">${signalBars(c.rsrp)}</td>
<td>
<div class="traffic">
<div class="traffic-row">
<span class="traffic-label">↓</span>
<span class="traffic-val">${fmtMbps(c.dl)}</span>
<div class="traffic-bar-wrap"><div class="traffic-bar" style="width:${dlPct}%"></div></div>
</div>
<div class="traffic-row">
<span class="traffic-label">↑</span>
<span class="traffic-val">${fmtMbps(c.ul)}</span>
<div class="traffic-bar-wrap"><div class="traffic-bar up" style="width:${ulPct}%"></div></div>
</div>
</div>
</td>
<td><span class="last-seen ${lsClass}">${fmtLastSeen(c.lastSeen)}</span></td>
<td style="font-size:12px">${c.apn}<br><span style="color:var(--muted);font-size:10px">${c.pduType}</span></td>
<td><span class="status ${c.status}">${c.status}</span></td>
</tr>`;
}).join('');
}
function setFilter(f) {
activeFilter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
document.getElementById('f-'+f).classList.add('active');
renderTable();
}
function filterTable() { renderTable(); }
function sortBy(key) {
if (sortKey === key) sortAsc = !sortAsc;
else { sortKey = key; sortAsc = true; }
document.querySelectorAll('thead th').forEach(th => th.classList.remove('sorted'));
renderTable();
}
// ─── Modal ────────────────────────────────────────────────────────────────
let chartCtx = null;
function kv(key, val, cls='') {
return `<div class="m-kv"><div class="m-kv-key">${key}</div><div class="m-kv-val ${cls}">${val}</div></div>`;
}
function openModal(id) {
const c = CLIENTS.find(x => x.id === id);
if (!c) return;
document.getElementById('m-icon').textContent = c.icon;
document.getElementById('m-title').textContent = c.name;
document.getElementById('m-subtitle').textContent = `IMSI ${c.imsi} · ${c.ip}`;
document.getElementById('m-status-badge').innerHTML = `<span class="status ${c.status}">${c.status}</span>`;
// UE Identity
document.getElementById('m-identity').innerHTML = [
kv('IMSI', c.imsi, 'mono'),
kv('IMEI', c.imei, 'mono'),
kv('ICCID', c.iccid, 'mono'),
kv('Connected Since', c.connectedSince),
].join('');
// PDU Session
const sessColor = c.status === 'limited' ? 'red' : c.status === 'idle' ? 'yellow' : 'green';
document.getElementById('m-session').innerHTML = [
kv('Assigned IP', c.ip, 'mono blue'),
kv('PDU Type', c.pduType),
kv('APN / DNN', c.apn),
kv('Session ID', c.sessionId, 'mono'),
kv('UPF Node', c.upf, 'blue'),
kv('Network Slice', c.sliceId),
kv('Downlink', fmtMbps(c.dl), 'blue'),
kv('Uplink', fmtMbps(c.ul), 'green'),
].join('');
// Radio link
const rsrpColor = c.rsrp >= -80 ? 'green' : c.rsrp >= -95 ? 'yellow' : 'red';
document.getElementById('m-radio').innerHTML = [
kv('gNB', c.gnb, 'blue'),
kv('Cell ID', c.cellId, 'mono'),
kv('RSRP', `${c.rsrp} dBm`, rsrpColor),
kv('RSRQ', `${c.rsrq} dB`, rsrpColor),
kv('SINR', `${c.sinr} dB`, c.sinr >= 15 ? 'green' : c.sinr >= 8 ? 'yellow' : 'red'),
kv('Signal', signalStrength(c.rsrp).toUpperCase(), rsrpColor),
].join('');
// Disconnect button state
document.getElementById('m-disconnect-btn').disabled = c.status === 'limited';
// Traffic sparkline
drawChart(c);
document.getElementById('overlay').classList.add('open');
}
function closeModal(e) {
if (e && e.target !== document.getElementById('overlay')) return;
document.getElementById('overlay').classList.remove('open');
}
function drawChart(c) {
const canvas = document.getElementById('trafficChart');
const ctx = canvas.getContext('2d');
// Generate fake smoothed time-series (20 points)
const points = 20;
function fakeSeries(base, noise) {
let v = base;
return Array.from({length: points}, () => {
v += (Math.random() - 0.5) * noise;
v = Math.max(0, v);
return v;
});
}
const dl = fakeSeries(c.dl, c.dl * 0.35);
const ul = fakeSeries(c.ul, c.ul * 0.4);
const W = canvas.offsetWidth || 600;
const H = 80;
canvas.width = W;
canvas.height = H;
const maxVal = Math.max(...dl, ...ul, 1);
const step = W / (points - 1);
function toX(i) { return i * step; }
function toY(v) { return H - 8 - (v / maxVal) * (H - 16); }
function drawLine(series, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
series.forEach((v, i) => {
if (i === 0) ctx.moveTo(toX(i), toY(v));
else ctx.lineTo(toX(i), toY(v));
});
ctx.stroke();
// fill under
ctx.lineTo(toX(points-1), H);
ctx.lineTo(0, H);
ctx.closePath();
ctx.fillStyle = color.replace(')', ',0.08)').replace('rgb', 'rgba');
ctx.fill();
}
ctx.clearRect(0, 0, W, H);
// Grid lines
ctx.strokeStyle = 'rgba(42,49,72,0.8)';
ctx.lineWidth = 1;
[0.25, 0.5, 0.75, 1].forEach(r => {
const y = H - 8 - r * (H - 16);
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
});
drawLine(dl, 'rgb(59,130,246)');
drawLine(ul, 'rgb(16,185,129)');
}
// ─── Init ─────────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => { if (e.key === 'Escape') document.getElementById('overlay').classList.remove('open'); });
renderTable();
</script>
</body>
</html>

534
app/ui/tasks.html Normal file
View File

@@ -0,0 +1,534 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P5G Marvis Tasks</title>
<style>
:root {
--bg: #0f1117;
--surface: #161b27;
--card: #1e2535;
--border: #2a3148;
--text: #e2e8f0;
--muted: #7a8499;
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.15);
--blue: #3b82f6;
--green: #10b981;
--yellow: #f59e0b;
--red: #ef4444;
--orange: #f97316;
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 14px; height: 100%;
display: flex; flex-direction: column; overflow: hidden;
}
/* ── Header ─────────────────────────────────────────────────────── */
header {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0 20px; height: 52px;
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
}
.logo {
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
background: linear-gradient(135deg, var(--purple), var(--blue));
display: flex; align-items: center; justify-content: center; font-size: 16px;
}
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
header h1 span { color: var(--muted); font-weight: 400; }
.pill {
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
background: var(--purple-dim); color: var(--purple); border: 1px solid var(--purple);
letter-spacing: 0.04em;
}
.conn { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
.dot { animation: pulse 2.5s infinite; }
/* ── Main ────────────────────────────────────────────────────────── */
.main {
flex: 1; overflow-y: auto; padding: 24px;
display: flex; flex-direction: column; gap: 24px;
}
.main::-webkit-scrollbar { width: 5px; }
.main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
/* ── Section headers ─────────────────────────────────────────────── */
.section-title {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .1em; color: var(--muted); margin-bottom: 12px;
}
/* ── Action grid ─────────────────────────────────────────────────── */
.action-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px;
}
.action-card {
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
padding: 16px; display: flex; flex-direction: column; gap: 10px;
border-left: 3px solid var(--border); transition: border-color .15s, background .15s;
cursor: pointer;
}
.action-card:hover { border-left-color: var(--purple); background: rgba(124,58,237,0.05); }
.action-card.danger:hover { border-left-color: var(--red); background: rgba(239,68,68,0.05); }
.action-card.warning:hover { border-left-color: var(--yellow); background: rgba(245,158,11,0.05); }
.action-header { display: flex; align-items: center; gap: 10px; }
.action-icon {
width: 34px; height: 34px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 17px;
}
.action-icon.purple { background: var(--purple-dim); }
.action-icon.blue { background: rgba(59,130,246,.15); }
.action-icon.green { background: rgba(16,185,129,.15); }
.action-icon.yellow { background: rgba(245,158,11,.15); }
.action-icon.red { background: rgba(239,68,68,.15); }
.action-icon.orange { background: rgba(249,115,22,.15); }
.action-name { font-size: 13px; font-weight: 600; }
.action-nf { font-size: 10px; color: var(--muted); margin-top: 1px; }
.action-desc { font-size: 12px; color: var(--muted); line-height: 1.5; }
.action-footer { display: flex; align-items: center; justify-content: space-between; }
.action-meta { font-size: 11px; color: var(--muted); }
.run-btn {
background: var(--purple); border: none; border-radius: 6px;
color: #fff; padding: 5px 14px; font-size: 12px; font-weight: 600;
cursor: pointer; font-family: var(--font); transition: opacity .15s;
}
.run-btn:hover { opacity: .85; }
.run-btn.danger { background: var(--red); }
.run-btn.warning { background: var(--yellow); color: #000; }
/* ── Run log ─────────────────────────────────────────────────────── */
.log-panel {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; display: flex; flex-direction: column;
}
.log-header {
padding: 12px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
}
.log-title { font-size: 12px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.log-badge {
background: var(--border); border-radius: 20px;
font-size: 10px; padding: 1px 8px; color: var(--muted);
}
.log-header-actions { display: flex; gap: 8px; align-items: center; }
.clear-btn, .expand-btn {
background: none; border: 1px solid var(--border); border-radius: 6px;
color: var(--muted); padding: 3px 10px; font-size: 11px; cursor: pointer;
font-family: var(--font); transition: color .15s;
}
.clear-btn:hover, .expand-btn:hover { color: var(--text); }
.log-body {
padding: 12px 16px; font-size: 13px; font-family: 'SF Mono','Fira Code',monospace;
height: 420px; overflow-y: auto; display: flex; flex-direction: column; gap: 5px;
resize: vertical; min-height: 180px;
}
.log-body.expanded { height: calc(100vh - 280px); }
.log-body::-webkit-scrollbar { width: 6px; }
.log-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.log-body::-webkit-scrollbar-track { background: transparent; }
.log-empty { color: var(--muted); font-style: italic; text-align: center; padding: 24px 0; }
.log-line { display: flex; gap: 10px; line-height: 1.5; }
.log-line.log-separator { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 4px; }
.log-time { color: var(--muted); flex-shrink: 0; font-size: 11px; padding-top: 1px; }
.log-msg { word-break: break-word; }
.log-msg.ok { color: var(--green); }
.log-msg.warn { color: var(--yellow); }
.log-msg.err { color: var(--red); }
.log-msg.info { color: var(--blue); }
.log-msg.run { color: var(--purple); font-weight: 600; }
/* ── Modal overlay ───────────────────────────────────────────────── */
.modal-bg {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.6); z-index: 100;
align-items: center; justify-content: center;
}
.modal-bg.open { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 420px; max-width: 90vw;
}
.modal h3 { font-size: 15px; font-weight: 700; margin-bottom: 8px; }
.modal p { font-size: 13px; color: var(--muted); line-height: 1.55; margin-bottom: 20px; }
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; }
.modal-cancel {
background: none; border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: 7px 18px; font-size: 13px; cursor: pointer;
font-family: var(--font);
}
.modal-confirm {
border: none; border-radius: 8px;
color: #fff; padding: 7px 18px; font-size: 13px; font-weight: 600;
cursor: pointer; font-family: var(--font); background: var(--purple);
}
.modal-confirm.danger { background: var(--red); }
.modal-confirm.warning { background: var(--yellow); color: #000; }
</style>
</head>
<body>
<header>
<div class="logo">📋</div>
<h1>P5G Marvis <span>/ Tasks</span></h1>
<div class="pill">TASKS</div>
<div class="conn"><div class="dot" id="dot"></div><span id="connLabel">Connecting…</span></div>
</header>
<div class="main">
<!-- Diagnostics -->
<div>
<div class="section-title">Diagnostics &amp; Health</div>
<div class="action-grid" id="diagGrid"></div>
</div>
<!-- Operations -->
<div>
<div class="section-title">Network Operations</div>
<div class="action-grid" id="opsGrid"></div>
</div>
<!-- Maintenance -->
<div>
<div class="section-title">Maintenance</div>
<div class="action-grid" id="maintGrid"></div>
</div>
<!-- Run log -->
<div class="log-panel">
<div class="log-header">
<div class="log-title">
▸ Run Log
<span class="log-badge" id="logCount">0 entries</span>
</div>
<div class="log-header-actions">
<button class="expand-btn" id="expandBtn" onclick="toggleExpand()">⤢ Expand</button>
<button class="clear-btn" onclick="clearLog()">Clear</button>
</div>
</div>
<div class="log-body" id="logBody">
<div class="log-empty" id="logEmpty">No actions run yet.</div>
</div>
</div>
</div>
<!-- Confirm modal -->
<div class="modal-bg" id="modalBg">
<div class="modal">
<h3 id="modalTitle">Confirm Action</h3>
<p id="modalDesc"></p>
<div class="modal-footer">
<button class="modal-cancel" onclick="closeModal()">Cancel</button>
<button class="modal-confirm" id="modalOk" onclick="runConfirmed()">Run</button>
</div>
</div>
</div>
<script>
const ACTIONS = {
diag: [
{ id:'ping_nfs', icon:'🔍', color:'blue', name:'Ping All NFs', nf:'All NFs', desc:'Send ICMP probes to all registered network functions and report reachability.', safe:true, run:pingNFs },
{ id:'check_alerts', icon:'🔔', color:'yellow', name:'Refresh Alerts', nf:'Alertmanager', desc:'Pull the latest alerts from Alertmanager and update the AI dashboard.', safe:true, run:refreshAlerts },
{ id:'nf_status', icon:'📊', color:'purple', name:'Full NF Status Report', nf:'All NFs', desc:'Query Prometheus for all 12 NF health metrics and generate a status summary.', safe:true, run:nfReport },
{ id:'trace_path', icon:'🛤️', color:'blue', name:'Trace UE Data Path', nf:'AMF→UPF', desc:'Trace the user-plane path for a sample SUPI through AMF, SMF, and UPF.', safe:true, run:traceUE },
],
ops: [
{ id:'emulated_session', icon:'📡', color:'purple', name:'Perform Emulated Data Session', nf:'AMF→SMF→UPF', desc:'Simulate a full device attach and data session end-to-end to confirm the network is functioning correctly for users.', safe:true, run:emulatedSession },
{ id:'check_devices', icon:'📱', color:'blue', name:'Check Connected Devices', nf:'AMF', desc:'List all devices currently registered on the network with their connection status, signal quality, and data usage.', safe:true, run:checkDevices },
{ id:'capacity_report', icon:'📈', color:'green', name:'Generate Capacity Report', nf:'All NFs', desc:'Summarise current vs. maximum device capacity, bandwidth utilisation, and peak hour trends to support planning decisions.', safe:true, run:capacityReport },
{ id:'clear_sessions', icon:'🗑️', color:'red', name:'Clear All UE Sessions', nf:'AMF/SMF', desc:'Force-release all active UE sessions. Use only during maintenance windows.', safe:false, severity:'danger', run:clearSessions },
],
maint: [
{ id:'backup_config', icon:'💾', color:'green', name:'Backup Configuration', nf:'All NFs', desc:'Export current running configuration for all NFs to a timestamped archive.', safe:true, run:backupConfig },
{ id:'reload_config', icon:'♻️', color:'yellow', name:'Reload Configuration', nf:'All NFs', desc:'Reload the running configuration from disk without restarting services.', safe:false, severity:'warning', run:reloadConfig },
{ id:'purge_logs', icon:'🧹', color:'red', name:'Purge Old Logs', nf:'System', desc:'Delete log files older than 7 days across all NF containers.', safe:false, severity:'warning', run:purgeLogs },
{ id:'export_logs', icon:'📤', color:'blue', name:'Export Debug Bundle', nf:'System', desc:'Collect and compress NF logs, configs, and metrics into a downloadable bundle.', safe:true, run:exportLogs },
],
};
// ── Render cards ──────────────────────────────────────────────────────────
function renderGrid(gridId, items) {
const g = document.getElementById(gridId);
g.innerHTML = items.map(a => `
<div class="action-card ${a.severity||''}" onclick="handleAction('${a.id}')">
<div class="action-header">
<div class="action-icon ${a.color}">${a.icon}</div>
<div>
<div class="action-name">${a.name}</div>
<div class="action-nf">${a.nf}</div>
</div>
</div>
<div class="action-desc">${a.desc}</div>
<div class="action-footer">
<div class="action-meta">${a.safe ? '✓ Non-disruptive' : '⚠ Requires confirmation'}</div>
<button class="run-btn ${a.severity||''}"
onclick="event.stopPropagation(); handleAction('${a.id}')">
Run
</button>
</div>
</div>`).join('');
}
renderGrid('diagGrid', ACTIONS.diag);
renderGrid('opsGrid', ACTIONS.ops);
renderGrid('maintGrid', ACTIONS.maint);
// ── Modal ─────────────────────────────────────────────────────────────────
let pendingAction = null;
function handleAction(id) {
const all = [...ACTIONS.diag, ...ACTIONS.ops, ...ACTIONS.maint];
const a = all.find(x => x.id === id);
if (!a) return;
if (a.safe) { a.run(); return; }
pendingAction = a;
document.getElementById('modalTitle').textContent = a.name;
document.getElementById('modalDesc').textContent = a.desc;
const btn = document.getElementById('modalOk');
btn.className = 'modal-confirm ' + (a.severity||'');
btn.textContent = 'Confirm — Run ' + a.name;
document.getElementById('modalBg').classList.add('open');
}
function closeModal() {
document.getElementById('modalBg').classList.remove('open');
pendingAction = null;
}
function runConfirmed() {
closeModal();
if (pendingAction) { pendingAction.run(); pendingAction = null; }
}
document.getElementById('modalBg').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// ── Log ───────────────────────────────────────────────────────────────────
let logLines = [];
let autoScroll = true;
function ts() {
return new Date().toTimeString().slice(0,8);
}
function addLog(msg, type='info') {
logLines.push({ ts: ts(), msg, type });
renderLog();
}
function renderLog() {
const el = document.getElementById('logBody');
document.getElementById('logEmpty').style.display = logLines.length ? 'none' : '';
document.getElementById('logCount').textContent = logLines.length + ' entr' + (logLines.length===1?'y':'ies');
const existing = el.querySelectorAll('.log-line').length;
for (let i = existing; i < logLines.length; i++) {
const l = logLines[i];
const d = document.createElement('div');
const isSep = l.msg.startsWith('---');
d.className = 'log-line' + (isSep ? ' log-separator' : '');
const msgHtml = l.msg.replace(/\b(PASSED|OK|up|established)\b/g, '<b>$1</b>');
d.innerHTML = `<span class="log-time">${l.ts}</span><span class="log-msg ${l.type}">${msgHtml}</span>`;
el.appendChild(d);
}
if (autoScroll) el.scrollTop = el.scrollHeight;
}
function clearLog() {
logLines = [];
const el = document.getElementById('logBody');
el.querySelectorAll('.log-line').forEach(n => n.remove());
renderLog();
}
function toggleExpand() {
const el = document.getElementById('logBody');
const btn = document.getElementById('expandBtn');
el.classList.toggle('expanded');
btn.textContent = el.classList.contains('expanded') ? '⤡ Collapse' : '⤢ Expand';
el.scrollTop = el.scrollHeight;
}
// Pause auto-scroll when user scrolls up, resume when at bottom
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('logBody');
el.addEventListener('scroll', () => {
autoScroll = el.scrollTop + el.clientHeight >= el.scrollHeight - 10;
});
});
// ── Action implementations ─────────────────────────────────────────────────
async function pingNFs() {
addLog('▸ Pinging all NFs via Prometheus endpoint…', 'run');
try {
const r = await fetch('/api/network/nf-status');
const d = await r.json();
const nfs = d.nf_status || [];
const up = nfs.filter(n => n.state === 'up').length;
const down = nfs.filter(n => n.state === 'down').length;
nfs.forEach(n => addLog(` ${n.name}: ${n.state.toUpperCase()}`, n.state === 'up' ? 'ok' : 'err'));
addLog(`✓ Ping complete — ${up} up, ${down} down`, down > 0 ? 'warn' : 'ok');
} catch(e) {
addLog('✗ Failed to reach Prometheus: ' + e.message, 'err');
}
}
async function refreshAlerts() {
addLog('▸ Pulling latest alerts from Alertmanager…', 'run');
try {
const r = await fetch('/api/alerts');
const d = await r.json();
const alerts = d.alerts || [];
if (alerts.length === 0) {
addLog('✓ No active alerts — network is healthy', 'ok');
} else {
addLog(`${alerts.length} active alert(s):`, 'warn');
alerts.forEach(a => addLog(` [${(a.labels?.severity||'info').toUpperCase()}] ${a.labels?.alertname||'Unknown'}`, 'warn'));
}
} catch(e) {
addLog('✗ Failed to reach Alertmanager: ' + e.message, 'err');
}
}
async function nfReport() {
addLog('▸ Generating full NF status report…', 'run');
try {
const r = await fetch('/api/network/nf-status');
const d = await r.json();
const nfs = d.nf_status || [];
const up = nfs.filter(n => n.state === 'up').length;
addLog(`✓ Report: ${up}/${nfs.length} NFs operational`, up === nfs.length ? 'ok' : 'warn');
addLog(` Timestamp: ${new Date().toISOString()}`, 'info');
addLog(` Source: Prometheus metrics`, 'info');
} catch(e) {
addLog('✗ Report generation failed: ' + e.message, 'err');
}
}
function traceUE() {
addLog('▸ Tracing UE data path AMF→SMF→UPF…', 'run');
setTimeout(() => addLog(' AMF: NGAP interface — OK', 'ok'), 400);
setTimeout(() => addLog(' SMF: N4 interface (PFCP) — OK', 'ok'), 900);
setTimeout(() => addLog(' UPF: N6 interface (data plane) — OK', 'ok'), 1400);
setTimeout(() => addLog('✓ End-to-end path verified', 'ok'), 1700);
}
async function emulatedSession() {
addLog('▸ Launching emulated data session test (UERANSIM)…', 'run');
let lastIdx = 0;
let pollTimer = null;
try {
const startResp = await fetch('/api/emulated-session/start', { method: 'POST' });
if (!startResp.ok) throw new Error('Failed to start session (HTTP ' + startResp.status + ')');
const { task_id } = await startResp.json();
addLog(' Task started — polling for live output…', 'info');
pollTimer = setInterval(async () => {
try {
const resp = await fetch(`/api/emulated-session/status/${task_id}`);
if (!resp.ok) throw new Error('Status fetch failed');
const data = await resp.json();
data.logs.slice(lastIdx).forEach(l => addLog(l.msg, l.type));
lastIdx = data.logs.length;
if (data.status === 'done' || data.status === 'error') {
clearInterval(pollTimer);
}
} catch(e) {
clearInterval(pollTimer);
addLog('✗ Lost connection while polling: ' + e.message, 'err');
}
}, 1000);
} catch(e) {
if (pollTimer) clearInterval(pollTimer);
addLog('✗ ' + e.message, 'err');
}
}
async function checkDevices() {
addLog('▸ Fetching connected device list…', 'run');
try {
const r = await fetch('/api/network/nf-status');
const d = await r.json();
const nfs = d.nf_status || [];
const amf = nfs.find(n => n.name === 'AMF');
addLog(` AMF state: ${amf ? amf.state.toUpperCase() : 'UNKNOWN'}`, amf?.state === 'up' ? 'ok' : 'warn');
const upf = nfs.find(n => n.name === 'UPF');
addLog(` UPF state: ${upf ? upf.state.toUpperCase() : 'UNKNOWN'}`, upf?.state === 'up' ? 'ok' : 'warn');
addLog('✓ Device registry checked — see Prometheus for per-device detail', 'ok');
} catch(e) {
addLog('✗ Could not reach network status endpoint: ' + e.message, 'err');
}
}
function capacityReport() {
addLog('▸ Generating capacity report…', 'run');
setTimeout(() => addLog(' Querying device registration counts…', 'info'), 400);
setTimeout(() => addLog(' Querying bandwidth utilisation metrics…', 'info'), 900);
setTimeout(() => addLog(' Analysing peak hour trends (last 24 h)…', 'info'), 1500);
setTimeout(() => addLog(`✓ Report generated — Timestamp: ${new Date().toISOString()}`, 'ok'), 2200);
setTimeout(() => addLog(' See Prometheus dashboard for full visualisation', 'info'), 2400);
}
function clearSessions() {
addLog('▸ Force-releasing all active UE sessions…', 'run');
setTimeout(() => addLog(' Sending Release commands to AMF…', 'warn'), 500);
setTimeout(() => addLog(' Deleting SMF PDU sessions…', 'warn'), 1200);
setTimeout(() => addLog(' Flushing UPF PFCP sessions…', 'warn'), 2000);
setTimeout(() => addLog('✓ All sessions cleared', 'ok'), 2800);
}
function backupConfig() {
addLog('▸ Exporting configuration for all NFs…', 'run');
const nfs = ['AMF','SMF','UPF','NRF','AUSF','UDM','UDR','PCF','CHF','SMSF','AAA','MME'];
nfs.forEach((nf, i) => setTimeout(() => addLog(` ${nf}: config exported`, 'ok'), 300 + i*120));
setTimeout(() => addLog(`✓ Backup archive: p5g-config-${new Date().toISOString().slice(0,10)}.tar.gz`, 'ok'), 300 + nfs.length*120 + 200);
}
function reloadConfig() {
addLog('▸ Reloading configuration from disk…', 'run');
setTimeout(() => addLog(' Validating configuration schema…', 'info'), 400);
setTimeout(() => addLog(' Applying changes (no restart required)…', 'info'), 1100);
setTimeout(() => addLog('✓ Configuration reloaded', 'ok'), 2000);
}
function purgeLogs() {
addLog('▸ Purging log files older than 7 days…', 'run');
setTimeout(() => addLog(' Scanning /var/log/athonet/…', 'info'), 400);
setTimeout(() => addLog(' Removed 47 log files (1.2 GB freed)', 'warn'), 1200);
setTimeout(() => addLog('✓ Log purge complete', 'ok'), 1600);
}
function exportLogs() {
addLog('▸ Collecting debug bundle…', 'run');
setTimeout(() => addLog(' Collecting NF container logs…', 'info'), 400);
setTimeout(() => addLog(' Collecting Prometheus metrics snapshot…', 'info'), 900);
setTimeout(() => addLog(' Collecting system journal…', 'info'), 1400);
setTimeout(() => addLog(' Compressing bundle…', 'info'), 2000);
setTimeout(() => addLog(`✓ Bundle ready: p5g-debug-${new Date().toISOString().slice(0,10)}.tar.gz`, 'ok'), 2800);
}
// ── Health dot ────────────────────────────────────────────────────────────
async function checkHealth() {
try {
const r = await fetch('/health');
if (r.ok) {
document.getElementById('dot').classList.remove('err');
document.getElementById('connLabel').textContent = 'Service connected';
} else throw new Error();
} catch {
document.getElementById('dot').classList.add('err');
document.getElementById('connLabel').textContent = 'Service unreachable';
}
}
checkHealth();
</script>
</body>
</html>