feat: show runtime diagnostics in management summary
This commit is contained in:
@@ -65,6 +65,11 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
|||||||
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
|
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
|
||||||
self._send_json({"items": load_events(ctx, limit), "limit": limit})
|
self._send_json({"items": load_events(ctx, limit), "limit": limit})
|
||||||
return
|
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)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
def do_PUT(self) -> None:
|
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]:
|
def build_summary(ctx: ManageContext) -> dict[str, Any]:
|
||||||
events = load_events(ctx, MAX_EVENT_LINES)
|
events = load_events(ctx, MAX_EVENT_LINES)
|
||||||
|
diagnostics = load_diagnostics(ctx, MAX_EVENT_LINES)
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
last_event_time = ""
|
last_event_time = ""
|
||||||
latest_alert = ""
|
latest_alert = ""
|
||||||
@@ -261,22 +267,35 @@ def build_summary(ctx: ManageContext) -> dict[str, Any]:
|
|||||||
"violation_count": active_alert_count,
|
"violation_count": active_alert_count,
|
||||||
"latest_alert_time": latest_alert,
|
"latest_alert_time": latest_alert,
|
||||||
"events_path": str(event_sink_path(ctx)),
|
"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]]:
|
def load_events(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
|
||||||
path = event_sink_path(ctx)
|
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)
|
lines = tail_lines(path, limit)
|
||||||
events: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
try:
|
try:
|
||||||
payload = json.loads(line)
|
payload = json.loads(line)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
events.append(payload)
|
items.append(payload)
|
||||||
return events
|
return items
|
||||||
|
|
||||||
|
|
||||||
def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
|
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()
|
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]:
|
def tail_lines(path: Path, limit: int) -> list[str]:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -77,6 +77,37 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(summary["metrics"]["event_count"], 2)
|
self.assertEqual(summary["metrics"]["event_count"], 2)
|
||||||
self.assertEqual(summary["metrics"]["violation_count"], 1)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -484,10 +484,18 @@ function renderMetrics() {
|
|||||||
const cards = [
|
const cards = [
|
||||||
["事件总数", metrics.event_count ?? 0],
|
["事件总数", metrics.event_count ?? 0],
|
||||||
["违规事件", metrics.violation_count ?? 0],
|
["违规事件", metrics.violation_count ?? 0],
|
||||||
|
["诊断帧数", metrics.diagnostics_count ?? 0],
|
||||||
|
["基线状态", metrics.baseline_ready ? "ready" : "learning"],
|
||||||
["最新报警", metrics.latest_alert_time || "-"],
|
["最新报警", metrics.latest_alert_time || "-"],
|
||||||
["事件文件", metrics.events_path || "-"],
|
["事件文件", metrics.events_path || "-"],
|
||||||
];
|
];
|
||||||
els.metrics.innerHTML = cards.map(([label, value]) => `<div class="metric"><span>${label}</span><strong>${value}</strong></div>`).join("");
|
const zoneCounts = metrics.latest_zone_counts || {};
|
||||||
|
const zoneSummary = Object.keys(zoneCounts).length
|
||||||
|
? `<div class="metric wide"><span>最新区域状态</span><strong>${Object.entries(zoneCounts)
|
||||||
|
.map(([zoneId, count]) => `${zoneId}:${count}`)
|
||||||
|
.join(" ")}</strong></div>`
|
||||||
|
: "";
|
||||||
|
els.metrics.innerHTML = cards.map(([label, value]) => `<div class="metric"><span>${label}</span><strong>${value}</strong></div>`).join("") + zoneSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEvents() {
|
function renderEvents() {
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ canvas {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.events-table {
|
.events-table {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user