From b0a0827d01c2f0f08b1500660a320d47d2e97523e5023fe4482026ef3c5a85ff Mon Sep 17 00:00:00 2001 From: larssand Date: Mon, 22 Jun 2026 21:50:21 +0200 Subject: [PATCH] fix ui --- src/fgai/cli.py | 2 ++ src/fgai/dashboard.py | 8 +++++++- src/fgai/history.py | 28 ++++++++++++++++++++++++++++ src/fgai/monitor.py | 12 ++++++++++-- start.sh | 3 ++- 5 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 src/fgai/history.py diff --git a/src/fgai/cli.py b/src/fgai/cli.py index f1ad866..fdd8d40 100644 --- a/src/fgai/cli.py +++ b/src/fgai/cli.py @@ -205,6 +205,7 @@ def run_monitor(args: argparse.Namespace) -> int: llm_timeout=args.llm_timeout, baseline_path=args.baseline_db, config_path=args.config_file, + history_path=args.history_db, ) return 0 @@ -289,6 +290,7 @@ def build_parser() -> argparse.ArgumentParser: monitor.add_argument("--anomaly-limit", type=int, default=20, help="Maximum anomaly findings to include") monitor.add_argument("--baseline-db", default="state/fgai-baseline.sqlite3", help="SQLite database for historical behavior baselines") monitor.add_argument("--config-file", default="state/fgai-config.json", help="Runtime configuration written by dashboard") + monitor.add_argument("--history-db", default="state/signalscope-history.sqlite3", help="Aggregate history database for dashboard trends") monitor.add_argument("--llm", action="store_true", help="Generate cached Ollama analyst note for dashboard") monitor.add_argument("--llm-interval", type=int, default=300, help="Seconds between Ollama dashboard assessments") monitor.add_argument("--model", default=None, help="Ollama model name") diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index 30c895b..4f10913 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -54,6 +54,7 @@ HTML = """ .field-row code { width: fit-content; } .field-controls { display: flex; flex-wrap: wrap; gap: 10px; } .field-controls label { white-space: nowrap; } + .chart { width: 100%; height: 220px; background: #04182d; border: 1px solid #163b59; } @media (max-width: 860px) { .hero, .split { grid-template-columns: 1fr; } .hero img { display: none; } } @@ -68,7 +69,7 @@ HTML = """ -

AI Assessment

LLM assessment disabled.

Anomalies

Recommendations

+

Events and Anomalies

Baseline and Stream Health

AI Assessment

LLM assessment disabled.

Anomalies

Recommendations

Field Baseline Deviations

Related Activity Across Sources

Block Candidates

Threat Intelligence

Policy Findings

Diagnostics

Runtime Configuration

