diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py
index 6e46931..9b54f65 100644
--- a/src/fgai/dashboard.py
+++ b/src/fgai/dashboard.py
@@ -95,6 +95,7 @@ function table(rows, columns) {
function capability(label, state, detail) { return `${esc(label)}: ${esc(detail)}`; }
function drawTrend(history) { const canvas=document.getElementById('trendChart'), ctx=canvas.getContext('2d'), w=canvas.width=canvas.clientWidth*devicePixelRatio, h=canvas.height=canvas.clientHeight*devicePixelRatio; ctx.scale(devicePixelRatio,devicePixelRatio); const cw=canvas.clientWidth,ch=canvas.clientHeight; ctx.clearRect(0,0,cw,ch); const max=Math.max(1,...history.map(item=>item.events||0)); const line=(key,color)=>{ctx.strokeStyle=color;ctx.lineWidth=2;ctx.beginPath();history.forEach((item,index)=>{const x=12+index*Math.max(1,(cw-24)/Math.max(1,history.length-1));const y=ch-18-((item[key]||0)/max)*(ch-36);index?ctx.lineTo(x,y):ctx.moveTo(x,y)});ctx.stroke()}; line('events','#1ea9ff');line('anomalies','#ff5656'); }
async function refresh() {
+ const openDetails = new Set([...document.querySelectorAll('details[open][data-detail-id]')].map(item => item.dataset.detailId));
const res = await fetch('/api/status', {cache: 'no-store'});
const data = await res.json();
const s = data.summary || {};
@@ -157,7 +158,7 @@ async function refresh() {
{label:'Severity', render:r => `${esc(r.severity)}`},
{label:'Streams', render:r => esc((r.correlated_streams || []).join(', ') || 'single stream')},
{label:'Evidence', render:r => esc((r.evidence || []).join('; '))},
- {label:'Timeline', render:r => { const rows=(r.timeline||[]).map(item => esc(`${item.timestamp || ''} | ${item.stream || ''} | ${item.action || ''} | ${item.destination || ''} | ${item.context || item.message || ''}`)).join('
'); return rows ? `${esc(`${r.first_seen || '-'} to ${r.last_seen || '-'}`)}
${rows}
` : '-'; }}
+ {label:'Timeline', render:r => { const rows=(r.timeline||[]).map(item => esc(`${item.timestamp || ''} | ${item.stream || ''} | ${item.action || ''} | ${item.destination || ''} | ${item.context || item.message || ''}`)).join('
'); const id=`incident:${r.entity}:${r.first_seen || ''}`; return rows ? `${esc(`${r.first_seen || '-'} to ${r.last_seen || '-'}`)}
${rows}
` : '-'; }}
]);
document.getElementById('blocks').innerHTML = table(data.block_candidates || [], [
{label:'Source', key:'src_ip'},
@@ -169,7 +170,7 @@ async function refresh() {
const streamTitles = Object.fromEntries((configuration.graylog_streams || []).map(item => [item.id, item.title || item.id]));
const fieldRows = Object.entries(data.field_deviations || {}).flatMap(([entity, deviations]) => (deviations || []).map(item => ({entity, stream_title: streamTitles[item.stream_id] || item.stream_id, ...item})));
document.getElementById('fieldDeviations').innerHTML = table(fieldRows, [
- {label:'Entity', key:'entity'}, {label:'Stream', key:'stream_title'}, {label:'Field', key:'field'}, {label:'Score', key:'score'}, {label:'Review', render:r => esc(r.feedback || 'unreviewed')}, {label:'Evidence', render:r => { const summary=esc(`${r.reason}; current ${r.current ?? '-'} vs baseline ${r.baseline ?? '-'}; values: ${(r.sample_values || []).join(', ') || '-'}`); const events=(r.sample_events || []).map(item => esc(`${item.timestamp} | ${item.source} -> ${item.destination} | ${item.action} ${item.service} | ${item.value} | ${item.message}`)).join('
'); return events ? `${summary}
${events}
` : summary; }}, {label:'Review action', render:r => `
`}
+ {label:'Entity', key:'entity'}, {label:'Stream', key:'stream_title'}, {label:'Field', key:'field'}, {label:'Score', key:'score'}, {label:'Review', render:r => esc(r.feedback || 'unreviewed')}, {label:'Evidence', render:r => { const summary=esc(`${r.reason}; current ${r.current ?? '-'} vs baseline ${r.baseline ?? '-'}; values: ${(r.sample_values || []).join(', ') || '-'}`); const events=(r.sample_events || []).map(item => esc(`${item.timestamp} | ${item.source} -> ${item.destination} | ${item.action} ${item.service} | ${item.value} | ${item.message}`)).join('
'); const id=`deviation:${r.entity}:${r.stream_id}:${r.field}:${r.value || ''}`; return events ? `${summary}
${events}
` : summary; }}, {label:'Review action', render:r => ``}
]);
document.querySelectorAll('.feedback').forEach(button => button.addEventListener('click', async () => {
const note = prompt('Review note (optional):') || '';
@@ -215,6 +216,7 @@ async function refresh() {
'Top Source Ports
' + table(d.top_source_ports || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'Top Services
' + table(d.top_services || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'Local-in Failures
' + table(d.local_in_failures || [], [{label:'Source', key:'src_ip'}, {label:'Service', key:'service'}, {label:'Policy', key:'policy'}, {label:'Count', key:'count'}]);
+ document.querySelectorAll('details[data-detail-id]').forEach(item => { if (openDetails.has(item.dataset.detailId)) item.open = true; });
}
async function loadSettings() {
const config = await (await fetch('/api/config', {cache: 'no-store'})).json();