782 lines
31 KiB
HTML
782 lines
31 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>P5G Marvis</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-y: auto; overflow-x: hidden;
|
|
}
|
|
.left::-webkit-scrollbar { width: 5px; }
|
|
.left::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
.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;
|
|
}
|
|
.section-head {
|
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
}
|
|
.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); }
|
|
.collapse-btn {
|
|
background: none; border: none; color: var(--muted); cursor: pointer;
|
|
font-size: 12px; border-radius: 4px; transition: color .15s, transform .15s;
|
|
}
|
|
.collapse-btn:hover { color: var(--text); }
|
|
.collapsed .collapse-btn { transform: rotate(-90deg); }
|
|
|
|
/* 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); }
|
|
|
|
/* Cluster nodes */
|
|
.node-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.node-card {
|
|
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
.node-summary {
|
|
display: flex; align-items: center; gap: 8px; padding: 10px 12px; cursor: pointer;
|
|
}
|
|
.node-summary:hover { background: rgba(255,255,255,.02); }
|
|
.node-top { display: flex; align-items: center; gap: 8px; width: 100%; }
|
|
.node-name { font-size: 13px; font-weight: 700; }
|
|
.node-addr { font-size: 11px; color: var(--muted); margin-top: 3px; }
|
|
.node-caret {
|
|
margin-left: 8px; font-size: 11px; color: var(--muted); transition: transform .15s;
|
|
}
|
|
.node-card.open .node-caret { transform: rotate(180deg); }
|
|
.node-role {
|
|
margin-left: auto; font-size: 10px; font-weight: 700; letter-spacing: .08em;
|
|
border-radius: 999px; padding: 3px 8px; border: 1px solid var(--border);
|
|
color: var(--blue); background: rgba(59,130,246,.12);
|
|
}
|
|
.node-role.current {
|
|
color: var(--green); border-color: rgba(16,185,129,.5); background: rgba(16,185,129,.12);
|
|
}
|
|
.node-meta {
|
|
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
|
|
}
|
|
.node-chip {
|
|
font-size: 10px; color: var(--muted); padding: 2px 7px;
|
|
border-radius: 999px; background: rgba(255,255,255,.04); border: 1px solid var(--border);
|
|
}
|
|
.node-services {
|
|
margin-top: 8px; font-size: 11px; color: var(--text); line-height: 1.4;
|
|
}
|
|
.node-services b,
|
|
.node-profile b {
|
|
color: var(--muted); font-weight: 600;
|
|
}
|
|
.node-profile {
|
|
margin-top: 6px; font-size: 11px; color: var(--text); line-height: 1.4;
|
|
}
|
|
.node-empty {
|
|
color: var(--muted); font-size: 12px;
|
|
}
|
|
.node-details {
|
|
display: none; padding: 0 12px 12px; border-top: 1px solid rgba(255,255,255,.04);
|
|
}
|
|
.node-card.open .node-details { display: block; }
|
|
.node-nf-grid {
|
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 10px;
|
|
}
|
|
.node-nf {
|
|
background: rgba(255,255,255,.03);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 7px 5px;
|
|
border-left: 3px solid var(--border);
|
|
text-align: center;
|
|
}
|
|
.node-nf.up { border-left-color: var(--green); }
|
|
.node-nf.down { border-left-color: var(--red); }
|
|
.node-nf.unknown { border-left-color: var(--yellow); }
|
|
.node-nf-name {
|
|
font-size: 10px; font-weight: 700; color: var(--text); letter-spacing: .04em;
|
|
}
|
|
.node-nf-state {
|
|
margin-top: 3px; font-size: 9px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
|
|
}
|
|
.node-nf.up .node-nf-state { color: var(--green); }
|
|
.node-nf.down .node-nf-state { color: var(--red); }
|
|
.node-nf.unknown .node-nf-state { color: var(--yellow); }
|
|
|
|
/* 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.logs { border-left-color: var(--blue); }
|
|
.alert-row-name { font-size: 12px; font-weight: 600; }
|
|
.alert-row-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
|
.alert-row-node { font-size: 10px; color: var(--blue); margin-top: 5px; }
|
|
.alert-row-meta { display: flex; gap: 6px; align-items: center; margin-top: 6px; flex-wrap: wrap; }
|
|
.alert-badge {
|
|
font-size: 9px; text-transform: uppercase; letter-spacing: .08em;
|
|
border-radius: 999px; padding: 2px 6px; border: 1px solid var(--border); color: var(--muted);
|
|
}
|
|
.alert-badge.logs { color: var(--blue); border-color: rgba(59,130,246,.4); background: rgba(59,130,246,.12); }
|
|
.alert-badge.alertmanager { color: var(--yellow); border-color: rgba(245,158,11,.4); background: rgba(245,158,11,.12); }
|
|
.alert-context {
|
|
margin-top: 7px; font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
font-size: 10px; line-height: 1.45; color: #c8d1e3;
|
|
background: rgba(0,0,0,.18); border: 1px solid rgba(255,255,255,.05);
|
|
border-radius: 6px; padding: 7px 8px; white-space: pre-wrap;
|
|
}
|
|
|
|
.cluster-flyout {
|
|
margin-top: 12px;
|
|
border-top: 1px solid rgba(255,255,255,.05);
|
|
padding-top: 12px;
|
|
}
|
|
.cluster-flyout.hidden,
|
|
.cluster-flyout[hidden] { display: none !important; }
|
|
|
|
/* ── Chat panel ─────────────────────────────────────────────────── */
|
|
.chat { display: grid; grid-template-rows: auto minmax(0,1fr) auto auto; overflow: hidden; }
|
|
.messages {
|
|
min-height: 0; 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: 8px 20px; overflow-x: auto; flex-shrink: 0;
|
|
border-top: 1px solid rgba(255,255,255,.04);
|
|
background: rgba(15,17,23,.75);
|
|
}
|
|
.chips::-webkit-scrollbar { display: none; }
|
|
.chip {
|
|
background: var(--card); border: 1px solid var(--border); border-radius: 20px;
|
|
color: var(--text); padding: 4px 10px; font-size: 11px; line-height: 1.2; 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; }
|
|
|
|
/* Trace panel */
|
|
.trace-panel {
|
|
background: linear-gradient(180deg, rgba(30,37,53,.65), rgba(15,17,23,.95));
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 72px;
|
|
max-height: 320px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.trace-panel.collapsed { max-height: 72px; }
|
|
.trace-panel.collapsed .trace-controls,
|
|
.trace-panel.collapsed .trace-log { display: none; }
|
|
.trace-header {
|
|
padding: 12px 20px 10px;
|
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
border-bottom: 1px solid rgba(255,255,255,.04);
|
|
}
|
|
.trace-title {
|
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--muted);
|
|
}
|
|
.trace-status { font-size: 11px; color: var(--muted); }
|
|
.trace-head-left { display: flex; align-items: center; gap: 10px; }
|
|
.trace-head-right { display: flex; align-items: center; gap: 12px; }
|
|
.refresh-mode {
|
|
background: var(--card); color: var(--text); border: 1px solid var(--border);
|
|
border-radius: 8px; padding: 5px 8px; font: inherit;
|
|
}
|
|
.trace-controls {
|
|
padding: 10px 20px;
|
|
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px;
|
|
}
|
|
.trace-controls select,
|
|
.trace-controls input,
|
|
.trace-controls button {
|
|
background: var(--card); color: var(--text); border: 1px solid var(--border);
|
|
border-radius: 8px; padding: 8px 10px; font: inherit; min-width: 0;
|
|
}
|
|
.trace-controls button { cursor: pointer; }
|
|
.trace-controls button:hover { border-color: var(--purple); }
|
|
.trace-log {
|
|
flex: 1; overflow: auto; padding: 0 20px 16px;
|
|
}
|
|
.trace-log::-webkit-scrollbar { width: 4px; height: 4px; }
|
|
.trace-log::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
.trace-empty {
|
|
color: var(--muted); font-size: 12px; padding-top: 16px;
|
|
}
|
|
.trace-pre {
|
|
font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
font-size: 11px; line-height: 1.55; color: #dbe5f5; white-space: pre-wrap;
|
|
}
|
|
.trace-line {
|
|
display: block; padding: 2px 0;
|
|
}
|
|
.trace-line .t-ts { color: var(--muted); }
|
|
.trace-line .t-node { color: var(--blue); }
|
|
.trace-line .t-nf { color: var(--green); }
|
|
.trace-line .t-src { color: var(--yellow); }
|
|
|
|
@media (max-width: 680px) {
|
|
.layout { grid-template-columns: 1fr; }
|
|
.left { max-height: 260px; }
|
|
.trace-controls { grid-template-columns: 1fr 1fr; }
|
|
.trace-panel { max-height: 320px; }
|
|
.trace-head-right { flex-wrap: wrap; justify-content: flex-end; }
|
|
}
|
|
</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" id="clusterSection">
|
|
<div class="section-title section-head">
|
|
<span>Cluster Overview</span>
|
|
<div>
|
|
<button type="button" class="refresh-btn" onclick="refresh()" title="Refresh">↻</button>
|
|
<button type="button" class="collapse-btn" id="clusterToggle" onclick="toggleClusterFlyout()" title="Toggle discovered nodes" aria-expanded="true">▾</button>
|
|
</div>
|
|
</div>
|
|
<div class="nf-grid" id="nfGrid">
|
|
<div class="nf-card"><div class="nf-name">···</div></div>
|
|
</div>
|
|
<div class="cluster-flyout" id="clusterFlyout">
|
|
<div class="section-title" style="margin-bottom:10px">Discovered Nodes</div>
|
|
<div class="node-list" id="nodeList">
|
|
<div class="node-empty">Loading cluster inventory…</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="trace-panel" id="tracePanel">
|
|
<div class="trace-header">
|
|
<div class="trace-head-left">
|
|
<div class="trace-title">Live Log Trace</div>
|
|
<button type="button" class="collapse-btn" id="traceToggle" onclick="toggleTracePanel()" title="Toggle live log trace">▾</button>
|
|
</div>
|
|
<div class="trace-head-right">
|
|
<label for="traceRefreshMode" class="trace-status">Refresh</label>
|
|
<select class="refresh-mode" id="traceRefreshMode" onchange="setTraceRefreshMode(this.value)">
|
|
<option value="immediate">Realtime</option>
|
|
<option value="30">30s</option>
|
|
<option value="60">60s</option>
|
|
<option value="90">90s</option>
|
|
<option value="off">Off</option>
|
|
</select>
|
|
<div class="trace-status" id="traceStatus">Waiting for log stream…</div>
|
|
</div>
|
|
</div>
|
|
<div class="trace-controls">
|
|
<select id="traceNode" onchange="loadTraces()">
|
|
<option value="">All nodes</option>
|
|
</select>
|
|
<select id="traceNf" onchange="loadTraces()">
|
|
<option value="">All NFs</option>
|
|
</select>
|
|
<input id="traceLines" type="number" min="10" max="1000" value="80" onchange="loadTraces()" />
|
|
<button onclick="loadTraces()">Refresh Trace</button>
|
|
</div>
|
|
<div class="trace-log" id="traceLog">
|
|
<div class="trace-empty">No trace data yet.</div>
|
|
</div>
|
|
</div>
|
|
<div class="messages" id="messages"></div>
|
|
<div class="chips">
|
|
<button type="button" class="chip" onclick="ask(this)">Network health overview</button>
|
|
<button type="button" class="chip" onclick="ask(this)">Any active alerts?</button>
|
|
<button type="button" class="chip" onclick="ask(this)">UPF status</button>
|
|
<button type="button" class="chip" onclick="ask(this)">SMF session analysis</button>
|
|
<button type="button" class="chip" onclick="ask(this)">Subscriber issues?</button>
|
|
<button type="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'});
|
|
const ROLE_LABELS = {
|
|
'5GALL': '5G All',
|
|
'4GALL': '4G All',
|
|
'4GCP': '4G CP',
|
|
'4GDCP': '4G DCP',
|
|
'COMBOALL': 'Combo All',
|
|
'COMBOCP': 'Combo CP',
|
|
'COMBODCP': 'Combo DCP',
|
|
};
|
|
let latestCluster = { nodes: [] };
|
|
let allowedTraceNfs = [];
|
|
let tracePollHandle = null;
|
|
let traceRefreshMode = 'immediate';
|
|
let initialAiMessage = null;
|
|
let openNodeKeys = new Set();
|
|
let nodeExpansionInitialized = false;
|
|
|
|
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;
|
|
}
|
|
|
|
function replaceInitialAiMessage(html) {
|
|
if (!initialAiMessage) return;
|
|
const bubble = initialAiMessage.querySelector('.bubble');
|
|
if (bubble) bubble.innerHTML = html;
|
|
}
|
|
|
|
// ── Network status ─────────────────────────────────────────────────────────
|
|
async function loadNFs() {
|
|
try {
|
|
const d = await (await fetch('./api/network/status')).json();
|
|
latestCluster = d.cluster || { nodes: [] };
|
|
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);
|
|
});
|
|
renderNodes(d.cluster);
|
|
populateTraceFilters(d.cluster);
|
|
$('dot').className = 'dot';
|
|
$('connLabel').textContent = 'Live';
|
|
const up = d.summary?.up ?? 0;
|
|
const down = d.summary?.down ?? 0;
|
|
const nodes = d.cluster?.nodes?.length ?? 0;
|
|
replaceInitialAiMessage(md(
|
|
`**P5G Marvis is live.**\n\n` +
|
|
`Cluster nodes discovered: **${nodes}**\n` +
|
|
`Network functions up: **${up}**\n` +
|
|
`Network functions down: **${down}**\n\n` +
|
|
`Ask about health, alerts, sessions, or logs below.`
|
|
));
|
|
} 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>';
|
|
$('nodeList').innerHTML = '<div class="node-empty">Cannot reach cluster discovery API</div>';
|
|
}
|
|
}
|
|
|
|
function populateTraceFilters(cluster) {
|
|
const nodes = cluster?.nodes || [];
|
|
const nodeSel = $('traceNode');
|
|
const nfSel = $('traceNf');
|
|
const currentNode = nodeSel.value;
|
|
const currentNf = nfSel.value;
|
|
|
|
const nodeOptions = ['<option value="">All nodes</option>']
|
|
.concat(nodes.map(node => `<option value="${node.hostname}">${node.hostname}</option>`));
|
|
nodeSel.innerHTML = nodeOptions.join('');
|
|
nodeSel.value = nodes.some(node => node.hostname === currentNode) ? currentNode : '';
|
|
|
|
const nfs = new Set(allowedTraceNfs);
|
|
nfSel.innerHTML = ['<option value="">All NFs</option>']
|
|
.concat([...nfs].sort().map(nf => `<option value="${nf}">${nf}</option>`))
|
|
.join('');
|
|
nfSel.value = nfs.has(currentNf) ? currentNf : '';
|
|
}
|
|
|
|
function toggleNodeCard(button) {
|
|
const card = button.closest('.node-card');
|
|
if (!card) return;
|
|
const key = card.dataset.nodeKey;
|
|
card.classList.toggle('open');
|
|
if (card.classList.contains('open')) {
|
|
openNodeKeys.add(key);
|
|
} else {
|
|
openNodeKeys.delete(key);
|
|
}
|
|
}
|
|
|
|
function toggleClusterFlyout() {
|
|
const section = $('clusterSection');
|
|
const flyout = $('clusterFlyout');
|
|
const toggle = $('clusterToggle');
|
|
const isOpen = !flyout.hasAttribute('hidden');
|
|
if (isOpen) {
|
|
flyout.setAttribute('hidden', '');
|
|
flyout.classList.add('hidden');
|
|
section.classList.add('collapsed');
|
|
toggle.textContent = '▸';
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
return;
|
|
}
|
|
flyout.removeAttribute('hidden');
|
|
flyout.classList.remove('hidden');
|
|
section.classList.remove('collapsed');
|
|
toggle.textContent = '▾';
|
|
toggle.setAttribute('aria-expanded', 'true');
|
|
}
|
|
|
|
function toggleTracePanel() {
|
|
$('tracePanel').classList.toggle('collapsed');
|
|
}
|
|
|
|
function renderNodes(cluster) {
|
|
const list = $('nodeList');
|
|
const nodes = cluster?.nodes || [];
|
|
if (!nodes.length) {
|
|
list.innerHTML = '<div class="node-empty">No cluster nodes discovered</div>';
|
|
return;
|
|
}
|
|
|
|
if (!nodeExpansionInitialized) {
|
|
openNodeKeys = new Set(
|
|
nodes
|
|
.filter(node => node.current)
|
|
.map(node => node.hostname || node.address || node.name)
|
|
);
|
|
nodeExpansionInitialized = true;
|
|
}
|
|
|
|
list.innerHTML = nodes.map(node => {
|
|
const nodeKey = node.hostname || node.address || node.name;
|
|
const role = ROLE_LABELS[node.role] || node.role || 'AP';
|
|
const repoChips = (node.repositories || []).slice(0, 3).map(repo =>
|
|
`<span class="node-chip">${repo.type}:${repo.role}</span>`
|
|
).join('');
|
|
const running = (node.started_services || []).filter(name => !['alertmanager','prometheus','ncm','pls','fluent-bit','grafana','openvpn','ssh','node-exporter','podman-exporter','licensed','webconsole'].includes(name));
|
|
const serviceText = running.length ? running.join(', ') : 'No managed NFs started';
|
|
const expectedSet = new Set((node.expected_nfs || []).map(nf => String(nf).toUpperCase()));
|
|
const expected = (node.expected_nfs || []).join(', ') || 'No NF profile mapped';
|
|
const profileNfs = (node.nfs || []).filter(nf => expectedSet.has(String(nf.name).toUpperCase()));
|
|
const nfTiles = profileNfs.map(nf => `
|
|
<div class="node-nf ${nf.state}">
|
|
<div class="node-nf-name">${nf.name}</div>
|
|
<div class="node-nf-state">${nf.state === 'up' ? '● up' : nf.state === 'down' ? '● dn' : '○ n/a'}</div>
|
|
</div>
|
|
`).join('');
|
|
const downCount = profileNfs.filter(nf => nf.state === 'down').length;
|
|
const openClass = openNodeKeys.has(nodeKey) ? 'open' : '';
|
|
return `
|
|
<div class="node-card ${openClass}" data-node-key="${escapeHtml(nodeKey)}">
|
|
<div class="node-summary" onclick="toggleNodeCard(this)">
|
|
<div class="node-top">
|
|
<div>
|
|
<div class="node-name">${node.hostname}</div>
|
|
<div class="node-addr">${node.address} · ${profileNfs.filter(nf => nf.state === 'up').length} up${downCount ? `, ${downCount} down` : ''}</div>
|
|
</div>
|
|
<div class="node-role ${node.current ? 'current' : ''}">${role}${node.current ? ' · local' : ''}</div>
|
|
<div class="node-caret">▾</div>
|
|
</div>
|
|
</div>
|
|
<div class="node-details">
|
|
<div class="node-meta">
|
|
${repoChips || '<span class="node-chip">No repo data</span>'}
|
|
</div>
|
|
<div class="node-services"><b>Running:</b> ${serviceText}</div>
|
|
<div class="node-profile"><b>Profile:</b> ${expected}</div>
|
|
<div class="node-nf-grid">${nfTiles || '<div class="node-empty">No profile-scoped NF data</div>'}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
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')} ${a.source==='logs'?'logs':''}">
|
|
<div class="alert-row-name">${a.name}</div>
|
|
<div class="alert-row-desc">${a.summary||a.instance||''}</div>
|
|
<div class="alert-row-node">${(a.nodes||[]).length ? 'Node: ' + a.nodes.map(n => n.hostname).join(', ') : 'Node: unresolved'}</div>
|
|
<div class="alert-row-meta">
|
|
<span class="alert-badge ${a.source==='logs'?'logs':'alertmanager'}">${a.source || 'alertmanager'}</span>
|
|
<span class="alert-badge">${a.severity || 'warning'}</span>
|
|
</div>
|
|
${a.source === 'logs' && a.match_message ? `<div class="alert-context">${escapeHtml(a.match_message)}</div>` : ''}
|
|
</div>`
|
|
).join('');
|
|
}
|
|
} catch {
|
|
$('alertsContent').innerHTML = '<div style="color:var(--muted);font-size:12px">Cannot reach alerts API</div>';
|
|
}
|
|
}
|
|
|
|
async function loadTraces() {
|
|
try {
|
|
const limit = Math.max(10, Math.min(1000, parseInt($('traceLines').value || '80', 10) || 80));
|
|
const params = new URLSearchParams({ limit: String(limit) });
|
|
if ($('traceNode').value) params.set('node', $('traceNode').value);
|
|
if ($('traceNf').value) params.set('nf', $('traceNf').value);
|
|
const d = await (await fetch(`./api/logs/events?${params.toString()}`)).json();
|
|
allowedTraceNfs = (d.status?.allowed_nfs || []).map(nf => String(nf).toUpperCase());
|
|
populateTraceFilters(latestCluster);
|
|
const events = d.events || [];
|
|
$('traceStatus').textContent = d.status?.last_event_at
|
|
? `Last event ${formatFullDateTime(d.status.last_event_at)}`
|
|
: 'Waiting for log stream…';
|
|
if (!events.length) {
|
|
$('traceLog').innerHTML = '<div class="trace-empty">No log events match the selected filters.</div>';
|
|
return;
|
|
}
|
|
$('traceLog').innerHTML = `<div class="trace-pre">${
|
|
events.map(evt => `<span class="trace-line"><span class="t-ts">${escapeHtml(shortTs(evt.timestamp))}</span> <span class="t-node">${escapeHtml(evt.node || 'unknown')}</span> <span class="t-nf">${escapeHtml(evt.nf || 'SYSTEM')}</span> <span class="t-src">${escapeHtml(evt.source || 'unknown')}</span> ${escapeHtml(evt.message || '')}</span>`).join('')
|
|
}</div>`;
|
|
$('traceLog').scrollTop = $('traceLog').scrollHeight;
|
|
} catch {
|
|
$('traceStatus').textContent = 'Trace unavailable';
|
|
$('traceLog').innerHTML = '<div class="trace-empty">Cannot reach trace API.</div>';
|
|
}
|
|
}
|
|
|
|
function shortTs(value) {
|
|
if (!value) return '--:--:--';
|
|
const dt = new Date(value);
|
|
return Number.isNaN(dt.getTime())
|
|
? value
|
|
: dt.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});
|
|
}
|
|
|
|
function formatFullDateTime(value) {
|
|
if (!value) return 'unknown';
|
|
const dt = new Date(value);
|
|
return Number.isNaN(dt.getTime())
|
|
? value
|
|
: dt.toLocaleString([], {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
});
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
async function refresh() { await Promise.all([loadNFs(), loadAlerts(), loadTraces()]); }
|
|
|
|
function setTraceRefreshMode(mode) {
|
|
traceRefreshMode = mode;
|
|
if (tracePollHandle) {
|
|
clearInterval(tracePollHandle);
|
|
tracePollHandle = null;
|
|
}
|
|
if (mode === 'off') return;
|
|
const intervalMs = mode === 'immediate' ? 2000 : parseInt(mode, 10) * 1000;
|
|
tracePollHandle = setInterval(loadTraces, intervalMs);
|
|
}
|
|
|
|
// ── 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 () => {
|
|
initialAiMessage = 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" +
|
|
"_Connecting to network state…_"
|
|
));
|
|
await refresh();
|
|
setInterval(() => Promise.all([loadNFs(), loadAlerts()]), 30000);
|
|
setTraceRefreshMode($('traceRefreshMode').value);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|