diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index 01328ea..1a55b63 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.

Investigation Incidents

Anomalies

Recommendations

AI Assessment

LLM assessment disabled.
+

Events and Anomalies

Baseline and Stream Health

Operator Guidance

Waiting for monitor data.

Correlation Map

Waiting for correlated entities.

Investigation Incidents

Anomalies

Recommendations

AI Assessment

LLM assessment disabled.

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

@@ -687,11 +687,23 @@ async function refresh() { {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 => incidentActions(r)} ], 'incidents'); - document.querySelectorAll('.incident-action').forEach(button => button.addEventListener('click', async () => { + document.querySelectorAll('.incident-action').forEach(button => button.addEventListener('click', async event => { + event.preventDefault(); + const notice = document.getElementById('incidentNotice'); const note = prompt('Incident note (optional):') || ''; - const response = await fetch('/api/incidents', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:button.dataset.id, status:button.dataset.status, note})}); - document.getElementById('feedbackNotice').textContent = response.ok ? 'Incident state saved.' : 'Could not save incident state.'; - refresh(); + button.disabled = true; + if (notice) notice.textContent = `Saving incident state: ${button.dataset.status || 'open'}...`; + try { + const response = await fetch('/api/incidents', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:button.dataset.id, status:button.dataset.status, note})}); + let payload = {}; + try { payload = await response.json(); } catch (_err) {} + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + if (notice) notice.textContent = `Incident marked ${payload.status || button.dataset.status || 'open'}.`; + await refresh(); + } catch (err) { + if (notice) notice.textContent = `Could not save incident state: ${err.message || err}`; + button.disabled = false; + } })); document.getElementById('blocks').innerHTML = table(data.block_candidates || [], [ {label:'Source', key:'src_ip', render:r => entityCell(r, 'src_ip')}, diff --git a/src/fgai/graylog_mcp.py b/src/fgai/graylog_mcp.py index fab925c..5f2c8e9 100644 --- a/src/fgai/graylog_mcp.py +++ b/src/fgai/graylog_mcp.py @@ -1,11 +1,34 @@ from __future__ import annotations import base64 +import contextlib import json +import signal import ssl +import threading from urllib import error, request +@contextlib.contextmanager +def _hard_timeout(seconds: int): + if seconds <= 0 or threading.current_thread() is not threading.main_thread() or not hasattr(signal, "SIGALRM"): + yield + return + previous_handler = signal.getsignal(signal.SIGALRM) + previous_timer = signal.getitimer(signal.ITIMER_REAL) + + def _raise_timeout(_signum, _frame): + raise TimeoutError(f"mcp_call_timeout_{seconds}s") + + signal.signal(signal.SIGALRM, _raise_timeout) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, previous_timer[0], previous_timer[1]) + signal.signal(signal.SIGALRM, previous_handler) + + class GraylogMcpClient: """Small Streamable HTTP MCP client used for Graylog connection checks.""" @@ -42,9 +65,10 @@ class GraylogMcpClient: req = request.Request(self.url, data=json.dumps(payload).encode("utf-8"), method="POST", headers=headers) try: context = None if self.verify_tls else ssl._create_unverified_context() - with self._open(req, context) as response: - self.session_id = response.headers.get("Mcp-Session-Id", self.session_id) - body = response.read().decode("utf-8") + with _hard_timeout(self.timeout + 1): + with self._open(req, context) as response: + self.session_id = response.headers.get("Mcp-Session-Id", self.session_id) + body = response.read().decode("utf-8") except error.HTTPError as exc: raise RuntimeError(f"http_{exc.code}") from exc except Exception as exc: diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2ee06d2..38ceeee 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -53,6 +53,10 @@ class DashboardTests(unittest.TestCase): self.assertIn("function mcpCapability", HTML) self.assertIn("connected, ${suffix}", HTML) + def test_dashboard_has_visible_incident_action_status(self): + self.assertIn("incidentNotice", HTML) + self.assertIn("Incident marked", HTML) + if __name__ == "__main__": unittest.main()