diff --git a/README.md b/README.md
index 78ecf87..7e0a6e0 100644
--- a/README.md
+++ b/README.md
@@ -460,6 +460,8 @@ FGAI_LLM=1 OLLAMA_MODEL=llama3.1 ./start.sh restart
```
The monitor refreshes deterministic detections every `FGAI_MONITOR_INTERVAL` seconds and refreshes the LLM note every `FGAI_LLM_INTERVAL` seconds, default `300`.
+The dashboard also exposes `Ollama assessment timeout seconds`; raise this when
+Ollama is running but large multi-stream summaries still time out.
The script activates `.venv` inside the script process. If you also want your current shell prompt to show the venv, run:
@@ -687,6 +689,8 @@ end
- `OLLAMA_TIMEOUT`: Ollama request timeout in seconds, defaults to `180`.
- `FGAI_LLM`: set to `1` to enable dashboard Ollama analyst notes.
- `FGAI_LLM_INTERVAL`: seconds between dashboard LLM notes, defaults to `300`.
+- `llm_timeout` in the dashboard config controls the dashboard assessment
+ timeout after startup; it defaults to `180`.
- `FGAI_THREAT_INTEL`: set to `1` to enable external threat intelligence lookups.
- `ABUSEIPDB_API_KEY`: AbuseIPDB API key for public IP reputation enrichment.
- `ABUSEIPDB_MAX_AGE_DAYS`: report age window for AbuseIPDB, defaults to `90`.
diff --git a/src/fgai/config.py b/src/fgai/config.py
index a0c2de9..87e08e0 100644
--- a/src/fgai/config.py
+++ b/src/fgai/config.py
@@ -26,6 +26,7 @@ DEFAULT_CONFIG: dict[str, object] = {
"graylog_field_mapping": "",
"llm_enabled": False,
"llm_model": "",
+ "llm_timeout": 180,
"profile_advisor_enabled": False,
"profile_advisor_model": "qwen3:8b",
"profile_advisor_timeout": 240,
@@ -73,7 +74,7 @@ class ConfigStore:
current[key] = value
elif key == "graylog_fetch_mode" and value in {"auto", "raw", "aggregate"}:
current[key] = value
- elif key in {"graylog_range_seconds", "graylog_max_events_per_stream", "graylog_raw_sample_events", "graylog_mcp_call_timeout_seconds", "graylog_mcp_poll_timeout_seconds", "baseline_retention_days", "baseline_value_retention_days", "baseline_max_values_per_field", "baseline_training_days", "profile_advisor_timeout", "threat_intel_daily_limit", "threat_intel_ttl_seconds", "threat_intel_error_ttl_seconds", "abuseipdb_max_age_days"}:
+ elif key in {"graylog_range_seconds", "graylog_max_events_per_stream", "graylog_raw_sample_events", "graylog_mcp_call_timeout_seconds", "graylog_mcp_poll_timeout_seconds", "baseline_retention_days", "baseline_value_retention_days", "baseline_max_values_per_field", "baseline_training_days", "llm_timeout", "profile_advisor_timeout", "threat_intel_daily_limit", "threat_intel_ttl_seconds", "threat_intel_error_ttl_seconds", "abuseipdb_max_age_days"}:
try:
minimum = 60 if key in {"graylog_range_seconds", "graylog_mcp_poll_timeout_seconds"} else 1
current[key] = max(minimum, int(value))
diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py
index 80c52e9..c28f36d 100644
--- a/src/fgai/dashboard.py
+++ b/src/fgai/dashboard.py
@@ -181,10 +181,10 @@ 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
+
Recommended Stream Profiles
Waiting for observed stream data.
Installed Ollama Models
Loading local Ollama models.
Runtime Configuration
How To Use SignalScope
1. Normal workflow
Settings: connect Graylog MCP and enable streams.
Settings: apply recommended profiles for missing streams.
Diagnostics: confirm raw samples, aggregate counts, and profile readiness.
Overview: use Operator Guidance, incidents, trends, and correlation map.
Findings: review only high-signal deviations first, then mark decisions.
2. Stream health
ready: profile exists, events are arriving, and baseline fields are ready.
learning: profile exists and events arrive, but baseline age or buckets are still too low.
missing_profile: stream is enabled but no profile exists. Apply or edit one.
no_events: stream is enabled but the current poll has no raw sample events.
partial_fetch: Graylog returned only part of the requested raw sample.
3. Ready fields
0/8 means 8 profile fields are tracked but none are mature enough yet. A field needs at least 12 baseline buckets and the configured Baseline training days before it is ready.
During learning, treat findings as signals to tune profiles, not as final alerts.
4. Profiles
Entity fields define who or what behavior is tracked, such as user, host, source IP, or application actor.
Baseline fields define the changing behavior to learn, such as action, event ID, service, URL, status, or counters.
Relationships learn pairs such as username to srcip or host to process.
Detectors add burst checks for auth failures, DNS queries, and deny actions.
5. Findings
Start with Triage Queue and incidents, not raw long tables.
Open evidence details before confirming a finding.
Use Expected for known behavior, False positive for bad signal, Confirmed for real investigation items.
Use expiry when a behavior is expected only temporarily.
6. High EPS / MCP
Use aggregate or auto fetch mode for high EPS streams.
Keep raw samples small enough for context; aggregate counts represent the full window.
Sample capped is normal in aggregate mode. Truncated raw mode means you may miss context.
If MCP is stale, the UI shows cached status so you can still inspect previous findings.
7. Ollama
Dashboard assessment summarizes current evidence.
Profile advisor maps unknown/custom fields and suggests relationships.
Ollama advice is constrained to fields discovered from Graylog; unknown fields are rejected.
8. Baseline DB size
Baseline DB size is the local SQLite behavior baseline.
Retention deletes old rows, but SQLite only returns disk space after manual VACUUM.
If the DB grows fast, lower bucket retention, value retention, or max values per entity field in Settings.
Stop the service before running signalscope baseline-maintenance --vacuum.
9. What to fix first
No streams enabled.
Enabled streams with missing profiles.
Enabled streams with zero raw events.
Profiles stuck at 0 ready fields after the training window.
Too many repeated findings without review feedback.
Baseline DB growing without maintenance.
@@ -726,6 +726,25 @@ async function refresh() {
button.disabled = false;
}
}));
+ async function clearIncidents(status) {
+ const notice = document.getElementById('incidentNotice');
+ const label = status ? `${status} incident states` : 'all stored incident states';
+ if (!confirm(`Clear ${label}? Active evidence can recreate incidents on the next monitor refresh.`)) return;
+ if (notice) notice.textContent = `Clearing ${label}...`;
+ try {
+ const body = status ? {status} : {};
+ const response = await fetch('/api/incidents/clear', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
+ let payload = {};
+ try { payload = await response.json(); } catch (_err) {}
+ if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
+ if (notice) notice.textContent = `Cleared ${payload.removed || 0} incident state(s). ${payload.remaining || 0} stored state(s) remain.`;
+ await refresh();
+ } catch (err) {
+ if (notice) notice.textContent = `Could not clear incidents: ${err.message || err}`;
+ }
+ }
+ document.getElementById('clearResolvedIncidents')?.addEventListener('click', () => clearIncidents('resolved'));
+ document.getElementById('clearAllIncidents')?.addEventListener('click', () => clearIncidents(''));
document.getElementById('blocks').innerHTML = table(data.block_candidates || [], [
{label:'Source', key:'src_ip', render:r => entityCell(r, 'src_ip')},
{label:'Score', key:'score'},
@@ -986,6 +1005,7 @@ async function applySuggestedProfile(streamId) {
baseline_max_values_per_field: config.baseline_max_values_per_field || 500,
llm_enabled: Boolean(config.llm_enabled),
llm_model: config.llm_model || '',
+ llm_timeout: config.llm_timeout || 180,
profile_advisor_enabled: Boolean(config.profile_advisor_enabled),
profile_advisor_model: config.profile_advisor_model || 'qwen3:8b',
profile_advisor_timeout: config.profile_advisor_timeout || 240,
@@ -1211,6 +1231,14 @@ def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str |
except (ValueError, json.JSONDecodeError) as exc:
self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8"))
return
+ if self.path == "/api/incidents/clear" and self._is_loopback_client():
+ try:
+ payload = json.loads(self.rfile.read(min(int(self.headers.get("Content-Length", "0")), 16_384)).decode("utf-8"))
+ status = str(payload.get("status", "") or "")
+ self._send(200, "application/json", json.dumps(IncidentStore().clear(status=status or None)).encode("utf-8"))
+ except (ValueError, json.JSONDecodeError) as exc:
+ self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8"))
+ return
if self.path != "/api/config" or not self._is_loopback_client():
self._send(403, "application/json", b'{"error":"configuration is local-only"}')
return
diff --git a/src/fgai/incidents.py b/src/fgai/incidents.py
index b9c0fa4..bfb2679 100644
--- a/src/fgai/incidents.py
+++ b/src/fgai/incidents.py
@@ -112,6 +112,21 @@ class IncidentStore:
self._write(states)
return {"id": incident_id, **entry}
+ def clear(self, *, status: str | None = None) -> dict[str, object]:
+ states = self.entries()
+ if status is None:
+ removed = len(states)
+ states = {}
+ else:
+ status = status.lower()
+ if status not in {"open", "acknowledged", "resolved"}:
+ raise ValueError("invalid incident status")
+ before = len(states)
+ states = {key: value for key, value in states.items() if str(value.get("status", "open")) != status}
+ removed = before - len(states)
+ self._write(states)
+ return {"removed": removed, "remaining": len(states), "status": status or "all"}
+
def _write(self, states: dict[str, dict[str, object]]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(states, indent=2, sort_keys=True), encoding="utf-8")
diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py
index b6f2242..b45786c 100644
--- a/src/fgai/monitor.py
+++ b/src/fgai/monitor.py
@@ -742,6 +742,7 @@ def monitor_loop(
runtime = ConfigStore(config_path).read() if config_path and Path(config_path).exists() else {}
effective_llm = bool(runtime.get("llm_enabled")) if runtime else llm
effective_model = str(runtime.get("llm_model") or llm_model or "")
+ effective_llm_timeout = int(runtime.get("llm_timeout") or llm_timeout or 180)
mcp_call_timeout = max(1, int(runtime.get("graylog_mcp_call_timeout_seconds", 8) or 8))
mcp_poll_timeout = max(60, int(runtime.get("graylog_mcp_poll_timeout_seconds", 240) or 240))
if runtime.get("log_source") == "graylog_mcp":
@@ -779,15 +780,13 @@ def monitor_loop(
StatusSnapshotStore(status_cache_path).save("last_good", status)
write_status(status, output)
now = int(time.time())
- if runtime.get("profile_advisor_enabled"):
- add_profile_advisor(status, runtime)
- write_status(status, output)
if now - last_llm_at >= llm_interval:
- add_llm_assessment(status, previous=last_llm_text, model=effective_model or None, timeout=llm_timeout)
+ add_llm_assessment(status, previous=last_llm_text, model=effective_model or None, timeout=effective_llm_timeout)
assessment = status.get("llm_assessment", {})
if isinstance(assessment, dict):
last_llm_text = str(assessment.get("text", "") or last_llm_text or "")
last_llm_at = now
+ write_status(status, output)
else:
status["llm_assessment"] = {
"enabled": True,
@@ -795,6 +794,9 @@ def monitor_loop(
"generated_at": last_llm_at,
"text": last_llm_text or "",
}
+ if runtime.get("profile_advisor_enabled"):
+ add_profile_advisor(status, runtime)
+ write_status(status, output)
else:
status["llm_assessment"] = {"enabled": False, "status": "disabled", "text": ""}
if runtime.get("profile_advisor_enabled"):
diff --git a/tests/test_config.py b/tests/test_config.py
index 3c8aa33..d856548 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -48,6 +48,14 @@ class ConfigTests(unittest.TestCase):
self.assertEqual(public["graylog_mcp_call_timeout_seconds"], 6)
self.assertEqual(public["graylog_mcp_poll_timeout_seconds"], 180)
+ def test_llm_timeout_setting_is_numeric_and_bounded(self):
+ with tempfile.TemporaryDirectory() as directory:
+ store = ConfigStore(str(Path(directory) / "config.json"))
+ public = store.update({"llm_timeout": "0"})
+ self.assertEqual(public["llm_timeout"], 1)
+ public = store.update({"llm_timeout": "300"})
+ self.assertEqual(public["llm_timeout"], 300)
+
def test_graylog_tls_verify_can_be_disabled(self):
with tempfile.TemporaryDirectory() as directory:
store = ConfigStore(str(Path(directory) / "config.json"))
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index ab58869..4f34695 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -57,6 +57,11 @@ class DashboardTests(unittest.TestCase):
self.assertIn("incidentNotice", HTML)
self.assertIn("Incident marked", HTML)
+ def test_dashboard_can_clear_stored_incident_states(self):
+ self.assertIn('id="clearResolvedIncidents"', HTML)
+ self.assertIn('id="clearAllIncidents"', HTML)
+ self.assertIn("/api/incidents/clear", HTML)
+
def test_dashboard_does_not_clear_streams_when_picker_is_unloaded(self):
self.assertIn("const streamValues = Object.values(window.streamSelection || {})", HTML)
self.assertIn("if (streamValues.length)", HTML)
@@ -76,6 +81,10 @@ class DashboardTests(unittest.TestCase):
self.assertIn("assessment unavailable", HTML)
self.assertIn("Ollama assessment: unavailable", HTML)
+ def test_dashboard_exposes_ollama_assessment_timeout_setting(self):
+ self.assertIn('name="llm_timeout"', HTML)
+ self.assertIn("llm_timeout: config.llm_timeout || 180", HTML)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_incidents.py b/tests/test_incidents.py
index 5b8b228..fcfc7a5 100644
--- a/tests/test_incidents.py
+++ b/tests/test_incidents.py
@@ -37,3 +37,17 @@ class IncidentTests(unittest.TestCase):
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"])
+
+ def test_incident_store_can_clear_resolved_or_all_states(self):
+ with tempfile.TemporaryDirectory() as directory:
+ store = IncidentStore(str(Path(directory) / "incidents.json"))
+ store.update("one", "resolved")
+ store.update("two", "acknowledged")
+
+ resolved = store.clear(status="resolved")
+ self.assertEqual(resolved, {"removed": 1, "remaining": 1, "status": "resolved"})
+ self.assertEqual(set(store.entries()), {"two"})
+
+ all_items = store.clear()
+ self.assertEqual(all_items, {"removed": 1, "remaining": 0, "status": "all"})
+ self.assertEqual(store.entries(), {})