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.
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
AI Assessment
LLM assessment disabled.
Related Activity Across Sources
Recommended Stream Profiles
Waiting for observed stream data.
Installed Ollama Models
Loading local Ollama models.
@@ -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()