started log ingestion and analysis

This commit is contained in:
Jake Kasper
2026-04-24 14:15:58 -04:00
parent c2537dd955
commit 9ac96cee9a
27 changed files with 1368 additions and 179 deletions

View File

@@ -174,14 +174,28 @@ header h1 span { color: var(--muted); font-weight: 400; }
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;
}
/* ── Chat panel ─────────────────────────────────────────────────── */
.chat { display: flex; flex-direction: column; overflow: hidden; }
.chat { display: grid; grid-template-rows: auto auto minmax(0,1fr) auto; overflow: hidden; }
.messages {
flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px;
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; }
@@ -241,9 +255,62 @@ header h1 span { color: var(--muted); font-weight: 400; }
.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: 220px;
max-height: 280px;
border-bottom: 1px solid var(--border);
}
.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-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; }
}
</style>
</head>
@@ -282,7 +349,25 @@ header h1 span { color: var(--muted); font-weight: 400; }
<!-- Chat panel -->
<div class="chat">
<div class="messages" id="messages"></div>
<div class="trace-panel">
<div class="trace-header">
<div class="trace-title">Live Log Trace</div>
<div class="trace-status" id="traceStatus">Waiting for log stream…</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="chips">
<button class="chip" onclick="ask(this)">Network health overview</button>
<button class="chip" onclick="ask(this)">Any active alerts?</button>
@@ -291,6 +376,7 @@ header h1 span { color: var(--muted); font-weight: 400; }
<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="input-bar">
<input class="msg-input" id="inp" placeholder="Ask about your P5G network…"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}">
@@ -312,6 +398,9 @@ const ROLE_LABELS = {
'COMBOCP': 'Combo CP',
'COMBODCP': 'Combo DCP',
};
let latestCluster = { nodes: [] };
let allowedTraceNfs = [];
let tracePollHandle = null;
function md(text) {
// minimal markdown: **bold**, `code`, newlines
@@ -342,6 +431,7 @@ function addMsg(role, html, isTyping=false) {
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 => {
@@ -353,6 +443,7 @@ async function loadNFs() {
grid.appendChild(c);
});
renderNodes(d.cluster);
populateTraceFilters(d.cluster);
$('dot').className = 'dot';
$('connLabel').textContent = 'Live';
} catch {
@@ -363,6 +454,25 @@ async function loadNFs() {
}
}
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) {
button.closest('.node-card')?.classList.toggle('open');
}
@@ -424,10 +534,15 @@ async function loadAlerts() {
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 ${(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('');
}
@@ -436,7 +551,66 @@ async function loadAlerts() {
}
}
async function refresh() { await Promise.all([loadNFs(), loadAlerts()]); }
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function refresh() { await Promise.all([loadNFs(), loadAlerts(), loadTraces()]); }
// ── Chat ──────────────────────────────────────────────────────────────────
async function send() {
@@ -478,6 +652,7 @@ function ask(btn) { $('inp').value = btn.textContent; send(); }
));
await refresh();
setInterval(refresh, 30000);
tracePollHandle = setInterval(loadTraces, 5000);
})();
</script>
</body>