deeper log model
This commit is contained in:
@@ -70,11 +70,20 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
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; }
|
||||
@@ -192,8 +201,16 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
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 auto minmax(0,1fr) auto; overflow: hidden; }
|
||||
.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;
|
||||
}
|
||||
@@ -225,11 +242,15 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
@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 {
|
||||
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: 5px 13px; font-size: 12px; cursor: pointer;
|
||||
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); }
|
||||
@@ -261,10 +282,13 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 220px;
|
||||
max-height: 280px;
|
||||
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;
|
||||
@@ -274,6 +298,12 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
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;
|
||||
@@ -311,6 +341,7 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
.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>
|
||||
@@ -326,19 +357,22 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
<div class="layout">
|
||||
<!-- Left panel -->
|
||||
<div class="left">
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
Cluster Overview
|
||||
<button class="refresh-btn" onclick="refresh()" title="Refresh">↻</button>
|
||||
<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>
|
||||
<div class="section">
|
||||
<div class="section-title">Discovered Nodes</div>
|
||||
<div class="node-list" id="nodeList">
|
||||
<div class="node-empty">Loading cluster inventory…</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">
|
||||
@@ -349,10 +383,23 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
|
||||
<!-- Chat panel -->
|
||||
<div class="chat">
|
||||
<div class="trace-panel">
|
||||
<div class="trace-panel" id="tracePanel">
|
||||
<div class="trace-header">
|
||||
<div class="trace-title">Live Log Trace</div>
|
||||
<div class="trace-status" id="traceStatus">Waiting for log stream…</div>
|
||||
<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()">
|
||||
@@ -368,15 +415,15 @@ header h1 span { color: var(--muted); font-weight: 400; }
|
||||
<div class="trace-empty">No trace data yet.</div>
|
||||
</div>
|
||||
</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="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()}">
|
||||
@@ -401,6 +448,10 @@ const ROLE_LABELS = {
|
||||
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
|
||||
@@ -427,6 +478,12 @@ function addMsg(role, html, isTyping=false) {
|
||||
return el;
|
||||
}
|
||||
|
||||
function replaceInitialAiMessage(html) {
|
||||
if (!initialAiMessage) return;
|
||||
const bubble = initialAiMessage.querySelector('.bubble');
|
||||
if (bubble) bubble.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Network status ─────────────────────────────────────────────────────────
|
||||
async function loadNFs() {
|
||||
try {
|
||||
@@ -446,6 +503,16 @@ async function loadNFs() {
|
||||
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';
|
||||
@@ -474,7 +541,39 @@ function populateTraceFilters(cluster) {
|
||||
}
|
||||
|
||||
function toggleNodeCard(button) {
|
||||
button.closest('.node-card')?.classList.toggle('open');
|
||||
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) {
|
||||
@@ -485,29 +584,41 @@ function renderNodes(cluster) {
|
||||
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 nfTiles = (node.nfs || []).map(nf => `
|
||||
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 = (node.nfs || []).filter(nf => nf.state === 'down').length;
|
||||
const openClass = node.current ? 'open' : '';
|
||||
const downCount = profileNfs.filter(nf => nf.state === 'down').length;
|
||||
const openClass = openNodeKeys.has(nodeKey) ? 'open' : '';
|
||||
return `
|
||||
<div class="node-card ${openClass}">
|
||||
<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} · ${node.nfs.filter(nf => nf.state === 'up').length} up${downCount ? `, ${downCount} down` : ''}</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>
|
||||
@@ -519,7 +630,7 @@ function renderNodes(cluster) {
|
||||
</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 node-scoped NF data</div>'}</div>
|
||||
<div class="node-nf-grid">${nfTiles || '<div class="node-empty">No profile-scoped NF data</div>'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -612,6 +723,17 @@ function escapeHtml(value) {
|
||||
|
||||
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');
|
||||
@@ -644,15 +766,15 @@ function ask(btn) { $('inp').value = btn.textContent; send(); }
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
addMsg('ai', md(
|
||||
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" +
|
||||
"_Loading network state…_"
|
||||
"_Connecting to network state…_"
|
||||
));
|
||||
await refresh();
|
||||
setInterval(refresh, 30000);
|
||||
tracePollHandle = setInterval(loadTraces, 5000);
|
||||
setInterval(() => Promise.all([loadNFs(), loadAlerts()]), 30000);
|
||||
setTraceRefreshMode($('traceRefreshMode').value);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user