diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py
index 93fa091..01328ea 100644
--- a/src/fgai/dashboard.py
+++ b/src/fgai/dashboard.py
@@ -181,7 +181,7 @@ HTML = """
-
Events and Anomalies
Baseline and Stream Health
Operator Guidance
Waiting for monitor data.
Correlation Map
Waiting for correlated entities.
AI Assessment
LLM assessment disabled.
+ Events and Anomalies
Baseline and Stream Health
Operator Guidance
Waiting for monitor data.
Correlation Map
Waiting for correlated entities.
AI Assessment
LLM assessment disabled.
Related Activity Across Sources
Recommended Stream Profiles
Waiting for observed stream data.
Installed Ollama Models
Loading local Ollama models.
@@ -336,6 +336,15 @@ function refreshEntityLabels(correlations, context) {
rememberEntityLabel(item.entity, item.entity_label || item.entity_display || item.entity, item.entity_detail || '');
}
}
+function incidentActions(row) {
+ const state = String(row.lifecycle_status || 'open');
+ const id = esc(row.id);
+ const actions = [];
+ if (state !== 'acknowledged') actions.push(``);
+ if (state !== 'resolved') actions.push(``);
+ if (state !== 'open') actions.push(``);
+ return `${actions.join('')}
${row.note ? `${esc(row.note)}
` : ''}`;
+}
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);
@@ -664,7 +673,11 @@ async function refresh() {
{label:'Policies', render:r => esc((r.related_policy_ids || []).join(', '))},
{label:'Services', render:r => esc((r.related_services || []).join(', '))}
], 'recommendations');
- document.getElementById('incidents').innerHTML = table(data.incidents || [], [
+ const showResolvedIncidents = document.getElementById('showResolvedIncidents')?.checked;
+ const visibleIncidents = (data.incidents || []).filter(item => showResolvedIncidents || (item.lifecycle_status || 'open') !== 'resolved');
+ const resolvedIncidentCount = (data.incidents || []).filter(item => (item.lifecycle_status || 'open') === 'resolved').length;
+ document.getElementById('incidentSummary').textContent = `${visibleIncidents.length} shown, ${resolvedIncidentCount} resolved hidden.`;
+ document.getElementById('incidents').innerHTML = table(visibleIncidents, [
{label:'Entity', key:'entity', render:r => esc(`${entityDisplay(r.entity, r)} (${r.entity_type || 'entity'})`)},
{label:'Score', key:'score'},
{label:'Severity', render:r => `${esc(r.severity)}`},
@@ -672,7 +685,7 @@ async function refresh() {
{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 || ''}`)}${item.graylog_query ? `
${esc(item.graylog_query)}${graylogEvidenceLink(item.graylog_query, configuration)}` : ''}`).join('
'); const id=`incident:${r.id || r.entity}:${r.first_seen || ''}`; return rows ? `${esc(`${r.first_seen || '-'} to ${r.last_seen || '-'}`)}
${rows}
` : '-'; }},
- {label:'Action', render:r => `${r.note ? `${esc(r.note)}
` : ''}`}
+ {label:'Action', render:r => incidentActions(r)}
], 'incidents');
document.querySelectorAll('.incident-action').forEach(button => button.addEventListener('click', async () => {
const note = prompt('Incident note (optional):') || '';
@@ -1011,7 +1024,7 @@ document.querySelectorAll('.tab').forEach(button => button.addEventListener('cli
document.querySelectorAll('.tab').forEach(item => item.classList.toggle('active', item === button));
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));
+['showReviewedFindings','showLowFindings','showExistingProfiles','showResolvedIncidents'].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;
diff --git a/src/fgai/incidents.py b/src/fgai/incidents.py
index 73a72e2..b9c0fa4 100644
--- a/src/fgai/incidents.py
+++ b/src/fgai/incidents.py
@@ -52,7 +52,7 @@ def build_incidents(anomalies: list[AnomalyFinding], field_deviations: dict[str,
severity = "critical" if score >= 85 else "high" if score >= 60 else "medium" if score >= 35 else "low"
streams = sorted({stream for item in group["correlations"] for stream in item.get("streams", [])} | {str(item.get("stream_name") or item.get("stream_title") or item.get("stream_id", "")) for item in group["fields"] if item.get("stream_id") or item.get("stream_name") or item.get("stream_title")})
timeline = sorted(group["timeline"], key=lambda item: str(item.get("timestamp", "")))[:20]
- incident_id = hashlib.sha256(json.dumps({"entity": entity, "streams": streams, "evidence": list(dict.fromkeys(str(item) for item in group["evidence"] if item))[:4]}, sort_keys=True).encode("utf-8")).hexdigest()[:16]
+ incident_id = hashlib.sha256(json.dumps({"entity": entity, "streams": streams}, sort_keys=True).encode("utf-8")).hexdigest()[:16]
incidents.append({
"id": incident_id,
"entity": entity,
diff --git a/tests/test_incidents.py b/tests/test_incidents.py
index 339b576..5b8b228 100644
--- a/tests/test_incidents.py
+++ b/tests/test_incidents.py
@@ -32,3 +32,8 @@ class IncidentTests(unittest.TestCase):
self.assertEqual(applied_again["lifecycle_status"], "acknowledged")
self.assertEqual(applied_again["note"], "checking vpn logs")
+
+ def test_incident_id_is_stable_when_evidence_changes(self):
+ first = build_incidents([], {"alice": [{"score": 15, "reason": "new login country", "stream_id": "windows"}]}, [])[0]
+ second = build_incidents([], {"alice": [{"score": 25, "reason": "new source ip", "stream_id": "windows"}]}, [])[0]
+ self.assertEqual(first["id"], second["id"])