@@ -87,6 +88,7 @@ function table(rows, columns) { return `
${head}${body}
`; } function capability(label, state, detail) { return `${esc(label)}: ${esc(detail)}`; } +function drawTrend(history) { const canvas=document.getElementById('trendChart'), ctx=canvas.getContext('2d'), w=canvas.width=canvas.clientWidth*devicePixelRatio, h=canvas.height=canvas.clientHeight*devicePixelRatio; ctx.scale(devicePixelRatio,devicePixelRatio); const cw=canvas.clientWidth,ch=canvas.clientHeight; ctx.clearRect(0,0,cw,ch); const max=Math.max(1,...history.map(item=>item.events||0)); const line=(key,color)=>{ctx.strokeStyle=color;ctx.lineWidth=2;ctx.beginPath();history.forEach((item,index)=>{const x=12+index*Math.max(1,(cw-24)/Math.max(1,history.length-1));const y=ch-18-((item[key]||0)/max)*(ch-36);index?ctx.lineTo(x,y):ctx.moveTo(x,y)});ctx.stroke()}; line('events','#1ea9ff');line('anomalies','#ff5656'); } async function refresh() { const res = await fetch('/api/status', {cache: 'no-store'}); const data = await res.json(); @@ -96,6 +98,7 @@ async function refresh() { const threat = (data.capabilities || {}).threat_intel || {}; const mcp = (data.capabilities || {}).graylog_mcp || {}; const configuration = data.configuration || {}; + drawTrend(data.history || []); document.getElementById('stamp').textContent = data.generated_at ? `Updated ${new Date(data.generated_at * 1000).toLocaleString()}` : 'Waiting for monitor data'; document.getElementById('metrics').innerHTML = [ metric('Total events', s.total || 0), @@ -117,6 +120,9 @@ async function refresh() { `High anomalies: ${esc((a.high || 0))}`, `Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}` ].join('
'); + document.getElementById('health').innerHTML = [ + metric('Baseline sources ready', baseline.sources_ready || 0), metric('MCP events fetched', mcp.events_fetched || 0), metric('Profiles active', (data.stream_profiles || []).length), metric('Correlated entities', (data.cross_source_correlations || []).length) + ].join(''); const llmText = llm.text ? esc(llm.text).replace(/\\n/g, '
') : esc(llm.error || 'LLM assessment disabled or waiting for first run.'); document.getElementById('llmAssessment').innerHTML = `
Status: ${esc(llm.status || 'unknown')}

${llmText}

`; document.getElementById('anomalies').innerHTML = table(data.anomalies || [], [ diff --git a/src/fgai/history.py b/src/fgai/history.py new file mode 100644 index 0000000..e12a688 --- /dev/null +++ b/src/fgai/history.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + + +class HistoryStore: + def __init__(self, path: str) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self.path) as connection: + connection.execute("create table if not exists snapshots (at integer primary key, events integer, anomalies integer, critical integer, high integer, baseline_ready integer, mcp_events integer)") + + def record(self, status: dict[str, object]) -> None: + summary = status.get("summary", {}) if isinstance(status.get("summary"), dict) else {} + anomalies = status.get("anomaly_summary", {}) if isinstance(status.get("anomaly_summary"), dict) else {} + baseline = status.get("baseline", {}) if isinstance(status.get("baseline"), dict) else {} + capabilities = status.get("capabilities", {}) if isinstance(status.get("capabilities"), dict) else {} + mcp = capabilities.get("graylog_mcp", {}) if isinstance(capabilities.get("graylog_mcp"), dict) else {} + with sqlite3.connect(self.path) as connection: + connection.execute("insert or replace into snapshots values (?, ?, ?, ?, ?, ?, ?)", (int(time.time()), int(summary.get("total", 0) or 0), int(anomalies.get("total", 0) or 0), int(anomalies.get("critical", 0) or 0), int(anomalies.get("high", 0) or 0), int(baseline.get("sources_ready", 0) or 0), int(mcp.get("events_fetched", 0) or 0))) + connection.execute("delete from snapshots where at < ?", (int(time.time()) - 7 * 86400,)) + + def recent(self, limit: int = 288) -> list[dict[str, int]]: + with sqlite3.connect(self.path) as connection: + rows = connection.execute("select at, events, anomalies, critical, high, baseline_ready, mcp_events from snapshots order by at desc limit ?", (limit,)).fetchall() + return [dict(zip(("at", "events", "anomalies", "critical", "high", "baseline_ready", "mcp_events"), row)) for row in reversed(rows)] diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py index 83cb145..255457b 100644 --- a/src/fgai/monitor.py +++ b/src/fgai/monitor.py @@ -11,6 +11,7 @@ from .correlation import correlate_source_ips from .event_context import build_event_context from .graylog_mcp import GraylogMcpClient from .graylog_source import GraylogStreamSource +from .history import HistoryStore from .llm import ollama_dashboard_assessment from .logs import local_in_failures, read_events, summarize_events, top_field_values from .mitigation import parse_allowlist, suggest_block_candidates @@ -29,6 +30,7 @@ def build_status( anomaly_limit: int = 20, baseline_path: str | None = None, config_path: str | None = None, + history_path: str | None = None, ) -> dict[str, object]: config_store = ConfigStore(config_path) if config_path else None config_exists = bool(config_store and config_store.path.exists()) @@ -93,7 +95,7 @@ def build_status( except Exception as exc: policy_error = str(exc) - return { + status = { "generated_at": int(time.time()), "log_path": log_path, "policy_path": policy_path, @@ -149,6 +151,11 @@ def build_status( "policy_findings": policy_findings, "policy_error": policy_error, } + if history_path: + history = HistoryStore(history_path) + history.record(status) + status["history"] = history.recent() + return status def add_llm_assessment(status: dict[str, object], *, previous: str | None = None, model: str | None = None, timeout: int | None = None) -> None: @@ -190,6 +197,7 @@ def monitor_loop( llm_timeout: int | None = None, baseline_path: str | None = None, config_path: str | None = None, + history_path: str | None = None, ) -> None: print(f"Monitoring {log_path}") print(f"Writing status to {output}") @@ -201,7 +209,7 @@ def monitor_loop( effective_model = str(runtime.get("llm_model") or llm_model or "") status = build_status( log_path, policy_path=policy_path, anomaly_limit=anomaly_limit, - baseline_path=baseline_path, config_path=config_path, + baseline_path=baseline_path, config_path=config_path, history_path=history_path, ) if effective_llm: now = int(time.time()) diff --git a/start.sh b/start.sh index a17b6f3..699f0d7 100755 --- a/start.sh +++ b/start.sh @@ -13,6 +13,7 @@ POLICY_FILE="${FGAI_POLICY_FILE:-$ROOT_DIR/exports/policies.json}" STATE_FILE="${FGAI_STATE_FILE:-$ROOT_DIR/state/fgai-status.json}" CONFIG_FILE="${FGAI_CONFIG_FILE:-$ROOT_DIR/state/fgai-config.json}" BASELINE_DB="${FGAI_BASELINE_DB:-$ROOT_DIR/state/fgai-baseline.sqlite3}" +HISTORY_DB="${FGAI_HISTORY_DB:-$ROOT_DIR/state/signalscope-history.sqlite3}" MONITOR_INTERVAL="${FGAI_MONITOR_INTERVAL:-10}" LLM_ENABLED="${FGAI_LLM:-0}" LLM_INTERVAL="${FGAI_LLM_INTERVAL:-300}" @@ -120,7 +121,7 @@ start_monitor() { return 0 fi - monitor_args=(monitor --logs "$LOG_FILE" --output "$STATE_FILE" --interval "$MONITOR_INTERVAL" --baseline-db "$BASELINE_DB" --config-file "$CONFIG_FILE") + monitor_args=(monitor --logs "$LOG_FILE" --output "$STATE_FILE" --interval "$MONITOR_INTERVAL" --baseline-db "$BASELINE_DB" --history-db "$HISTORY_DB" --config-file "$CONFIG_FILE") if [ -f "$POLICY_FILE" ]; then monitor_args+=(--policies "$POLICY_FILE") fi