Add dashboard-triggered replay jobs and detector deltas

Closes the last ROADMAP §1 item. The replay/replay-graylog CLI backend
already existed and was already safe (a temporary, auto-cleaned SQLite
baseline, never touching live state) — the gap was purely that a human had
to SSH in and run it by hand. Extracts the core logic from cli.py into
run_local_replay()/run_graylog_replay() in replay.py, reused by both the
CLI (now thin wrappers) and a new dashboard panel (Diagnostics -> Replay &
Detector Deltas). Runs as a background job via an in-memory job store,
since the dashboard already runs ThreadingHTTPServer and this session
directly observed live MCP polling take anywhere from 9 minutes to over an
hour — a synchronous request/response was never viable. Deliberately
serialized to one job at a time given this box's GPU/CPU constraints.

Also removed a redundant "Detector Precision" panel that duplicated (with
a weaker, current-poll-cycle-only computation) what the new Detector
Health panel already does correctly with real historical data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-24 18:39:54 +02:00
parent bc4d2f465f
commit 3e01ac977d
8 changed files with 474 additions and 156 deletions

View File

@@ -6,8 +6,13 @@ from pathlib import Path
from .anomaly import anomaly_summary, detect_source_anomalies
from .baseline import BaselineStore
from .config import ConfigStore
from .field_shapes import parse_iso_timestamp
from .graylog_mcp import GraylogMcpClient
from .graylog_source import GraylogStreamSource
from .logs import read_events
from .models import LogEvent
from .stream_profiles import parse_profiles
def replay_comparison(current: dict[str, object], candidate: dict[str, object]) -> dict[str, object]:
@@ -77,3 +82,109 @@ def replay_events(events: list[LogEvent], profiles: dict[str, object], *, stream
for item in source_findings
],
}
def _stream_name(config: dict[str, object], stream_id: str) -> str:
return next((str(item.get("title", "")) for item in config.get("graylog_streams", []) if isinstance(item, dict) and item.get("id") == stream_id), stream_id)
def _profile_fields(profile: object | None) -> tuple[str, ...]:
if not profile:
return ()
return tuple(
field for field in (
str(getattr(profile, "entity_field", "")),
*tuple(str(item) for item in getattr(profile, "entity_fields", ())),
str(getattr(profile, "timestamp_field", "")),
*tuple(str(item) for item in getattr(profile, "categorical_fields", ())),
*tuple(str(item) for item in getattr(profile, "numeric_fields", ())),
*tuple(str(getattr(relation, "left", "")) for relation in getattr(profile, "relationship_fields", ())),
*tuple(str(getattr(relation, "right", "")) for relation in getattr(profile, "relationship_fields", ())),
)
if field
)
def _configured_streams(config: dict[str, object], selected: list[str] | None = None) -> list[dict[str, str]]:
selected_order = list(dict.fromkeys(item for item in selected or [] if item))
selected_set = set(selected_order)
configured = [
{"id": str(item.get("id", "")), "title": str(item.get("title", ""))}
for item in config.get("graylog_streams", [])
if isinstance(item, dict) and item.get("id") and (item.get("enabled") or selected_set)
]
if selected_set:
configured = [item for item in configured if item["id"] in selected_set]
by_id = {item["id"]: item for item in configured}
configured = [by_id.get(stream_id, {"id": stream_id, "title": _stream_name(config, stream_id)}) for stream_id in selected_order]
if not configured and str(config.get("graylog_stream", "")):
stream_id = str(config.get("graylog_stream", ""))
configured = [{"id": stream_id, "title": _stream_name(config, stream_id)}]
return configured
def run_local_replay(config_path: str, logs_path: str, *, stream_id: str = "", bucket_seconds: int = 300, compare_config_file: str = "") -> dict[str, object]:
"""Replay a local JSONL/key-value log export against a temporary baseline.
Safe to call from the dashboard: replay_events() never touches the live
baseline or monitor state, so this can run concurrently with a live poll.
"""
config = ConfigStore(config_path).read()
profiles = parse_profiles(config.get("graylog_stream_profiles", []))
stream_name = _stream_name(config, stream_id)
events = read_events(logs_path)
result = replay_events(events, profiles, stream_id=stream_id, stream_name=stream_name, bucket_seconds=bucket_seconds)
if compare_config_file:
candidate_config = ConfigStore(compare_config_file).read()
candidate_profiles = parse_profiles(candidate_config.get("graylog_stream_profiles", []))
candidate = replay_events(events, candidate_profiles, stream_id=stream_id, stream_name=_stream_name(candidate_config, stream_id), bucket_seconds=bucket_seconds)
result = {"current": result, "candidate": candidate, "comparison": replay_comparison(result, candidate), "candidate_config_file": compare_config_file}
return result
def run_graylog_replay(config_path: str, *, stream_ids: list[str] | None = None, range_seconds: int = 24 * 3600, max_events_per_stream: int = 5_000, bucket_seconds: int = 300, compare_config_file: str = "") -> dict[str, object]:
"""Replay a Graylog MCP time window against a temporary baseline.
Safe to call from the dashboard for the same reason as run_local_replay —
a temporary baseline only, and this only reads from Graylog MCP.
"""
config = ConfigStore(config_path).read()
url, token = str(config.get("graylog_mcp_url", "")), str(config.get("graylog_mcp_token", ""))
if not url or not token:
raise RuntimeError("graylog_mcp_not_configured")
profiles = parse_profiles(config.get("graylog_stream_profiles", []))
candidate_profiles = {}
if compare_config_file:
candidate_config = ConfigStore(compare_config_file).read()
candidate_profiles = parse_profiles(candidate_config.get("graylog_stream_profiles", []))
streams = _configured_streams(config, stream_ids)
if not streams:
raise RuntimeError("no_graylog_streams_selected")
events: list[LogEvent] = []
stream_statuses = []
for stream in streams:
stream_id = stream["id"]
profile_fields = tuple(dict.fromkeys([*_profile_fields(profiles.get(stream_id)), *_profile_fields(candidate_profiles.get(stream_id))]))
stream_events, status = GraylogStreamSource(
GraylogMcpClient(url, token),
stream_id,
str(config.get("graylog_query", "*")),
str(config.get("graylog_field_mapping", "")),
stream.get("title") or stream_id,
profile_fields,
).fetch(max_events=max_events_per_stream, range_seconds=range_seconds)
events.extend(stream_events)
stream_statuses.append({"stream_id": stream_id, "stream_name": stream.get("title") or stream_id, **status})
result = {
"source": "graylog_mcp",
"range_seconds": range_seconds,
"streams": stream_statuses,
"replay": replay_events(events, profiles, bucket_seconds=bucket_seconds),
}
if compare_config_file:
candidate = replay_events(events, candidate_profiles, bucket_seconds=bucket_seconds)
current = result["replay"]
result["candidate_config_file"] = compare_config_file
result["candidate_replay"] = candidate
result["comparison"] = replay_comparison(current, candidate)
return result