diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index 4f10913..b0e7fe8 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -7,6 +7,7 @@ from pathlib import Path from .config import ConfigStore from .graylog_mcp import GraylogMcpClient from .metrics import prometheus_metrics +from .feedback import FeedbackStore HTML = """ @@ -155,8 +156,12 @@ async function refresh() { const relatedRows = (data.cross_source_correlations || []).flatMap(correlation => (correlation.samples || []).map(sample => ({source_ip: correlation.source_ip, ...sample}))); const fieldRows = Object.entries(data.field_deviations || {}).flatMap(([entity, deviations]) => (deviations || []).map(item => ({entity, ...item}))); document.getElementById('fieldDeviations').innerHTML = table(fieldRows, [ - {label:'Entity', key:'entity'}, {label:'Stream', key:'stream_id'}, {label:'Field', key:'field'}, {label:'Score', key:'score'}, {label:'Evidence', key:'reason'} + {label:'Entity', key:'entity'}, {label:'Stream', key:'stream_id'}, {label:'Field', key:'field'}, {label:'Score', key:'score'}, {label:'Review', render:r => esc(r.feedback || 'unreviewed')}, {label:'Evidence', key:'reason'}, {label:'Action', render:r => ` `} ]); + document.querySelectorAll('.feedback').forEach(button => button.addEventListener('click', async () => { + await fetch('/api/feedback', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({status:button.dataset.status, entity:button.dataset.entity, stream_id:button.dataset.stream, field:button.dataset.field})}); + refresh(); + })); document.getElementById('relatedActivity').innerHTML = table(relatedRows, [ {label:'Source IP', key:'source_ip'}, {label:'Stream', key:'stream'}, {label:'Time', key:'timestamp'}, {label:'Type', key:'type'}, {label:'Action', key:'action'}, {label:'Severity', key:'severity'}, @@ -258,6 +263,9 @@ def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | if self.path == "/api/config": self._send(200, "application/json", json.dumps(config_store.public()).encode("utf-8")) return + if self.path == "/api/feedback": + self._send(200, "application/json", json.dumps(FeedbackStore().entries()).encode("utf-8")) + return if self.path == "/api/graylog/streams": config = config_store.read() try: @@ -310,6 +318,13 @@ def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | self._send(404, "text/plain; charset=utf-8", b"not found") def do_POST(self) -> None: + if self.path == "/api/feedback" 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(FeedbackStore().add(payload)).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