diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index 1746ae2..9422d86 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -120,6 +120,13 @@ HTML = """ .review-actions button[data-status="confirmed"] { border-color: #2a9b6e; color: #7be3ae; } .chart { width: 100%; height: 280px; background: #04182d; border: 1px solid #163b59; } .graph { width: 100%; height: 360px; background: #04182d; border: 1px solid #163b59; } + .correlation-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(280px, .85fr); gap: 12px; align-items: start; } + .entity-picker { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; } + .entity-chip { border: 1px solid #39709a; background: #08243e; color: #d9e8f7; padding: 6px 8px; cursor: pointer; border-radius: 4px; } + .entity-chip.active { border-color: #1ea9ff; color: #f2f8ff; background: #0b3358; } + .evidence-list { display: grid; gap: 8px; max-height: 360px; overflow: auto; padding-right: 4px; } + .evidence-item { border: 1px solid #163b59; background: #061a2e; border-radius: 6px; padding: 8px; } + .evidence-item code { display: inline-block; margin-top: 4px; } .sort-button { border: 0; background: transparent; color: #83bce9; cursor: pointer; font: inherit; font-weight: 600; padding: 0; } .sort-button:hover { color: #d9e8f7; } .model-list { display: flex; flex-wrap: wrap; gap: 8px; } @@ -136,7 +143,7 @@ HTML = """ .action-item.critical { border-color: #b00020; } .status-guide { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px; } .status-guide div { border: 1px solid #163b59; background: #061a2e; border-radius: 6px; padding: 10px; } - @media (max-width: 1100px) { .app-shell { grid-template-columns: 1fr; } .brand-panel { position: static; } .flow-steps { grid-template-columns: repeat(2, 1fr); } } + @media (max-width: 1100px) { .app-shell, .correlation-layout { grid-template-columns: 1fr; } .brand-panel { position: static; } .flow-steps { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 860px) { .hero, .split { grid-template-columns: 1fr; } .hero img { display: none; } .brand-title { font-size: 32px; } } @@ -163,7 +170,7 @@ HTML = """ -

Events and Anomalies

Baseline and Stream Health

Operator Guidance

Waiting for monitor data.

Correlation Map

AI Assessment

LLM assessment disabled.

Investigation Incidents

Anomalies

Recommendations

+

Events and Anomalies

Baseline and Stream Health

Operator Guidance

Waiting for monitor data.

Correlation Map

Waiting for correlated entities.

AI Assessment

LLM assessment disabled.

Investigation Incidents

Anomalies

Recommendations

Triage Queue

Field Baseline Deviations

Related Activity Across Sources

Block Candidates

Threat Intelligence

Policy Findings

Diagnostics

Recommended Stream Profiles

Waiting for observed stream data.

Installed Ollama Models

Loading local Ollama models.

Runtime Configuration

@@ -194,9 +201,11 @@ function graylogEvidenceLink(query, configuration, label='Open in Graylog') { return ` ${esc(label)}`; } const tableSort = {}; -const uiCache = {correlations: [], fieldRows: []}; +const uiCache = {correlations: [], fieldRows: [], correlationHitboxes: [], selectedCorrelationKey: ''}; window.availableStreams = []; window.streamSelection = {}; +window.currentCorrelations = []; +window.currentConfiguration = {}; function table(rows, columns, id = '') { if (!rows || rows.length === 0) return '

No data.

'; const sort = tableSort[id]; @@ -262,6 +271,35 @@ function compactRelatedActivity(rows) { } return [...groups.values()].sort((left,right) => (right.security-left.security) || (right.count-left.count)).slice(0,25); } +function correlationKey(item) { + return String(item.entity || item.source_ip || ''); +} +function renderCorrelationExplorer(correlations, configuration) { + const target = document.getElementById('correlationExplorer'); + const rows = [...(correlations || [])].sort((a,b)=>(Number(b.security_events)||0)-(Number(a.security_events)||0) || (Number(b.events)||0)-(Number(a.events)||0)).slice(0,10); + if (!rows.length) { + target.textContent = 'No correlated entities in the current analysis window.'; + return; + } + if (!rows.some(item => correlationKey(item) === uiCache.selectedCorrelationKey)) { + uiCache.selectedCorrelationKey = correlationKey(rows[0]); + } + const selected = rows.find(item => correlationKey(item) === uiCache.selectedCorrelationKey) || rows[0]; + const chips = rows.map(item => { + const key = correlationKey(item); + const label = `${key || '-'} (${Number(item.security_events || 0)}/${Number(item.events || 0)})`; + return ``; + }).join(''); + const samples = (selected.samples || []).slice(0,8).map(item => { + const line = `${item.timestamp || ''} | ${item.stream || ''} | ${item.action || ''} | ${item.destination || ''} | ${item.service || ''} | ${item.context || item.message || ''}`; + return `
${esc(line)}
${item.graylog_query ? `${esc(item.graylog_query)}${graylogEvidenceLink(item.graylog_query, configuration)}` : ''}
`; + }).join(''); + target.innerHTML = `
${chips}
${esc(correlationKey(selected) || '-')}
${esc((selected.streams || []).join(', ') || 'single stream')} · ${esc(selected.events || 0)} events · ${esc(selected.security_events || 0)} security events
${samples || '

No sample evidence for this entity.

'}
`; + target.querySelectorAll('.entity-chip').forEach(button => button.addEventListener('click', () => { + uiCache.selectedCorrelationKey = button.dataset.correlationKey || ''; + renderCorrelationExplorer(window.currentCorrelations || [], window.currentConfiguration || {}); + })); +} function inferredProfileType(row) { const explicit = row.detected_log_type || (row.discovery || {}).log_type; if (explicit) return explicit; @@ -338,6 +376,7 @@ function drawCorrelationGraph(correlations) { canvas.width=cw*ratio; canvas.height=ch*ratio; ctx.scale(ratio,ratio); ctx.clearRect(0,0,cw,ch); const short=(value,max=22)=>String(value||'-').length>max?`${String(value).slice(0,max-3)}...`:String(value||'-'); const items=[...(correlations||[])].sort((a,b)=>(Number(b.security_events)||0)-(Number(a.security_events)||0) || (Number(b.events)||0)-(Number(a.events)||0) || (b.streams||[]).length-(a.streams||[]).length).slice(0,8); + uiCache.correlationHitboxes = []; if (!items.length) { ctx.fillStyle='#91abc4'; ctx.font='14px Arial'; ctx.fillText('No multi-stream entities in the current analysis window.', 16, 28); return; } const streamScores=new Map(); items.forEach(item => (item.streams||[]).forEach(stream => streamScores.set(stream, (streamScores.get(stream)||0)+Number(item.security_events||0)+1))); @@ -362,6 +401,7 @@ function drawCorrelationGraph(correlations) { items.forEach((item,index)=>{ const y=entityY(index), security=Number(item.security_events)||0, radius=Math.min(16,8+Math.log10(Math.max(1,Number(item.events)||1))*3); ctx.fillStyle=security ? '#d95f5f' : '#2389cc'; ctx.beginPath(); ctx.arc(entityX,y,radius,0,Math.PI*2); ctx.fill(); + uiCache.correlationHitboxes.push({key: correlationKey(item), x: entityX, y, radius: radius + 8}); ctx.textAlign='right'; ctx.font='12px Arial'; ctx.fillStyle='#d9e8f7'; ctx.fillText(short(item.entity||item.source_ip,22), entityX-radius-10, y-2); ctx.fillStyle='#91abc4'; ctx.fillText(`${Number(item.events)||0} events`, entityX-radius-10, y+12); }); @@ -405,8 +445,11 @@ async function refresh() { const correlationsCached = rawCorrelations.length === 0 && uiCache.correlations.length > 0; const correlations = rawCorrelations.length ? rawCorrelations : uiCache.correlations; if (rawCorrelations.length) uiCache.correlations = rawCorrelations; + window.currentCorrelations = correlations; + window.currentConfiguration = configuration; drawTrend(data.history || []); drawCorrelationGraph(correlations); + renderCorrelationExplorer(correlations, configuration); document.getElementById('correlationGraphInfo').textContent = `${correlations.length} entities correlated across enabled streams${correlationsCached ? ' (cached from previous non-empty poll)' : ''}. Graph shows the highest-signal entities linked to the streams where they were observed.`; document.getElementById('stamp').textContent = data.generated_at ? `Updated ${new Date(data.generated_at * 1000).toLocaleString()}` : 'Waiting for monitor data'; document.getElementById('metrics').innerHTML = [ @@ -426,8 +469,6 @@ async function refresh() { data.stale ? `Showing cached dashboard data because live MCP fetch failed.` : '', cache.stored_at ? `Cached snapshot: ${esc(new Date(cache.stored_at * 1000).toLocaleString())}` : '', `Status schema: ${esc(data.status_schema || 'old')}`, - `Log file: ${esc(data.log_path || '')}`, - `Policy file: ${esc(data.policy_path || 'none')}`, `Critical anomalies: ${esc((a.critical || 0))}`, `High anomalies: ${esc((a.high || 0))}`, `Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`, @@ -846,6 +887,15 @@ document.querySelectorAll('.tab').forEach(button => button.addEventListener('cli document.querySelectorAll('[data-view]').forEach(view => view.classList.toggle('active', view.dataset.view === button.dataset.tab)); })); ['showReviewedFindings','showLowFindings','showExistingProfiles'].forEach(id => document.getElementById(id)?.addEventListener('change', refresh)); +document.getElementById('correlationGraph')?.addEventListener('click', event => { + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const hit = (uiCache.correlationHitboxes || []).find(item => Math.hypot(item.x - x, item.y - y) <= item.radius); + if (!hit) return; + uiCache.selectedCorrelationKey = hit.key; + renderCorrelationExplorer(window.currentCorrelations || [], window.currentConfiguration || {}); +}); refresh(); loadSettings(); setInterval(refresh, 5000); diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index e7d3504..2241386 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -44,6 +44,11 @@ class DashboardTests(unittest.TestCase): self.assertIn("function graylogEvidenceLink", HTML) self.assertIn("/search?rangetype=relative", HTML) + def test_dashboard_has_correlation_explorer(self): + self.assertIn("correlationExplorer", HTML) + self.assertIn("function renderCorrelationExplorer", HTML) + self.assertIn("correlationHitboxes", HTML) + if __name__ == "__main__": unittest.main()