Initial commit from Martins Github
This commit is contained in:
663
app/ui/actions.html
Normal file
663
app/ui/actions.html
Normal 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 & 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>
|
||||
337
app/ui/index.html
Normal file
337
app/ui/index.html
Normal 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
693
app/ui/overview.html
Normal 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
534
app/ui/tasks.html
Normal 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 & 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>
|
||||
Reference in New Issue
Block a user