added multi node functionality

This commit is contained in:
Jake Kasper
2026-04-24 12:33:52 -04:00
parent c4c081362e
commit 16e5f2ced2
30 changed files with 673 additions and 93 deletions

View File

@@ -60,8 +60,10 @@ header h1 span { color: var(--muted); font-weight: 400; }
/* ── Left panel ─────────────────────────────────────────────────── */
.left {
background: var(--surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
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;
@@ -88,6 +90,79 @@ header h1 span { color: var(--muted); font-weight: 400; }
.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; }
@@ -101,6 +176,7 @@ header h1 span { color: var(--muted); font-weight: 400; }
.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; }
.alert-row-node { font-size: 10px; color: var(--blue); margin-top: 5px; }
/* ── Chat panel ─────────────────────────────────────────────────── */
.chat { display: flex; flex-direction: column; overflow: hidden; }
@@ -185,13 +261,19 @@ header h1 span { color: var(--muted); font-weight: 400; }
<div class="left">
<div class="section">
<div class="section-title">
Network Functions
Cluster Overview
<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="section">
<div class="section-title">Discovered Nodes</div>
<div class="node-list" id="nodeList">
<div class="node-empty">Loading cluster inventory…</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>
@@ -221,6 +303,15 @@ header h1 span { color: var(--muted); font-weight: 400; }
// ── 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',
};
function md(text) {
// minimal markdown: **bold**, `code`, newlines
@@ -261,15 +352,70 @@ async function loadNFs() {
<div class="nf-state">${nf.state==='up'?'● up':nf.state==='down'?'● dn':'○ n/a'}</div>`;
grid.appendChild(c);
});
renderNodes(d.cluster);
$('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>';
$('nodeList').innerHTML = '<div class="node-empty">Cannot reach cluster discovery API</div>';
}
}
function toggleNodeCard(button) {
button.closest('.node-card')?.classList.toggle('open');
}
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;
}
list.innerHTML = nodes.map(node => {
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 expected = (node.expected_nfs || []).join(', ') || 'No NF profile mapped';
const nfTiles = (node.nfs || []).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' : '';
return `
<div class="node-card ${openClass}">
<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>
<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 node-scoped NF data</div>'}</div>
</div>
</div>
`;
}).join('');
}
async function loadAlerts() {
try {
const d = await (await fetch('./api/alerts')).json();
@@ -281,6 +427,7 @@ async function loadAlerts() {
`<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 class="alert-row-node">${(a.nodes||[]).length ? 'Node: ' + a.nodes.map(n => n.hostname).join(', ') : 'Node: unresolved'}</div>
</div>`
).join('');
}