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 = """ -
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(item.graylog_query)}${graylogEvidenceLink(item.graylog_query, configuration)}` : ''}No sample evidence for this entity.
'}${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()