started log ingestion and analysis
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user