multi-entity stream profiles.

This commit is contained in:
larssand
2026-06-29 19:18:11 +02:00
parent 68370217da
commit aab9f15c6d
6 changed files with 101 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ from .config import ConfigStore
from .graylog_mcp import GraylogMcpClient
from .metrics import prometheus_metrics
from .feedback import FeedbackStore
from .incidents import IncidentStore
HTML = """<!doctype html>
@@ -175,10 +176,18 @@ async function refresh() {
{label:'Entity', key:'entity', render:r => esc(`${r.entity} (${r.entity_type || 'entity'})`)},
{label:'Score', key:'score'},
{label:'Severity', render:r => `<span class="sev-${esc(r.severity)}">${esc(r.severity)}</span>`},
{label:'State', render:r => esc(r.lifecycle_status || 'open')},
{label:'Streams', render:r => esc((r.correlated_streams || []).join(', ') || 'single stream')},
{label:'Evidence', render:r => esc((r.evidence || []).join('; '))},
{label:'Timeline', render:r => { const rows=(r.timeline||[]).map(item => esc(`${item.timestamp || ''} | ${item.stream || ''} | ${item.action || ''} | ${item.destination || ''} | ${item.context || item.message || ''}`)).join('<br>'); const id=`incident:${r.entity}:${r.first_seen || ''}`; return rows ? `<details data-detail-id="${esc(id)}"><summary>${esc(`${r.first_seen || '-'} to ${r.last_seen || '-'}`)}</summary><p>${rows}</p></details>` : '-'; }}
{label:'Timeline', render:r => { const rows=(r.timeline||[]).map(item => esc(`${item.timestamp || ''} | ${item.stream || ''} | ${item.action || ''} | ${item.destination || ''} | ${item.context || item.message || ''}`)).join('<br>'); const id=`incident:${r.id || r.entity}:${r.first_seen || ''}`; return rows ? `<details data-detail-id="${esc(id)}"><summary>${esc(`${r.first_seen || '-'} to ${r.last_seen || '-'}`)}</summary><p>${rows}</p></details>` : '-'; }},
{label:'Action', render:r => `<div class="review-actions"><button class="incident-action" data-id="${esc(r.id)}" data-status="acknowledged">Ack</button><button class="incident-action" data-id="${esc(r.id)}" data-status="resolved">Resolve</button><button class="incident-action" data-id="${esc(r.id)}" data-status="open">Reopen</button></div>${r.note ? `<div class="muted">${esc(r.note)}</div>` : ''}`}
], 'incidents');
document.querySelectorAll('.incident-action').forEach(button => button.addEventListener('click', async () => {
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();
}));
document.getElementById('blocks').innerHTML = table(data.block_candidates || [], [
{label:'Source', key:'src_ip'},
{label:'Score', key:'score'},
@@ -330,6 +339,9 @@ def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str |
if self.path == "/api/feedback":
self._send(200, "application/json", json.dumps(FeedbackStore().entries()).encode("utf-8"))
return
if self.path == "/api/incidents":
self._send(200, "application/json", json.dumps(IncidentStore().entries()).encode("utf-8"))
return
if self.path == "/api/graylog/streams":
config = config_store.read()
try:
@@ -399,6 +411,13 @@ 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" 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"))
self._send(200, "application/json", json.dumps(IncidentStore().update(str(payload.get("id", "")), str(payload.get("status", "")), str(payload.get("note", "")))).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