diff --git a/src/cold_display_guard/manage_api.py b/src/cold_display_guard/manage_api.py index 937a8c9..287e28e 100644 --- a/src/cold_display_guard/manage_api.py +++ b/src/cold_display_guard/manage_api.py @@ -65,6 +65,11 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]: limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES) self._send_json({"items": load_events(ctx, limit), "limit": limit}) return + if parsed.path == "/api/manage/diagnostics": + query = parse_qs(parsed.query) + limit = bounded_int(query.get("limit", ["50"])[0], 1, MAX_EVENT_LINES) + self._send_json({"items": load_diagnostics(ctx, limit), "limit": limit}) + return self.send_error(HTTPStatus.NOT_FOUND) def do_PUT(self) -> None: @@ -234,6 +239,7 @@ def config_payload(ctx: ManageContext) -> dict[str, Any]: def build_summary(ctx: ManageContext) -> dict[str, Any]: events = load_events(ctx, MAX_EVENT_LINES) + diagnostics = load_diagnostics(ctx, MAX_EVENT_LINES) counts: dict[str, int] = {} last_event_time = "" latest_alert = "" @@ -261,22 +267,35 @@ def build_summary(ctx: ManageContext) -> dict[str, Any]: "violation_count": active_alert_count, "latest_alert_time": latest_alert, "events_path": str(event_sink_path(ctx)), + "diagnostics_path": str(diagnostics_path(ctx)), + "diagnostics_count": len(diagnostics), + "latest_zone_counts": latest_zone_counts(diagnostics), + "baseline_ready": latest_baseline_ready(diagnostics), }, } def load_events(ctx: ManageContext, limit: int) -> list[dict[str, Any]]: path = event_sink_path(ctx) + return load_jsonl_tail(path, limit) + + +def load_diagnostics(ctx: ManageContext, limit: int) -> list[dict[str, Any]]: + path = diagnostics_path(ctx) + return load_jsonl_tail(path, limit) + + +def load_jsonl_tail(path: Path, limit: int) -> list[dict[str, Any]]: lines = tail_lines(path, limit) - events: list[dict[str, Any]] = [] + items: list[dict[str, Any]] = [] for line in lines: try: payload = json.loads(line) except json.JSONDecodeError: continue if isinstance(payload, dict): - events.append(payload) - return events + items.append(payload) + return items def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path: @@ -289,6 +308,32 @@ def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> P return path.resolve() +def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path: + if data is None: + data = load_config_document(ctx.config_path) + raw_path = str(data.get("runtime", {}).get("diagnostics_path", "logs/runtime_diagnostics.jsonl")) + path = Path(raw_path).expanduser() + if not path.is_absolute(): + path = ctx.project_root / path + return path.resolve() + + +def latest_zone_counts(diagnostics: list[dict[str, Any]]) -> dict[str, int]: + for item in reversed(diagnostics): + zone_counts = item.get("zone_counts") + if isinstance(zone_counts, dict): + return {str(key): int(value) for key, value in zone_counts.items()} + return {} + + +def latest_baseline_ready(diagnostics: list[dict[str, Any]]) -> bool: + for item in reversed(diagnostics): + diagnostics_payload = item.get("diagnostics") + if isinstance(diagnostics_payload, dict): + return bool(diagnostics_payload.get("baseline_ready", False)) + return False + + def tail_lines(path: Path, limit: int) -> list[str]: if not path.exists(): return [] diff --git a/tests/test_manage_api.py b/tests/test_manage_api.py index 5554251..d410fe4 100644 --- a/tests/test_manage_api.py +++ b/tests/test_manage_api.py @@ -77,6 +77,37 @@ class ManageApiTests(unittest.TestCase): self.assertEqual(summary["metrics"]["event_count"], 2) self.assertEqual(summary["metrics"]["violation_count"], 1) + def test_summary_reads_runtime_diagnostics(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config_path = root / "config" / "local.toml" + save_config_document( + config_path, + { + "runtime": {"diagnostics_path": "logs/runtime_diagnostics.jsonl"}, + "event_sink": {"path": "logs/events.jsonl"}, + "layout": {"rows": 1, "cols": 1, "zone_ids": ["r1c1"]}, + }, + ) + diagnostics_path = root / "logs" / "runtime_diagnostics.jsonl" + diagnostics_path.parent.mkdir() + diagnostics_path.write_text( + json.dumps( + { + "ts": "2026-04-28T10:00:00+08:00", + "zone_counts": {"r1c1": 1}, + "diagnostics": {"baseline_ready": True}, + } + ), + encoding="utf-8", + ) + + summary = build_summary(ManageContext(config_path=config_path, project_root=root)) + + self.assertEqual(summary["metrics"]["diagnostics_count"], 1) + self.assertEqual(summary["metrics"]["latest_zone_counts"], {"r1c1": 1}) + self.assertTrue(summary["metrics"]["baseline_ready"]) + if __name__ == "__main__": unittest.main() diff --git a/web/src/main.js b/web/src/main.js index bc47fa3..30c99bb 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -484,10 +484,18 @@ function renderMetrics() { const cards = [ ["事件总数", metrics.event_count ?? 0], ["违规事件", metrics.violation_count ?? 0], + ["诊断帧数", metrics.diagnostics_count ?? 0], + ["基线状态", metrics.baseline_ready ? "ready" : "learning"], ["最新报警", metrics.latest_alert_time || "-"], ["事件文件", metrics.events_path || "-"], ]; - els.metrics.innerHTML = cards.map(([label, value]) => `
${label}${value}
`).join(""); + const zoneCounts = metrics.latest_zone_counts || {}; + const zoneSummary = Object.keys(zoneCounts).length + ? `
最新区域状态${Object.entries(zoneCounts) + .map(([zoneId, count]) => `${zoneId}:${count}`) + .join(" ")}
` + : ""; + els.metrics.innerHTML = cards.map(([label, value]) => `
${label}${value}
`).join("") + zoneSummary; } function renderEvents() { diff --git a/web/src/styles.css b/web/src/styles.css index 51aae4a..a550a7d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -226,6 +226,10 @@ canvas { font-size: 18px; } +.metric.wide { + grid-column: 1 / -1; +} + .events-table { overflow: auto; }