feat: add webhook case management

This commit is contained in:
2026-06-09 11:13:56 +08:00
parent 490b3089d2
commit 9d791be174
17 changed files with 1982 additions and 12 deletions

View File

@@ -97,6 +97,7 @@ http://127.0.0.1:23000
- 标定数字食品区域和垃圾桶 ROI - 标定数字食品区域和垃圾桶 ROI
- 直接保存标定结果到项目配置文件 - 直接保存标定结果到项目配置文件
- 查看事件汇总、区域序号、停留时间、报警和警告事件 - 查看事件汇总、区域序号、停留时间、报警和警告事件
- 查看本地处置单状态,并手工标记为已处理
项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。 项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。
@@ -117,6 +118,12 @@ http://127.0.0.1:19080
- `PUT /api/manage/calibration` - `PUT /api/manage/calibration`
- `GET /api/manage/summary` - `GET /api/manage/summary`
- `GET /api/manage/events` - `GET /api/manage/events`
- `GET /api/manage/cases`
- `GET /api/manage/cases/summary`
- `POST /api/manage/cases/{case_id}/handle`
- `POST /api/manage/webhooks/case-update`
`/api/manage/webhooks/case-update` 需要请求头 `X-Webhook-Token`,并且请求体里的 `status` 目前固定为 `handled`
## 运行识别计时进程 ## 运行识别计时进程
@@ -133,7 +140,9 @@ scripts/run_runtime.sh
3. 按标定区域做占用变化检测。 3. 按标定区域做占用变化检测。
4. 判断垃圾桶区域是否有明显投放动作。 4. 判断垃圾桶区域是否有明显投放动作。
5. 调用批次计时状态机。 5. 调用批次计时状态机。
6. 写入 `logs/events.jsonl`,管理页会读取这个文件 6. `time_alarm``batch_pending_disposal``warning_escalated` 映射到本地处置单状态
7. 写入 `logs/events.jsonl``logs/cases.jsonl``logs/runtime_diagnostics.jsonl`
8. 按配置向外部系统推送事件 webhook 和处置单 webhook。
当前视觉版本是可运行的启发式版本: 当前视觉版本是可运行的启发式版本:
@@ -168,8 +177,24 @@ trash_sustained_motion_delta = 8.0
trash_sustained_motion_frames = 2 trash_sustained_motion_frames = 2
trash_motion_cooldown_seconds = 3 trash_motion_cooldown_seconds = 3
diagnostics_path = "logs/runtime_diagnostics.jsonl" diagnostics_path = "logs/runtime_diagnostics.jsonl"
[case_sink]
path = "logs/cases.jsonl"
[webhooks]
enabled = true
event_url = "https://example.com/runtime-events"
case_url = "https://example.com/case-events"
callback_token = "shared-secret"
connect_timeout_seconds = 3
read_timeout_seconds = 5
``` ```
运行时会额外记录:
- `logs/cases.jsonl`:本地处置单状态变更
- `logs/webhook_delivery.jsonl`Webhook 投递结果审计
## 本地测试 ## 本地测试
```bash ```bash

View File

@@ -0,0 +1,112 @@
# Webhook Case Management Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build local case management plus outbound/inbound webhook support on top of the existing runtime batch-event flow.
**Architecture:** Keep `BatchEngine` as the factual event source, then add a separate case-state module that consumes selected events and persists case snapshots. Add a webhook delivery module for both batch events and case events, expose management APIs for case listing and handling, and render the resulting case workflow in the existing management console without mixing facts and workflow state.
**Tech Stack:** Python 3.12 via pyenv, Python standard library HTTP/JSON/TOML stack, JSONL files, unittest, Vite + vanilla JavaScript, Node test runner.
---
## File Map
- Create: `src/cold_display_guard/cases.py`
- Create: `src/cold_display_guard/webhooks.py`
- Modify: `src/cold_display_guard/config.py`
- Modify: `src/cold_display_guard/main.py`
- Modify: `src/cold_display_guard/manage_api.py`
- Modify: `web/src/main.js`
- Modify: `web/src/zone-state.js`
- Create: `tests/test_cases.py`
- Create: `tests/test_webhooks.py`
- Modify: `tests/test_manage_api.py`
- Modify: `tests/test_main.py`
- Modify: `web/test/zone-state.test.js`
### Task 1: Backend Case State Layer
**Files:**
- Create: `src/cold_display_guard/cases.py`
- Create: `tests/test_cases.py`
- Modify: `src/cold_display_guard/main.py`
- Modify: `tests/test_main.py`
- [ ] Write failing tests for case creation, case escalation, manual/callback/auto close, and restore behavior in `tests/test_cases.py`.
- [ ] Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_cases.py -v`
Expected: failing assertions or import errors for missing case helpers.
- [ ] Implement minimal case dataclasses, JSONL load/save helpers, event-to-case transitions, and restore logic in `src/cold_display_guard/cases.py`.
- [ ] Wire runtime event processing in `src/cold_display_guard/main.py` so emitted batch events produce persisted case snapshots.
- [ ] Run:
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_cases.py -v`
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
Expected: PASS.
### Task 2: Webhook Configuration And Delivery
**Files:**
- Create: `src/cold_display_guard/webhooks.py`
- Create: `tests/test_webhooks.py`
- Modify: `src/cold_display_guard/config.py`
- Modify: `src/cold_display_guard/main.py`
- [ ] Write failing tests for webhook config parsing, batch event payload delivery, case event payload delivery, and delivery-failure logging in `tests/test_webhooks.py`.
- [ ] Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
Expected: FAIL because webhook helpers/config support do not exist yet.
- [ ] Implement webhook settings parsing/saving in `src/cold_display_guard/config.py` and synchronous delivery plus audit logging in `src/cold_display_guard/webhooks.py`.
- [ ] Integrate webhook sending into `src/cold_display_guard/main.py` after local event and case persistence.
- [ ] Run:
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_config.py -v`
Expected: PASS.
### Task 3: Management API For Cases And Callback Handling
**Files:**
- Modify: `src/cold_display_guard/manage_api.py`
- Modify: `tests/test_manage_api.py`
- Modify: `src/cold_display_guard/config.py`
- [ ] Write failing API tests for `/api/manage/cases`, `/api/manage/cases/summary`, `/api/manage/cases/{case_id}/handle`, and `/api/manage/webhooks/case-update` in `tests/test_manage_api.py`.
- [ ] Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
Expected: FAIL because the new endpoints and case summary behavior are missing.
- [ ] Implement case listing, case summary, manual handle, and token-protected callback handling in `src/cold_display_guard/manage_api.py`.
- [ ] Ensure config payloads expose webhook settings and case/log sink paths without leaking secrets unnecessarily.
- [ ] Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
Expected: PASS.
### Task 4: Frontend Case Management UI
**Files:**
- Modify: `web/src/main.js`
- Modify: `web/src/zone-state.js`
- Modify: `web/test/zone-state.test.js`
- [ ] Write failing frontend tests for case summary mapping, case table rendering helpers, event/case separation, and manual handle request shaping in `web/test/zone-state.test.js`.
- [ ] Run: `node --test web/test/zone-state.test.js`
Expected: FAIL because case helpers and UI state handling do not exist yet.
- [ ] Implement frontend model helpers and UI rendering for case summaries, case rows, and manual handle actions while preserving the existing runtime event table semantics.
- [ ] Run:
- `node --test web/test/zone-state.test.js`
- `cd web && pnpm build`
Expected: PASS.
### Task 5: Full Verification And Documentation Alignment
**Files:**
- Modify: `README_zh.md`
- Modify: `tasks/todo.md`
- [ ] Update documentation for new webhook config, case logs, and management endpoints if implementation changed the documented surface area.
- [ ] Run targeted verification:
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_cases.py -v`
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
- `node --test web/test/zone-state.test.js`
Expected: PASS.
- [ ] Run full verification:
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest discover -s tests -v`
- `cd web && pnpm build`
Expected: PASS.
- [ ] Record final verification outcomes and any environmental caveats in `tasks/todo.md`.

View File

@@ -0,0 +1,216 @@
# Webhook Case Management Design
**Goal:** Add outbound webhooks plus a local case-management layer so the project can both push runtime facts to external systems and independently track pending/handled cases in the local management console.
**Architecture:** Keep the existing runtime event stream as the source of operational facts. Add a separate case-state layer that consumes selected runtime events, persists case state transitions, exposes management APIs, and emits case webhooks without mutating the underlying batch facts. Integrate manual handling and external callback handling through the same case-state model.
**Tech Stack:** Python 3.11+ standard library backend, JSONL persistence, Vite + vanilla JavaScript frontend, existing unittest and Node test suites.
---
## Scope
This design extends the current project in four focused areas:
1. Add outbound webhook delivery for runtime batch events.
2. Add a local case model for operator workflow.
3. Add management APIs for listing, summarizing, manually handling, and externally updating cases.
4. Add frontend views and actions for local case operations.
The runtime batch engine remains the producer of factual detection events. Case handling is a downstream interpretation layer.
## Current Constraints
- The current runtime writes facts to `logs/events.jsonl` and diagnostics to `logs/runtime_diagnostics.jsonl`.
- The management API is a small standard-library HTTP server and should stay that way.
- The frontend already renders runtime metrics and runtime events and should continue to do so.
- The user-selected workflow requires both manual handling and external callback handling.
- The user-selected workflow requires both event webhooks and case webhooks.
- The events that should enter the local pending-case flow are `time_alarm`, `batch_pending_disposal`, and `warning_escalated`.
## Design Summary
The system is split into three cooperating layers:
1. **Batch event layer**
Produces facts such as `batch_started`, `time_alarm`, `batch_pending_disposal`, `batch_discarded`, and `warning_escalated`. These remain append-only runtime facts.
2. **Case state layer**
Consumes selected batch events and maintains a separate per-batch local case state. The case layer owns pending/handled workflow and does not rewrite prior runtime facts.
3. **Integration layer**
Delivers outbound event and case webhooks, accepts external case callbacks, and records webhook delivery attempts for audit and debugging.
## Persistence Model
- `logs/events.jsonl`
Existing runtime fact log. No schema removals.
- `logs/cases.jsonl`
New append-only case transition log. Each line records a case snapshot after a state change.
- `logs/webhook_delivery.jsonl`
New append-only webhook delivery audit log. Each line records an attempted outbound delivery result.
`events.jsonl` remains the source of factual batch history. `cases.jsonl` is the source of case workflow state. `webhook_delivery.jsonl` is operational telemetry only.
## Case Model
Each batch can own at most one local case. A case is created or updated from selected batch events and then independently handled by a local operator or external callback.
### Case fields
- `case_id`
- `batch_id`
- `camera_id`
- `zone_id`
- `zone_label`
- `case_type`
- `case_status`
- `source_event`
- `created_at`
- `updated_at`
- `handled_at`
- `handled_by`
- `handled_source`
- `last_event_ts`
- `payload`
### Case type values
- `time_alarm`
- `pending_disposal`
- `warning_escalated`
### Case status values
- `open`
- `handled`
### Handled source values
- `manual`
- `webhook_callback`
- `auto_closed`
## Case State Flow
1. `time_alarm`
Create a case if one does not exist for the batch. If a case already exists, keep it open and refresh timestamps.
2. `batch_pending_disposal`
Create a case if one does not exist. If one exists, update it in place and upgrade `case_type` to `pending_disposal`.
3. `warning_escalated`
Update the same case in place and upgrade `case_type` to `warning_escalated`.
4. Manual handling
Mark the case as `handled`, set `handled_source=manual`, record `handled_by`, and append the new snapshot to `cases.jsonl`.
5. External callback handling
Mark the case as `handled`, set `handled_source=webhook_callback`, optionally record `handled_by` and `source_ref`, and append the new snapshot to `cases.jsonl`.
6. `batch_discarded`
If the related case is still `open`, close it automatically with `handled_source=auto_closed`.
Handled cases must not reopen when stale older events are replayed or re-read. Only new event processing in forward time may mutate an existing case. Restore logic must preserve handled status across runtime/API restarts.
## Backend Components
- Create `src/cold_display_guard/cases.py` for case transition logic, persistence, restore, and summary helpers.
- Create `src/cold_display_guard/webhooks.py` for webhook config parsing, payload building, synchronous delivery, and delivery audit logging.
- Extend `src/cold_display_guard/config.py` for webhook configuration and case/log sink paths.
- Extend `src/cold_display_guard/main.py` to feed runtime events into case persistence and webhook delivery.
- Extend `src/cold_display_guard/manage_api.py` to expose case listing, case summary, manual handling, and token-protected callback handling.
## API Design
All new endpoints stay under `/api/manage/*`.
- `GET /api/manage/cases`
Query: `status=open|handled` optional, `limit` optional.
- `GET /api/manage/cases/summary`
Returns case counts and latest update time.
- `POST /api/manage/cases/{case_id}/handle`
Body: `handled_by` required, `note` optional.
- `POST /api/manage/webhooks/case-update`
Body: `case_id` required, `status` required and must equal `handled`, `handled_by` optional, `source_ref` optional.
The callback endpoint must require the configured shared token in the `X-Webhook-Token` header and must reject unauthenticated updates.
## Webhook Configuration
```toml
[webhooks]
enabled = true
event_url = "https://example.com/runtime-events"
case_url = "https://example.com/case-events"
callback_token = "shared-secret"
connect_timeout_seconds = 3
read_timeout_seconds = 5
```
## Outbound Webhook Delivery
Event webhook payload core fields:
- `kind = "batch_event"`
- `event`
- `ts`
- `batch_id`
- `camera_id`
- `zone_id`
- `zone_label`
- `severity`
- `state`
Case webhook payload core fields:
- `kind = "case_event"`
- `action = "created" | "updated" | "handled"`
- `case_id`
- `case_type`
- `case_status`
- `batch_id`
- `source_event`
- `handled_source`
- `updated_at`
Delivery rules:
- Local runtime facts and case state must be persisted before webhook failure can affect control flow.
- Webhook failure must append a line to `logs/webhook_delivery.jsonl`.
- Webhook failure must not stop local event persistence or local case persistence.
- This batch does not add a retry queue.
## Frontend Changes
- Keep the current runtime event table for factual runtime events only.
- Add a separate case table with:
- `case_id`
- `case_type`
- `case_status`
- `zone_label`
- `batch_id`
- `created_at`
- `updated_at`
- `handled_source`
- Add manual-handle UI for `open` cases with `handled_by` required and `note` optional.
- Add summary cards for:
- `open_case_count`
- `handled_case_count`
- `time_alarm_case_count`
- `pending_disposal_case_count`
- `warning_escalated_case_count`
## Testing Plan
- Preserve existing batch engine behavior tests.
- Add case tests for create, escalate, manual handle, callback handle, auto-close, and non-reopen behavior.
- Add webhook tests for payloads, delivery success, and failure audit logging.
- Add API tests for new case and callback endpoints.
- Add frontend tests for case rendering, case summary mapping, and manual-handle request flow.
Verification commands:
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest discover -s tests -v`
- `node --test web/test/zone-state.test.js`
- `cd web && pnpm build`

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
EVENT_CASE_TYPES = {
"time_alarm": "time_alarm",
"batch_pending_disposal": "pending_disposal",
"warning_escalated": "warning_escalated",
}
CASE_PRIORITY = {
"time_alarm": 1,
"pending_disposal": 2,
"warning_escalated": 3,
}
@dataclass(slots=True)
class CaseSnapshot:
case_id: str
batch_id: str
camera_id: str
zone_id: str
zone_label: str
case_type: str
case_status: str
source_event: str
created_at: datetime
updated_at: datetime
handled_at: datetime | None = None
handled_by: str = ""
handled_source: str = ""
last_event_ts: datetime | None = None
payload: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
payload = {
"case_id": self.case_id,
"batch_id": self.batch_id,
"camera_id": self.camera_id,
"zone_id": self.zone_id,
"zone_label": self.zone_label,
"case_type": self.case_type,
"case_status": self.case_status,
"source_event": self.source_event,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"handled_by": self.handled_by,
"handled_source": self.handled_source,
"payload": self.payload,
}
if self.handled_at is not None:
payload["handled_at"] = self.handled_at.isoformat()
if self.last_event_ts is not None:
payload["last_event_ts"] = self.last_event_ts.isoformat()
return payload
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CaseSnapshot":
return cls(
case_id=str(payload.get("case_id", "")),
batch_id=str(payload.get("batch_id", "")),
camera_id=str(payload.get("camera_id", "")),
zone_id=str(payload.get("zone_id", "")),
zone_label=str(payload.get("zone_label", "")),
case_type=str(payload.get("case_type", "")),
case_status=str(payload.get("case_status", "")),
source_event=str(payload.get("source_event", "")),
created_at=parse_datetime(payload.get("created_at")) or datetime.min,
updated_at=parse_datetime(payload.get("updated_at")) or datetime.min,
handled_at=parse_datetime(payload.get("handled_at")),
handled_by=str(payload.get("handled_by", "")),
handled_source=str(payload.get("handled_source", "")),
last_event_ts=parse_datetime(payload.get("last_event_ts")),
payload=dict(payload.get("payload", {}) or {}),
)
class CaseStore:
def __init__(self, snapshots: list[dict[str, Any]] | None = None) -> None:
self._cases: dict[str, CaseSnapshot] = {}
for payload in snapshots or []:
snapshot = CaseSnapshot.from_dict(payload)
if not snapshot.case_id:
continue
existing = self._cases.get(snapshot.case_id)
if existing is None or snapshot.updated_at >= existing.updated_at:
self._cases[snapshot.case_id] = snapshot
def latest_cases(self) -> list[dict[str, Any]]:
snapshots = sorted(self._cases.values(), key=lambda item: item.updated_at, reverse=True)
return [snapshot.to_dict() for snapshot in snapshots]
def apply_batch_events(self, events: list[dict[str, Any]]) -> list[dict[str, Any]]:
snapshots: list[dict[str, Any]] = []
for event in events:
snapshot = self._apply_batch_event(event)
if snapshot is not None:
snapshots.append(snapshot.to_dict())
return snapshots
def mark_handled(
self,
case_id: str,
*,
handled_at: datetime,
handled_by: str = "",
handled_source: str,
note: str = "",
source_ref: str = "",
) -> dict[str, Any]:
snapshot = self._cases[case_id]
snapshot.case_status = "handled"
snapshot.updated_at = handled_at
snapshot.handled_at = handled_at
snapshot.handled_by = handled_by
snapshot.handled_source = handled_source
payload = dict(snapshot.payload)
if note:
payload["note"] = note
if source_ref:
payload["source_ref"] = source_ref
snapshot.payload = payload
return snapshot.to_dict()
def _apply_batch_event(self, event: dict[str, Any]) -> CaseSnapshot | None:
event_name = str(event.get("event", ""))
when = parse_datetime(event.get("ts"))
if when is None:
return None
batch_id = str(event.get("batch_id", "")).strip()
if not batch_id:
return None
case_id = build_case_id(batch_id)
existing = self._cases.get(case_id)
if event_name == "batch_discarded":
if existing is None or existing.case_status == "handled":
return None
return self._close_case(existing, when, handled_source="auto_closed")
case_type = EVENT_CASE_TYPES.get(event_name)
if case_type is None:
return None
if existing is not None:
if existing.last_event_ts is not None and when <= existing.last_event_ts:
return None
if existing.case_status == "handled":
return None
existing.case_type = higher_priority_case_type(existing.case_type, case_type)
existing.case_status = "open"
existing.source_event = event_name
existing.updated_at = when
existing.last_event_ts = when
existing.payload = {"event": dict(event)}
return existing
snapshot = CaseSnapshot(
case_id=case_id,
batch_id=batch_id,
camera_id=str(event.get("camera_id", "")),
zone_id=str(event.get("zone_id", "")),
zone_label=str(event.get("zone_label", "")),
case_type=case_type,
case_status="open",
source_event=event_name,
created_at=when,
updated_at=when,
last_event_ts=when,
payload={"event": dict(event)},
)
self._cases[case_id] = snapshot
return snapshot
def _close_case(self, snapshot: CaseSnapshot, handled_at: datetime, *, handled_source: str) -> CaseSnapshot:
snapshot.case_status = "handled"
snapshot.updated_at = handled_at
snapshot.handled_at = handled_at
snapshot.handled_source = handled_source
return snapshot
def build_case_id(batch_id: str) -> str:
return f"case_{batch_id}"
def higher_priority_case_type(current: str, incoming: str) -> str:
if CASE_PRIORITY.get(incoming, 0) >= CASE_PRIORITY.get(current, 0):
return incoming
return current
def append_case_snapshots(path: Path, payloads: list[dict[str, Any]]) -> None:
if not payloads:
return
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
if path.exists() and path.stat().st_size > 0 and not file_ends_with_newline(path):
handle.write("\n")
for payload in payloads:
handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True))
handle.write("\n")
def load_case_snapshots(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
items: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(payload, dict):
items.append(payload)
return items
def parse_datetime(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value
if not value:
return None
try:
return datetime.fromisoformat(str(value))
except ValueError:
return None
def file_ends_with_newline(path: Path) -> bool:
with path.open("rb") as handle:
handle.seek(-1, 2)
return handle.read(1) == b"\n"

View File

@@ -192,6 +192,34 @@ def format_config_document(data: dict[str, Any]) -> str:
lines.append("[event_sink]") lines.append("[event_sink]")
lines.append(f'path = "{_escape(str(event_sink.get("path", "logs/events.jsonl")))}"') lines.append(f'path = "{_escape(str(event_sink.get("path", "logs/events.jsonl")))}"')
lines.append("") lines.append("")
case_sink = data.get("case_sink", {})
if case_sink:
lines.append("[case_sink]")
lines.append(f'path = "{_escape(str(case_sink.get("path", "logs/cases.jsonl")))}"')
lines.append("")
webhooks = data.get("webhooks", {})
if webhooks:
lines.append("[webhooks]")
for key in (
"callback_token",
"case_url",
"connect_timeout_seconds",
"enabled",
"event_url",
"read_timeout_seconds",
):
if key not in webhooks:
continue
value = webhooks[key]
if isinstance(value, bool):
lines.append(f"{key} = {str(value).lower()}")
elif isinstance(value, int | float):
lines.append(f"{key} = {value}")
else:
lines.append(f'{key} = "{_escape(str(value))}"')
lines.append("")
return "\n".join(lines) return "\n".join(lines)

View File

@@ -7,6 +7,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from cold_display_guard.cases import CaseStore, append_case_snapshots, load_case_snapshots
from cold_display_guard.config import load_config_document, load_settings, resolve_config_path, resolve_project_root from cold_display_guard.config import load_config_document, load_settings, resolve_config_path, resolve_project_root
from cold_display_guard.engine import BatchEngine from cold_display_guard.engine import BatchEngine
from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource
@@ -18,6 +19,7 @@ from cold_display_guard.vision import (
load_runtime_vision_settings, load_runtime_vision_settings,
metrics_indicate_occupied, metrics_indicate_occupied,
) )
from cold_display_guard.webhooks import send_batch_event_webhooks, send_case_webhooks
def main() -> int: def main() -> int:
@@ -51,6 +53,11 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
timezone = ZoneInfo(str(config.get("timezone", "Asia/Shanghai"))) timezone = ZoneInfo(str(config.get("timezone", "Asia/Shanghai")))
event_path = resolve_project_path(project_root, str(config.get("event_sink", {}).get("path", "logs/events.jsonl"))) event_path = resolve_project_path(project_root, str(config.get("event_sink", {}).get("path", "logs/events.jsonl")))
case_path = case_sink_path(project_root, config)
webhook_delivery_path = resolve_project_path(
project_root,
str(config.get("webhook_delivery_sink", {}).get("path", "logs/webhook_delivery.jsonl")),
)
diagnostics_path = resolve_project_path(project_root, str(runtime.get("diagnostics_path", "logs/runtime_diagnostics.jsonl"))) diagnostics_path = resolve_project_path(project_root, str(runtime.get("diagnostics_path", "logs/runtime_diagnostics.jsonl")))
sample_interval_seconds = max(0.1, float(runtime.get("sample_interval_seconds", 5.0))) sample_interval_seconds = max(0.1, float(runtime.get("sample_interval_seconds", 5.0)))
frame_width = max(64, int(runtime.get("frame_width", 640))) frame_width = max(64, int(runtime.get("frame_width", 640)))
@@ -66,6 +73,7 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
vision_settings = load_runtime_vision_settings(config) vision_settings = load_runtime_vision_settings(config)
detector = ZoneOccupancyDetector(regions, trash_region, vision_settings) detector = ZoneOccupancyDetector(regions, trash_region, vision_settings)
engine = BatchEngine(settings) engine = BatchEngine(settings)
case_store = load_case_store(case_path)
baseline_seed, active_zone_counts = restore_runtime_state(diagnostics_path, config) baseline_seed, active_zone_counts = restore_runtime_state(diagnostics_path, config)
if baseline_seed: if baseline_seed:
detector.seed_baseline(baseline_seed) detector.seed_baseline(baseline_seed)
@@ -74,10 +82,13 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
engine.restore_from_events(load_jsonl_tail(event_path, 2000), active_zone_counts=active_zone_counts) engine.restore_from_events(load_jsonl_tail(event_path, 2000), active_zone_counts=active_zone_counts)
event_path.parent.mkdir(parents=True, exist_ok=True) event_path.parent.mkdir(parents=True, exist_ok=True)
case_path.parent.mkdir(parents=True, exist_ok=True)
webhook_delivery_path.parent.mkdir(parents=True, exist_ok=True)
diagnostics_path.parent.mkdir(parents=True, exist_ok=True) diagnostics_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Cold Display Guard runtime started") print(f"Cold Display Guard runtime started")
print(f"Config: {resolved_config}") print(f"Config: {resolved_config}")
print(f"Events: {event_path}") print(f"Events: {event_path}")
print(f"Cases: {case_path}")
print(f"Diagnostics: {diagnostics_path}") print(f"Diagnostics: {diagnostics_path}")
iteration = 0 iteration = 0
@@ -90,6 +101,8 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
observation = Observation(ts=when, zone_counts=zone_counts, trash_deposit_count=trash_deposit_count) observation = Observation(ts=when, zone_counts=zone_counts, trash_deposit_count=trash_deposit_count)
events = engine.process(observation) events = engine.process(observation)
append_jsonl(event_path, events) append_jsonl(event_path, events)
case_snapshots = persist_case_updates(case_store, case_path, events)
deliver_runtime_webhooks(events, case_snapshots, config, webhook_delivery_path)
append_jsonl( append_jsonl(
diagnostics_path, diagnostics_path,
[ [
@@ -122,6 +135,11 @@ def resolve_project_path(project_root: Path, raw_path: str) -> Path:
return path.resolve() return path.resolve()
def case_sink_path(project_root: Path, config: dict) -> Path:
raw_path = str(config.get("case_sink", {}).get("path", "logs/cases.jsonl"))
return resolve_project_path(project_root, raw_path)
def append_jsonl(path: Path, payloads: list[dict]) -> None: def append_jsonl(path: Path, payloads: list[dict]) -> None:
if not payloads: if not payloads:
return return
@@ -131,6 +149,28 @@ def append_jsonl(path: Path, payloads: list[dict]) -> None:
handle.write("\n") handle.write("\n")
def load_case_store(path: Path) -> CaseStore:
return CaseStore(load_case_snapshots(path))
def persist_case_updates(case_store: CaseStore, path: Path, events: list[dict[str, object]]) -> list[dict[str, object]]:
snapshots = case_store.apply_batch_events(events)
append_case_snapshots(path, snapshots)
return snapshots
def deliver_runtime_webhooks(
events: list[dict[str, object]],
case_snapshots: list[dict[str, object]],
config: dict[str, object],
audit_path: Path,
*,
http_post=None,
) -> None:
send_batch_event_webhooks(events, config, audit_path, http_post=http_post)
send_case_webhooks(case_snapshots, config, audit_path, http_post=http_post)
def restore_runtime_state(diagnostics_path: Path, config: dict) -> tuple[dict[str, RegionMetrics], dict[str, int]]: def restore_runtime_state(diagnostics_path: Path, config: dict) -> tuple[dict[str, RegionMetrics], dict[str, int]]:
latest = load_jsonl_tail(diagnostics_path, 1) latest = load_jsonl_tail(diagnostics_path, 1)
if not latest: if not latest:

View File

@@ -11,6 +11,7 @@ from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from cold_display_guard.cases import CaseStore, append_case_snapshots, load_case_snapshots
from cold_display_guard.config import ( from cold_display_guard.config import (
load_config_document, load_config_document,
merge_calibration, merge_calibration,
@@ -19,6 +20,7 @@ from cold_display_guard.config import (
save_config_document, save_config_document,
) )
from cold_display_guard.vision import load_runtime_vision_settings, metrics_indicate_occupied from cold_display_guard.vision import load_runtime_vision_settings, metrics_indicate_occupied
from cold_display_guard.webhooks import send_case_webhooks
PROJECT_TYPE = "cold_display_guard" PROJECT_TYPE = "cold_display_guard"
@@ -66,6 +68,15 @@ 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/cases":
query = parse_qs(parsed.query)
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
status = str(query.get("status", [""])[0]).strip().lower()
self._send_json({"items": load_cases(ctx, limit=limit, status=status), "limit": limit})
return
if parsed.path == "/api/manage/cases/summary":
self._send_json(build_case_summary(ctx))
return
if parsed.path == "/api/manage/diagnostics": if parsed.path == "/api/manage/diagnostics":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
limit = bounded_int(query.get("limit", ["50"])[0], 1, MAX_EVENT_LINES) limit = bounded_int(query.get("limit", ["50"])[0], 1, MAX_EVENT_LINES)
@@ -88,6 +99,13 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
if parsed.path == "/api/manage/snapshot": if parsed.path == "/api/manage/snapshot":
self._capture_snapshot() self._capture_snapshot()
return return
if parsed.path.startswith("/api/manage/cases/") and parsed.path.endswith("/handle"):
case_id = parsed.path.removeprefix("/api/manage/cases/").removesuffix("/handle").strip("/")
self._handle_case(case_id)
return
if parsed.path == "/api/manage/webhooks/case-update":
self._handle_case_callback()
return
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
def log_message(self, format: str, *args: object) -> None: def log_message(self, format: str, *args: object) -> None:
@@ -154,6 +172,54 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
self.end_headers() self.end_headers()
self.wfile.write(image) self.wfile.write(image)
def _handle_case(self, case_id: str) -> None:
payload = self._read_json()
handled_by = str(payload.get("handled_by", "")).strip()
if not case_id:
self._send_json({"error": "case_id is required"}, HTTPStatus.BAD_REQUEST)
return
if not handled_by:
self._send_json({"error": "handled_by is required"}, HTTPStatus.BAD_REQUEST)
return
snapshot = handle_case_update(
ctx,
case_id,
handled_by=handled_by,
handled_source="manual",
note=str(payload.get("note", "")).strip(),
)
if snapshot is None:
self._send_json({"error": "case not found"}, HTTPStatus.NOT_FOUND)
return
self._send_json(snapshot)
def _handle_case_callback(self) -> None:
payload = self._read_json()
config = load_config_document(ctx.config_path)
token = str(config.get("webhooks", {}).get("callback_token", ""))
if not token or self.headers.get("X-Webhook-Token") != token:
self._send_json({"error": "forbidden"}, HTTPStatus.FORBIDDEN)
return
case_id = str(payload.get("case_id", "")).strip()
status = str(payload.get("status", "")).strip().lower()
if not case_id:
self._send_json({"error": "case_id is required"}, HTTPStatus.BAD_REQUEST)
return
if status != "handled":
self._send_json({"error": "status must be handled"}, HTTPStatus.BAD_REQUEST)
return
snapshot = handle_case_update(
ctx,
case_id,
handled_by=str(payload.get("handled_by", "")).strip(),
handled_source="webhook_callback",
source_ref=str(payload.get("source_ref", "")).strip(),
)
if snapshot is None:
self._send_json({"error": "case not found"}, HTTPStatus.NOT_FOUND)
return
self._send_json(snapshot)
def _read_json(self) -> dict[str, Any]: def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
if length == 0: if length == 0:
@@ -229,6 +295,9 @@ def main() -> int:
def config_payload(ctx: ManageContext) -> dict[str, Any]: def config_payload(ctx: ManageContext) -> dict[str, Any]:
data = load_config_document(ctx.config_path) data = load_config_document(ctx.config_path)
event_path = event_sink_path(ctx, data) event_path = event_sink_path(ctx, data)
case_path = case_sink_path(ctx, data)
webhooks = dict(data.get("webhooks", {}) or {})
webhooks.pop("callback_token", None)
return { return {
"project_type": PROJECT_TYPE, "project_type": PROJECT_TYPE,
"config_path": str(ctx.config_path), "config_path": str(ctx.config_path),
@@ -243,6 +312,8 @@ def config_payload(ctx: ManageContext) -> dict[str, Any]:
"zones": data.get("zones", []), "zones": data.get("zones", []),
"trash": data.get("trash", {}), "trash": data.get("trash", {}),
"event_sink": {"path": str(event_path)}, "event_sink": {"path": str(event_path)},
"case_sink": {"path": str(case_path)},
"webhooks": webhooks,
} }
@@ -307,6 +378,40 @@ def load_events(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
return load_jsonl_tail(path, limit) return load_jsonl_tail(path, limit)
def load_cases(ctx: ManageContext, limit: int, status: str = "") -> list[dict[str, Any]]:
store = CaseStore(load_case_snapshots(case_sink_path(ctx)))
cases = store.latest_cases()
if status:
cases = [item for item in cases if str(item.get("case_status", "")).lower() == status]
return cases[:limit]
def build_case_summary(ctx: ManageContext) -> dict[str, Any]:
cases = load_cases(ctx, limit=MAX_EVENT_LINES)
summary = {
"open_case_count": 0,
"handled_case_count": 0,
"time_alarm_case_count": 0,
"pending_disposal_case_count": 0,
"warning_escalated_case_count": 0,
"latest_case_update_time": "",
}
for case in cases:
status = str(case.get("case_status", ""))
case_type = str(case.get("case_type", ""))
if status == "open":
summary["open_case_count"] += 1
elif status == "handled":
summary["handled_case_count"] += 1
key = f"{case_type}_case_count"
if key in summary:
summary[key] += 1
updated_at = str(case.get("updated_at", ""))
if updated_at and updated_at > str(summary["latest_case_update_time"]):
summary["latest_case_update_time"] = updated_at
return summary
def load_diagnostics(ctx: ManageContext, limit: int) -> list[dict[str, Any]]: def load_diagnostics(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
path = diagnostics_path(ctx) path = diagnostics_path(ctx)
return load_jsonl_tail(path, limit) return load_jsonl_tail(path, limit)
@@ -335,6 +440,26 @@ def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> P
return path.resolve() return path.resolve()
def case_sink_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("case_sink", {}).get("path", "logs/cases.jsonl"))
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = ctx.project_root / path
return path.resolve()
def webhook_delivery_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("webhook_delivery_sink", {}).get("path", "logs/webhook_delivery.jsonl"))
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = ctx.project_root / path
return path.resolve()
def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path: def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
if data is None: if data is None:
data = load_config_document(ctx.config_path) data = load_config_document(ctx.config_path)
@@ -345,6 +470,34 @@ def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) ->
return path.resolve() return path.resolve()
def handle_case_update(
ctx: ManageContext,
case_id: str,
*,
handled_by: str,
handled_source: str,
note: str = "",
source_ref: str = "",
) -> dict[str, Any] | None:
config = load_config_document(ctx.config_path)
path = case_sink_path(ctx, config)
store = CaseStore(load_case_snapshots(path))
matching = {item["case_id"] for item in store.latest_cases()}
if case_id not in matching:
return None
snapshot = store.mark_handled(
case_id,
handled_at=datetime.now(timezone.utc),
handled_by=handled_by,
handled_source=handled_source,
note=note,
source_ref=source_ref,
)
append_case_snapshots(path, [snapshot])
send_case_webhooks([snapshot], config, webhook_delivery_path(ctx, config))
return snapshot
def latest_zone_counts(diagnostics: list[dict[str, Any]], config: dict[str, Any] | None = None) -> dict[str, int]: def latest_zone_counts(diagnostics: list[dict[str, Any]], config: dict[str, Any] | None = None) -> dict[str, int]:
for item in reversed(diagnostics): for item in reversed(diagnostics):
stable_counts = stable_zone_counts_from_diagnostics(item) stable_counts = stable_zone_counts_from_diagnostics(item)

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from urllib import request
@dataclass(frozen=True, slots=True)
class WebhookSettings:
enabled: bool = False
event_url: str = ""
case_url: str = ""
callback_token: str = ""
connect_timeout_seconds: float = 3.0
read_timeout_seconds: float = 5.0
HttpPost = Callable[[str, dict[str, object], tuple[float, float]], tuple[int, str]]
def load_webhook_settings(config: dict[str, Any]) -> WebhookSettings:
payload = config.get("webhooks", {})
if not isinstance(payload, dict):
payload = {}
return WebhookSettings(
enabled=bool(payload.get("enabled", False)),
event_url=str(payload.get("event_url", "")),
case_url=str(payload.get("case_url", "")),
callback_token=str(payload.get("callback_token", "")),
connect_timeout_seconds=float(payload.get("connect_timeout_seconds", 3.0)),
read_timeout_seconds=float(payload.get("read_timeout_seconds", 5.0)),
)
def build_batch_event_payload(event: dict[str, object]) -> dict[str, object]:
return {
"kind": "batch_event",
"event": event.get("event", ""),
"ts": event.get("ts", ""),
"batch_id": event.get("batch_id", ""),
"camera_id": event.get("camera_id", ""),
"zone_id": event.get("zone_id", ""),
"zone_label": event.get("zone_label", ""),
"severity": event.get("severity", ""),
"state": event.get("state", ""),
}
def build_case_event_payload(snapshot: dict[str, object]) -> dict[str, object]:
return {
"kind": "case_event",
"action": infer_case_action(snapshot),
"case_id": snapshot.get("case_id", ""),
"case_type": snapshot.get("case_type", ""),
"case_status": snapshot.get("case_status", ""),
"batch_id": snapshot.get("batch_id", ""),
"source_event": snapshot.get("source_event", ""),
"handled_source": snapshot.get("handled_source", ""),
"updated_at": snapshot.get("updated_at", ""),
}
def infer_case_action(snapshot: dict[str, object]) -> str:
if str(snapshot.get("case_status", "")) == "handled":
return "handled"
created_at = str(snapshot.get("created_at", ""))
updated_at = str(snapshot.get("updated_at", ""))
return "created" if created_at and created_at == updated_at else "updated"
def send_batch_event_webhooks(
events: list[dict[str, object]],
config: dict[str, Any],
audit_path: Path,
*,
http_post: HttpPost | None = None,
) -> list[dict[str, object]]:
settings = load_webhook_settings(config)
if not settings.enabled or not settings.event_url:
return []
deliveries: list[dict[str, object]] = []
for event in events:
payload = build_batch_event_payload(event)
deliveries.append(
deliver_webhook(
settings.event_url,
payload,
audit_path,
target="batch_event",
settings=settings,
http_post=http_post,
)
)
return deliveries
def send_case_webhooks(
snapshots: list[dict[str, object]],
config: dict[str, Any],
audit_path: Path,
*,
http_post: HttpPost | None = None,
) -> list[dict[str, object]]:
settings = load_webhook_settings(config)
if not settings.enabled or not settings.case_url:
return []
deliveries: list[dict[str, object]] = []
for snapshot in snapshots:
payload = build_case_event_payload(snapshot)
deliveries.append(
deliver_webhook(
settings.case_url,
payload,
audit_path,
target="case_event",
settings=settings,
http_post=http_post,
)
)
return deliveries
def deliver_webhook(
url: str,
payload: dict[str, object],
audit_path: Path,
*,
target: str,
settings: WebhookSettings,
http_post: HttpPost | None = None,
) -> dict[str, object]:
post = http_post or post_json
timeout = (settings.connect_timeout_seconds, settings.read_timeout_seconds)
try:
status_code, response_text = post(url, payload, timeout)
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"target": target,
"url": url,
"status": "ok",
"status_code": status_code,
"message": response_text,
}
except OSError as exc:
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"target": target,
"url": url,
"status": "error",
"message": str(exc),
}
append_delivery_record(audit_path, record)
return record
def post_json(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
data = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
req = request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with request.urlopen(req, timeout=sum(timeout)) as response:
return response.getcode(), response.read().decode("utf-8")
def append_delivery_record(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True))
handle.write("\n")

View File

@@ -1,10 +1,51 @@
# Task Todo # Task Todo
- [x] Review the current `AGENTS.md` structure and check for existing `tasks/` tracking files. - [x] Review the current project instructions and check for task-relevant lessons.
- [x] Check in on the plan before implementation and keep this change scoped to the requested documentation update. - [x] Check repository status before writing the implementation plan.
- [x] Merge the requested workflow orchestration, task management, and execution principles into `AGENTS.md`. - [x] Inspect existing engine, CLI, docs, and frontend event handling for disposal-tracking impact.
- [x] Verify the updated `AGENTS.md` content against the requested rules and record the review result here. - [x] Write the design spec for webhook case management in an isolated worktree.
- [x] Confirm the design with the user before implementation.
## Review ## Design Review
- Verified by re-reading `AGENTS.md` and checking the inserted sections for plan-first workflow, `tasks/todo.md` tracking, `tasks/lessons.md` review/update rules, subagent guidance, verification requirements, and simplicity/minimal-impact principles. - Spec path: `docs/superpowers/specs/2026-06-09-webhook-case-management-design.md`
- Scope fixed to local case management plus outbound and inbound webhook integration.
- Confirmed behaviors:
- manual handling and external callback handling are both supported
- cases are created from `time_alarm`, `batch_pending_disposal`, and `warning_escalated`
- both batch-event webhooks and case-state webhooks are required
- callback `status` is exactly `handled`
- callback-applied case handling must emit a `case_event` webhook
## 2026-06-09 Implementation Plan
- [x] Create isolated worktree for implementation on branch `feat/webhook-case-management`.
- [x] Re-check runtime baseline in the worktree and note the local Python environment requirement.
- [x] Write the detailed implementation plan to `docs/superpowers/plans/2026-06-09-webhook-case-management-implementation.md`.
- [x] Execute backend case-state TDD cycle.
- [x] Execute webhook integration TDD cycle.
- [x] Execute management API TDD cycle.
- [x] Execute frontend case-management TDD cycle.
- [x] Run full verification and record outcomes.
## 2026-06-09 Implementation Review
- Worktree path: `/Users/glo/.config/superpowers/worktrees/cold_display_guard/webhook-case-management`
- Baseline note: the default `python3` in this shell resolves to macOS system Python 3.9 and cannot import the repo's `dataclass(..., slots=True)` code. Python verification in this worktree must run through `eval "$(/opt/homebrew/bin/pyenv init -)" && python ...`, which resolves to Python 3.12.11.
- Frontend baseline check in the worktree passed with `node --test web/test/zone-state.test.js`.
- Implemented:
- `src/cold_display_guard/cases.py` for case lifecycle and JSONL persistence
- `src/cold_display_guard/webhooks.py` for outbound event/case webhook delivery and audit logging
- runtime integration in `src/cold_display_guard/main.py`
- case listing/summary/manual-handle/callback routes in `src/cold_display_guard/manage_api.py`
- frontend case summary and manual-handle flow in `web/src/main.js` and `web/src/zone-state.js`
- Targeted verification passed during implementation:
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_cases.py -v`
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
- `node --test web/test/zone-state.test.js`
- Final verification passed:
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest discover -s tests -v`
- `cd web && pnpm build`
- Frontend build note: the isolated worktree needed `cd web && pnpm install --frozen-lockfile` before `pnpm build` because `node_modules` are not shared into new worktrees.

158
tests/test_cases.py Normal file
View File

@@ -0,0 +1,158 @@
from __future__ import annotations
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from cold_display_guard.cases import CaseStore, append_case_snapshots, load_case_snapshots
UTC = timezone.utc
def event(
event_name: str,
when: datetime,
*,
batch_id: str = "batch_000001",
zone_id: str = "1",
zone_label: str = "区域 1",
camera_id: str = "cam_01",
severity: str = "info",
state: str = "active",
) -> dict[str, object]:
return {
"event": event_name,
"ts": when.isoformat(),
"batch_id": batch_id,
"camera_id": camera_id,
"zone_id": zone_id,
"zone_label": zone_label,
"severity": severity,
"state": state,
}
class CaseStoreTests(unittest.TestCase):
def setUp(self) -> None:
self.t0 = datetime(2026, 6, 9, 9, 0, tzinfo=UTC)
def test_time_alarm_creates_open_case(self) -> None:
store = CaseStore()
snapshots = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
self.assertEqual(len(snapshots), 1)
self.assertEqual(snapshots[0]["case_type"], "time_alarm")
self.assertEqual(snapshots[0]["case_status"], "open")
self.assertEqual(snapshots[0]["source_event"], "time_alarm")
def test_pending_disposal_upgrades_existing_case(self) -> None:
store = CaseStore()
store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
snapshots = store.apply_batch_events(
[event("batch_pending_disposal", self.t0.replace(minute=1), severity="warning", state="pending_disposal")]
)
self.assertEqual(len(snapshots), 1)
self.assertEqual(snapshots[0]["case_type"], "pending_disposal")
self.assertEqual(snapshots[0]["case_status"], "open")
self.assertEqual(snapshots[0]["source_event"], "batch_pending_disposal")
def test_warning_escalated_upgrades_same_case(self) -> None:
store = CaseStore()
store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
store.apply_batch_events(
[event("batch_pending_disposal", self.t0.replace(minute=1), severity="warning", state="pending_disposal")]
)
snapshots = store.apply_batch_events(
[event("warning_escalated", self.t0.replace(minute=2), severity="warning", state="warning")]
)
self.assertEqual(len(snapshots), 1)
self.assertEqual(snapshots[0]["case_type"], "warning_escalated")
self.assertEqual(snapshots[0]["case_status"], "open")
self.assertEqual(snapshots[0]["source_event"], "warning_escalated")
def test_batch_discarded_auto_closes_open_case(self) -> None:
store = CaseStore()
store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
snapshots = store.apply_batch_events(
[event("batch_discarded", self.t0.replace(minute=3), severity="info", state="discarded")]
)
self.assertEqual(len(snapshots), 1)
self.assertEqual(snapshots[0]["case_status"], "handled")
self.assertEqual(snapshots[0]["handled_source"], "auto_closed")
def test_manual_handle_closes_case(self) -> None:
store = CaseStore()
created = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])[0]
snapshot = store.mark_handled(
str(created["case_id"]),
handled_at=self.t0.replace(minute=4),
handled_by="alice",
handled_source="manual",
note="checked",
)
self.assertEqual(snapshot["case_status"], "handled")
self.assertEqual(snapshot["handled_source"], "manual")
self.assertEqual(snapshot["handled_by"], "alice")
self.assertEqual(snapshot["payload"]["note"], "checked")
def test_callback_handle_closes_case(self) -> None:
store = CaseStore()
created = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])[0]
snapshot = store.mark_handled(
str(created["case_id"]),
handled_at=self.t0.replace(minute=5),
handled_by="crm-bot",
handled_source="webhook_callback",
source_ref="crm-123",
)
self.assertEqual(snapshot["case_status"], "handled")
self.assertEqual(snapshot["handled_source"], "webhook_callback")
self.assertEqual(snapshot["payload"]["source_ref"], "crm-123")
def test_handled_case_does_not_reopen_on_stale_event(self) -> None:
store = CaseStore()
created = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])[0]
store.mark_handled(
str(created["case_id"]),
handled_at=self.t0.replace(minute=5),
handled_by="alice",
handled_source="manual",
)
snapshots = store.apply_batch_events(
[event("batch_pending_disposal", self.t0.replace(minute=1), severity="warning", state="pending_disposal")]
)
self.assertEqual(snapshots, [])
case = store.latest_cases()[0]
self.assertEqual(case["case_status"], "handled")
self.assertEqual(case["handled_source"], "manual")
def test_case_snapshots_round_trip_through_jsonl(self) -> None:
store = CaseStore()
snapshots = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "cases.jsonl"
append_case_snapshots(path, snapshots)
loaded = load_case_snapshots(path)
self.assertEqual(len(loaded), 1)
self.assertEqual(loaded[0]["case_type"], "time_alarm")
if __name__ == "__main__":
unittest.main()

View File

@@ -122,6 +122,32 @@ zone_ids = ["1", "2", "3"]
self.assertIn("[trash]", text) self.assertIn("[trash]", text)
self.assertNotIn('"trash"', text.split("[layout]", maxsplit=1)[1].split("[[zones]]", maxsplit=1)[0]) self.assertNotIn('"trash"', text.split("[layout]", maxsplit=1)[1].split("[[zones]]", maxsplit=1)[0])
def test_save_config_document_writes_webhooks_and_case_sink(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "config.toml"
save_config_document(
path,
{
"webhooks": {
"enabled": True,
"event_url": "https://example.com/events",
"case_url": "https://example.com/cases",
"callback_token": "secret",
"connect_timeout_seconds": 3,
"read_timeout_seconds": 5,
},
"case_sink": {"path": "logs/cases.jsonl"},
},
)
text = path.read_text(encoding="utf-8")
self.assertIn("[webhooks]", text)
self.assertIn('event_url = "https://example.com/events"', text)
self.assertIn('case_url = "https://example.com/cases"', text)
self.assertIn('callback_token = "secret"', text)
self.assertIn("[case_sink]", text)
self.assertIn('path = "logs/cases.jsonl"', text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -3,12 +3,102 @@ from __future__ import annotations
import json import json
import tempfile import tempfile
import unittest import unittest
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from cold_display_guard.main import restore_runtime_state from cold_display_guard.cases import CaseStore
from cold_display_guard.main import case_sink_path, deliver_runtime_webhooks, persist_case_updates, restore_runtime_state
UTC = timezone.utc
class RuntimeRestoreTests(unittest.TestCase): class RuntimeRestoreTests(unittest.TestCase):
def test_case_sink_path_uses_default_logs_location(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
path = case_sink_path(root, {})
self.assertEqual(path, (root / "logs" / "cases.jsonl").resolve())
def test_persist_case_updates_writes_case_snapshots(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "cases.jsonl"
store = CaseStore()
snapshots = persist_case_updates(
store,
path,
[
{
"event": "time_alarm",
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"severity": "alarm",
"state": "alerted",
}
],
)
written = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(len(snapshots), 1)
self.assertEqual(written[0]["case_type"], "time_alarm")
self.assertEqual(written[0]["case_status"], "open")
def test_deliver_runtime_webhooks_sends_event_and_case_payloads(self) -> None:
deliveries: list[tuple[str, dict[str, object]]] = []
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
deliveries.append((url, payload))
return 200, "ok"
with tempfile.TemporaryDirectory() as tmpdir:
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
deliver_runtime_webhooks(
[
{
"event": "time_alarm",
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"severity": "alarm",
"state": "alerted",
}
],
[
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"handled_source": "",
"created_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
"updated_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
}
],
{
"webhooks": {
"enabled": True,
"event_url": "https://example.com/events",
"case_url": "https://example.com/cases",
}
},
audit_path,
http_post=fake_post,
)
self.assertEqual(len(deliveries), 2)
self.assertEqual(deliveries[0][1]["kind"], "batch_event")
self.assertEqual(deliveries[1][1]["kind"], "case_event")
def test_restore_runtime_state_uses_stable_occupancy_when_raw_metrics_flicker(self) -> None: def test_restore_runtime_state_uses_stable_occupancy_when_raw_metrics_flicker(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl" diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl"

View File

@@ -1,15 +1,47 @@
from __future__ import annotations from __future__ import annotations
import http.client
import json import json
import tempfile import tempfile
import threading
import unittest import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from cold_display_guard.config import load_config_document, merge_calibration, save_config_document from cold_display_guard.config import load_config_document, merge_calibration, save_config_document
from cold_display_guard.manage_api import ManageContext, build_summary from cold_display_guard.manage_api import ManageContext, build_summary, config_payload, create_handler
class ManageApiTests(unittest.TestCase): class ManageApiTests(unittest.TestCase):
def _serve_once(self, ctx: ManageContext) -> tuple[ThreadingHTTPServer, threading.Thread]:
server = ThreadingHTTPServer(("127.0.0.1", 0), create_handler(ctx))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread
def _request(
self,
server: ThreadingHTTPServer,
method: str,
path: str,
body: dict | None = None,
headers: dict[str, str] | None = None,
) -> tuple[int, dict]:
conn = http.client.HTTPConnection("127.0.0.1", server.server_address[1], timeout=5)
payload = None if body is None else json.dumps(body)
final_headers = {"Content-Type": "application/json"}
final_headers.update(headers or {})
conn.request(method, path, body=payload, headers=final_headers)
response = conn.getresponse()
raw = response.read().decode("utf-8")
conn.close()
return response.status, json.loads(raw or "{}")
def _stop_server(self, server: ThreadingHTTPServer, thread: threading.Thread) -> None:
server.shutdown()
thread.join()
server.server_close()
def test_merge_calibration_updates_zones_and_trash(self) -> None: def test_merge_calibration_updates_zones_and_trash(self) -> None:
data = { data = {
"camera_id": "cam", "camera_id": "cam",
@@ -350,6 +382,242 @@ class ManageApiTests(unittest.TestCase):
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 1, "2": 0}) self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 1, "2": 0})
def test_config_payload_exposes_case_sink_and_webhooks_without_callback_token(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"webhooks": {
"enabled": True,
"event_url": "https://example.com/events",
"case_url": "https://example.com/cases",
"callback_token": "secret",
},
},
)
payload = config_payload(ManageContext(config_path=config_path, project_root=root))
self.assertEqual(payload["case_sink"]["path"], str((root / "logs" / "cases.jsonl").resolve()))
self.assertTrue(payload["webhooks"]["enabled"])
self.assertNotIn("callback_token", payload["webhooks"])
def test_cases_endpoint_returns_latest_snapshots(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"layout": {"zone_ids": ["1"]},
},
)
cases_path = root / "logs" / "cases.jsonl"
cases_path.parent.mkdir()
cases_path.write_text(
json.dumps(
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"created_at": "2026-06-09T09:00:00+08:00",
"updated_at": "2026-06-09T09:00:00+08:00",
"payload": {},
}
),
encoding="utf-8",
)
ctx = ManageContext(config_path=config_path, project_root=root)
server, thread = self._serve_once(ctx)
try:
status, payload = self._request(server, "GET", "/api/manage/cases?status=open")
finally:
self._stop_server(server, thread)
self.assertEqual(status, 200)
self.assertEqual(len(payload["items"]), 1)
self.assertEqual(payload["items"][0]["case_id"], "case_batch_000001")
def test_case_summary_endpoint_counts_open_and_handled(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"layout": {"zone_ids": ["1"]},
},
)
cases_path = root / "logs" / "cases.jsonl"
cases_path.parent.mkdir()
cases_path.write_text(
"\n".join(
[
json.dumps(
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"created_at": "2026-06-09T09:00:00+08:00",
"updated_at": "2026-06-09T09:00:00+08:00",
"payload": {},
}
),
json.dumps(
{
"case_id": "case_batch_000002",
"batch_id": "batch_000002",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "warning_escalated",
"case_status": "handled",
"source_event": "warning_escalated",
"created_at": "2026-06-09T09:01:00+08:00",
"updated_at": "2026-06-09T09:05:00+08:00",
"handled_source": "manual",
"payload": {},
}
),
]
),
encoding="utf-8",
)
ctx = ManageContext(config_path=config_path, project_root=root)
server, thread = self._serve_once(ctx)
try:
status, payload = self._request(server, "GET", "/api/manage/cases/summary")
finally:
self._stop_server(server, thread)
self.assertEqual(status, 200)
self.assertEqual(payload["open_case_count"], 1)
self.assertEqual(payload["handled_case_count"], 1)
self.assertEqual(payload["warning_escalated_case_count"], 1)
def test_manual_handle_endpoint_appends_handled_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"layout": {"zone_ids": ["1"]},
},
)
cases_path = root / "logs" / "cases.jsonl"
cases_path.parent.mkdir()
cases_path.write_text(
json.dumps(
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"created_at": "2026-06-09T09:00:00+08:00",
"updated_at": "2026-06-09T09:00:00+08:00",
"payload": {},
}
),
encoding="utf-8",
)
ctx = ManageContext(config_path=config_path, project_root=root)
server, thread = self._serve_once(ctx)
try:
status, payload = self._request(
server,
"POST",
"/api/manage/cases/case_batch_000001/handle",
body={"handled_by": "alice", "note": "checked"},
)
finally:
self._stop_server(server, thread)
lines = [json.loads(line) for line in cases_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(status, 200)
self.assertEqual(payload["case_status"], "handled")
self.assertEqual(lines[-1]["handled_source"], "manual")
self.assertEqual(lines[-1]["payload"]["note"], "checked")
def test_callback_endpoint_requires_token_and_handles_case(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"webhooks": {"callback_token": "secret"},
"layout": {"zone_ids": ["1"]},
},
)
cases_path = root / "logs" / "cases.jsonl"
cases_path.parent.mkdir()
cases_path.write_text(
json.dumps(
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"created_at": "2026-06-09T09:00:00+08:00",
"updated_at": "2026-06-09T09:00:00+08:00",
"payload": {},
}
),
encoding="utf-8",
)
ctx = ManageContext(config_path=config_path, project_root=root)
server, thread = self._serve_once(ctx)
try:
unauthorized_status, _ = self._request(
server,
"POST",
"/api/manage/webhooks/case-update",
body={"case_id": "case_batch_000001", "status": "handled"},
)
status, payload = self._request(
server,
"POST",
"/api/manage/webhooks/case-update",
body={"case_id": "case_batch_000001", "status": "handled", "handled_by": "crm-bot"},
headers={"X-Webhook-Token": "secret"},
)
finally:
self._stop_server(server, thread)
lines = [json.loads(line) for line in cases_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(unauthorized_status, 403)
self.assertEqual(status, 200)
self.assertEqual(payload["handled_source"], "webhook_callback")
self.assertEqual(lines[-1]["handled_source"], "webhook_callback")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

187
tests/test_webhooks.py Normal file
View File

@@ -0,0 +1,187 @@
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from cold_display_guard.webhooks import (
build_batch_event_payload,
build_case_event_payload,
load_webhook_settings,
send_batch_event_webhooks,
send_case_webhooks,
)
UTC = timezone.utc
class WebhookTests(unittest.TestCase):
def test_load_webhook_settings_from_config(self) -> None:
settings = load_webhook_settings(
{
"webhooks": {
"enabled": True,
"event_url": "https://example.com/events",
"case_url": "https://example.com/cases",
"callback_token": "secret",
"connect_timeout_seconds": 4,
"read_timeout_seconds": 6,
}
}
)
self.assertTrue(settings.enabled)
self.assertEqual(settings.event_url, "https://example.com/events")
self.assertEqual(settings.case_url, "https://example.com/cases")
self.assertEqual(settings.callback_token, "secret")
self.assertEqual(settings.connect_timeout_seconds, 4)
self.assertEqual(settings.read_timeout_seconds, 6)
def test_build_batch_event_payload_wraps_runtime_event(self) -> None:
payload = build_batch_event_payload(
{
"event": "time_alarm",
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"severity": "alarm",
"state": "alerted",
}
)
self.assertEqual(payload["kind"], "batch_event")
self.assertEqual(payload["event"], "time_alarm")
self.assertEqual(payload["zone_label"], "区域 1")
def test_build_case_event_payload_wraps_case_snapshot(self) -> None:
payload = build_case_event_payload(
{
"case_id": "case_batch_000001",
"case_type": "warning_escalated",
"case_status": "open",
"batch_id": "batch_000001",
"source_event": "warning_escalated",
"handled_source": "",
"updated_at": datetime(2026, 6, 9, 9, 5, tzinfo=UTC).isoformat(),
}
)
self.assertEqual(payload["kind"], "case_event")
self.assertEqual(payload["action"], "updated")
self.assertEqual(payload["case_id"], "case_batch_000001")
def test_send_batch_event_webhooks_delivers_payload(self) -> None:
deliveries: list[tuple[str, dict[str, object], tuple[float, float]]] = []
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
deliveries.append((url, payload, timeout))
return 202, "ok"
with tempfile.TemporaryDirectory() as tmpdir:
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
send_batch_event_webhooks(
[
{
"event": "time_alarm",
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"severity": "alarm",
"state": "alerted",
}
],
{
"webhooks": {
"enabled": True,
"event_url": "https://example.com/events",
"connect_timeout_seconds": 4,
"read_timeout_seconds": 6,
}
},
audit_path,
http_post=fake_post,
)
self.assertEqual(deliveries[0][0], "https://example.com/events")
self.assertEqual(deliveries[0][1]["kind"], "batch_event")
self.assertEqual(deliveries[0][2], (4.0, 6.0))
def test_send_case_webhooks_delivers_payload(self) -> None:
deliveries: list[tuple[str, dict[str, object]]] = []
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
deliveries.append((url, payload))
return 200, "ok"
with tempfile.TemporaryDirectory() as tmpdir:
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
send_case_webhooks(
[
{
"case_id": "case_batch_000001",
"case_type": "time_alarm",
"case_status": "handled",
"batch_id": "batch_000001",
"source_event": "time_alarm",
"handled_source": "manual",
"updated_at": datetime(2026, 6, 9, 9, 10, tzinfo=UTC).isoformat(),
}
],
{
"webhooks": {
"enabled": True,
"case_url": "https://example.com/cases",
}
},
audit_path,
http_post=fake_post,
)
self.assertEqual(deliveries[0][0], "https://example.com/cases")
self.assertEqual(deliveries[0][1]["kind"], "case_event")
self.assertEqual(deliveries[0][1]["action"], "handled")
def test_failed_delivery_is_logged_without_raising(self) -> None:
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
raise OSError("network down")
with tempfile.TemporaryDirectory() as tmpdir:
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
send_batch_event_webhooks(
[
{
"event": "time_alarm",
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"severity": "alarm",
"state": "alerted",
}
],
{
"webhooks": {
"enabled": True,
"event_url": "https://example.com/events",
}
},
audit_path,
http_post=fake_post,
)
logged = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(logged[0]["status"], "error")
self.assertEqual(logged[0]["target"], "batch_event")
self.assertIn("network down", logged[0]["message"])
if __name__ == "__main__":
unittest.main()

View File

@@ -3,6 +3,8 @@ import {
TRASH_REGION_ID, TRASH_REGION_ID,
alarmMinutesToSeconds, alarmMinutesToSeconds,
buildCalibrationPayload, buildCalibrationPayload,
buildCaseDisplayModel,
buildManualHandlePayload,
buildPolygonMap, buildPolygonMap,
buildRuntimeDisplayModel, buildRuntimeDisplayModel,
clampZoneCount, clampZoneCount,
@@ -31,6 +33,8 @@ const state = {
config: null, config: null,
summary: null, summary: null,
events: [], events: [],
cases: [],
caseSummary: null,
activeTab: "events", activeTab: "events",
activeRegion: "1", activeRegion: "1",
foodZones: defaultFoodZones, foodZones: defaultFoodZones,
@@ -147,6 +151,11 @@ app.innerHTML = `
<div class="panel-title">最近事件</div> <div class="panel-title">最近事件</div>
<div id="eventsTable" class="events-table"></div> <div id="eventsTable" class="events-table"></div>
</section> </section>
<section class="panel case-panel">
<div class="panel-meta">CASE WORKFLOW</div>
<div class="panel-title">处置单</div>
<div id="casesTable" class="events-table"></div>
</section>
</section> </section>
<section id="settingsView" class="view hidden"> <section id="settingsView" class="view hidden">
@@ -216,6 +225,7 @@ const els = {
runtimeProgress: document.querySelector("#runtimeProgress"), runtimeProgress: document.querySelector("#runtimeProgress"),
metrics: document.querySelector("#metrics"), metrics: document.querySelector("#metrics"),
eventsTable: document.querySelector("#eventsTable"), eventsTable: document.querySelector("#eventsTable"),
casesTable: document.querySelector("#casesTable"),
statusPill: document.querySelector("#statusPill"), statusPill: document.querySelector("#statusPill"),
activeRegionBadge: document.querySelector("#activeRegionBadge"), activeRegionBadge: document.querySelector("#activeRegionBadge"),
}; };
@@ -245,6 +255,7 @@ function wireEvents() {
document.querySelector("#clearRegion").addEventListener("click", clearRegion); document.querySelector("#clearRegion").addEventListener("click", clearRegion);
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig); document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
els.canvas.addEventListener("click", addPoint); els.canvas.addEventListener("click", addPoint);
els.casesTable.addEventListener("click", handleCaseTableClick);
window.addEventListener("resize", drawCanvas); window.addEventListener("resize", drawCanvas);
els.foodZoneCount.addEventListener("input", () => updateFoodZoneCount(els.foodZoneCount.value)); els.foodZoneCount.addEventListener("input", () => updateFoodZoneCount(els.foodZoneCount.value));
[els.rtspUrl, els.settingsRtspUrl, els.cameraId, els.timezone, els.maxDwell, els.trashWindow].forEach((input) => { [els.rtspUrl, els.settingsRtspUrl, els.cameraId, els.timezone, els.maxDwell, els.trashWindow].forEach((input) => {
@@ -309,9 +320,11 @@ async function refreshRuntimeDataSilently() {
} }
async function loadRuntimeData() { async function loadRuntimeData() {
const [summaryResult, eventsResult] = await Promise.allSettled([ const [summaryResult, eventsResult, casesResult, caseSummaryResult] = await Promise.allSettled([
apiJson("/api/manage/summary"), apiJson("/api/manage/summary"),
apiJson("/api/manage/events?limit=1000"), apiJson("/api/manage/events?limit=1000"),
apiJson("/api/manage/cases?limit=1000"),
apiJson("/api/manage/cases/summary"),
]); ]);
const errors = []; const errors = [];
if (summaryResult.status === "fulfilled") { if (summaryResult.status === "fulfilled") {
@@ -326,6 +339,18 @@ async function loadRuntimeData() {
state.events = []; state.events = [];
errors.push(`events ${errorMessage(eventsResult.reason)}`); errors.push(`events ${errorMessage(eventsResult.reason)}`);
} }
if (casesResult.status === "fulfilled") {
state.cases = casesResult.value.items || [];
} else {
state.cases = [];
errors.push(`cases ${errorMessage(casesResult.reason)}`);
}
if (caseSummaryResult.status === "fulfilled") {
state.caseSummary = caseSummaryResult.value;
} else {
state.caseSummary = null;
errors.push(`case summary ${errorMessage(caseSummaryResult.reason)}`);
}
state.runtimeDemoReason = errors.length ? errors.join("") : ""; state.runtimeDemoReason = errors.length ? errors.join("") : "";
} }
@@ -515,12 +540,21 @@ function buildRuntimeModel() {
}); });
} }
function buildCaseModel() {
return buildCaseDisplayModel({
summary: state.caseSummary,
cases: state.cases,
});
}
function renderRuntimeSections() { function renderRuntimeSections() {
const runtimeModel = buildRuntimeModel(); const runtimeModel = buildRuntimeModel();
const caseModel = buildCaseModel();
renderRuntimeOverview(runtimeModel); renderRuntimeOverview(runtimeModel);
renderMetrics(runtimeModel); renderMetrics(runtimeModel, caseModel);
renderRuntimeProgress(runtimeModel); renderRuntimeProgress(runtimeModel);
renderEvents(runtimeModel); renderEvents(runtimeModel);
renderCases(caseModel);
} }
function renderRegionList() { function renderRegionList() {
@@ -739,12 +773,13 @@ function renderRuntimeOverview(model) {
`; `;
} }
function renderMetrics(model) { function renderMetrics(model, caseModel) {
const metrics = model.summary?.metrics || {}; const metrics = model.summary?.metrics || {};
const alertCount = metrics.alert_count ?? 0; const alertCount = metrics.alert_count ?? 0;
const warningCount = metrics.warning_count ?? 0; const warningCount = metrics.warning_count ?? 0;
const violationCount = metrics.violation_count ?? 0; const violationCount = metrics.violation_count ?? 0;
const baselineReady = Boolean(metrics.baseline_ready); const baselineReady = Boolean(metrics.baseline_ready);
const caseMetrics = caseModel?.metrics || {};
const metricLabel = (label) => label; const metricLabel = (label) => label;
const cards = [ const cards = [
{label: metricLabel("事件总数"), value: metrics.event_count ?? 0, tone: "neutral"}, {label: metricLabel("事件总数"), value: metrics.event_count ?? 0, tone: "neutral"},
@@ -754,6 +789,11 @@ function renderMetrics(model) {
{label: metricLabel("诊断帧数"), value: metrics.diagnostics_count ?? 0, tone: "neutral"}, {label: metricLabel("诊断帧数"), value: metrics.diagnostics_count ?? 0, tone: "neutral"},
{label: metricLabel("基线状态"), value: baselineReady ? "ready" : "learning", tone: baselineReady ? "good" : "warning"}, {label: metricLabel("基线状态"), value: baselineReady ? "ready" : "learning", tone: baselineReady ? "good" : "warning"},
{label: metricLabel("最新报警"), value: metrics.latest_alert_time || "-", tone: metrics.latest_alert_time ? "danger" : "neutral"}, {label: metricLabel("最新报警"), value: metrics.latest_alert_time || "-", tone: metrics.latest_alert_time ? "danger" : "neutral"},
{label: metricLabel("待处理处置单"), value: caseMetrics.openCaseCount ?? 0, tone: (caseMetrics.openCaseCount ?? 0) > 0 ? "warning" : "good"},
{label: metricLabel("已处理处置单"), value: caseMetrics.handledCaseCount ?? 0, tone: "good"},
{label: metricLabel("超时报警单"), value: caseMetrics.timeAlarmCaseCount ?? 0, tone: "neutral"},
{label: metricLabel("待丢弃确认单"), value: caseMetrics.pendingDisposalCaseCount ?? 0, tone: "neutral"},
{label: metricLabel("升级警告单"), value: caseMetrics.warningEscalatedCaseCount ?? 0, tone: (caseMetrics.warningEscalatedCaseCount ?? 0) > 0 ? "danger" : "good"},
{label: metricLabel("事件文件"), value: metrics.events_path || "-", tone: "path"}, {label: metricLabel("事件文件"), value: metrics.events_path || "-", tone: "path"},
]; ];
const zoneCounts = metrics.latest_zone_counts || {}; const zoneCounts = metrics.latest_zone_counts || {};
@@ -834,6 +874,36 @@ function renderEvents(model) {
`; `;
} }
function renderCases(model) {
if (!model.rows.length) {
els.casesTable.innerHTML = `<div class="empty">还没有处置单数据</div>`;
return;
}
els.casesTable.innerHTML = `
<table>
<thead><tr><th>处置单</th><th>类型</th><th>状态</th><th>区域</th><th>批次</th><th>更新时间</th><th>处理来源</th><th>操作</th></tr></thead>
<tbody>
${model.rows
.map((row) => `
<tr class="event-row ${row.tone}">
<td>${escapeHtml(row.caseId)}</td>
<td>${escapeHtml(row.typeLabel)}</td>
<td><span class="event-severity ${row.tone}">${escapeHtml(row.statusLabel)}</span></td>
<td>${escapeHtml(row.zone_label || "")}</td>
<td>${escapeHtml(row.batch_id || "")}</td>
<td>${escapeHtml(row.updated_at || "")}</td>
<td>${escapeHtml(row.handledSourceLabel || "-")}</td>
<td>${row.case_status === "open"
? `<button type="button" class="secondary-action" data-handle-case="${escapeHtml(row.caseId)}">标记已处理</button>`
: `<span class="event-source real">已完成</span>`}</td>
</tr>
`)
.join("")}
</tbody>
</table>
`;
}
function formatDuration(seconds) { function formatDuration(seconds) {
const value = Number(seconds); const value = Number(seconds);
if (!Number.isFinite(value) || value <= 0) { if (!Number.isFinite(value) || value <= 0) {
@@ -898,6 +968,39 @@ async function apiJson(path, options = {}) {
return payload; return payload;
} }
function handleCaseTableClick(event) {
const button = event.target.closest("[data-handle-case]");
if (!button) {
return;
}
handleCase(button.dataset.handleCase);
}
async function handleCase(caseId) {
const handledBy = window.prompt("请输入处理人");
if (handledBy === null) {
return;
}
const trimmedHandledBy = handledBy.trim();
if (!trimmedHandledBy) {
setStatus("处理人不能为空");
return;
}
const note = window.prompt("请输入处理备注(可选)") || "";
try {
setStatus("正在更新处置单状态...");
await apiJson(`/api/manage/cases/${encodeURIComponent(caseId)}/handle`, {
method: "POST",
body: buildManualHandlePayload(trimmedHandledBy, note),
});
await loadRuntimeData();
renderRuntimeSections();
setStatus("处置单已标记为已处理");
} catch (error) {
setStatus(`更新处置单失败:${error.message}`);
}
}
function setStatus(message) { function setStatus(message) {
state.status = message; state.status = message;
els.statusText.textContent = message; els.statusText.textContent = message;

View File

@@ -158,6 +158,38 @@ export function buildRuntimeDisplayModel({
}; };
} }
export function buildCaseDisplayModel({summary = null, cases = []} = {}) {
const metrics = {
openCaseCount: Number(summary?.open_case_count || 0),
handledCaseCount: Number(summary?.handled_case_count || 0),
timeAlarmCaseCount: Number(summary?.time_alarm_case_count || 0),
pendingDisposalCaseCount: Number(summary?.pending_disposal_case_count || 0),
warningEscalatedCaseCount: Number(summary?.warning_escalated_case_count || 0),
};
const rows = (Array.isArray(cases) ? cases : [])
.map((item) => ({
...item,
caseId: String(item.case_id || ""),
typeLabel: caseTypeLabel(item.case_type),
statusLabel: String(item.case_status || "") === "handled" ? "已处理" : "待处理",
tone: String(item.case_status || "") === "handled" ? "good" : "warning",
handledSourceLabel: caseHandledSourceLabel(item.handled_source),
}))
.sort((left, right) => timestampMillis(right.updated_at) - timestampMillis(left.updated_at));
return {metrics, rows};
}
export function buildManualHandlePayload(handledBy, note = "") {
const payload = {
handled_by: String(handledBy || "").trim(),
};
const trimmedNote = String(note || "").trim();
if (trimmedNote) {
payload.note = trimmedNote;
}
return payload;
}
export function getRegionColor(id) { export function getRegionColor(id) {
if (id === TRASH_REGION_ID) { if (id === TRASH_REGION_ID) {
return "#111827"; return "#111827";
@@ -221,6 +253,32 @@ function containsDemoMarker(value) {
return text.includes("demo") || text.includes("演示"); return text.includes("demo") || text.includes("演示");
} }
function caseTypeLabel(caseType) {
if (caseType === "warning_escalated") {
return "升级警告";
}
if (caseType === "pending_disposal") {
return "待丢弃确认";
}
if (caseType === "time_alarm") {
return "超时报警";
}
return String(caseType || "");
}
function caseHandledSourceLabel(source) {
if (source === "manual") {
return "人工处理";
}
if (source === "webhook_callback") {
return "回调处理";
}
if (source === "auto_closed") {
return "自动关闭";
}
return "";
}
function createEmptyRuntimeSummary(thresholdSeconds) { function createEmptyRuntimeSummary(thresholdSeconds) {
return { return {
result_type: "cold_display_guard", result_type: "cold_display_guard",

View File

@@ -5,6 +5,8 @@ import {
TRASH_REGION_ID, TRASH_REGION_ID,
alarmMinutesToSeconds, alarmMinutesToSeconds,
buildCalibrationPayload, buildCalibrationPayload,
buildCaseDisplayModel,
buildManualHandlePayload,
buildPolygonMap, buildPolygonMap,
buildRuntimeDisplayModel, buildRuntimeDisplayModel,
classifyEvent, classifyEvent,
@@ -628,3 +630,58 @@ test("buildRuntimeDisplayModel uses config threshold when event omits threshold"
source: "real", source: "real",
}]); }]);
}); });
test("buildCaseDisplayModel normalizes case rows and summary metrics", () => {
const model = buildCaseDisplayModel({
summary: {
open_case_count: 1,
handled_case_count: 2,
time_alarm_case_count: 1,
pending_disposal_case_count: 1,
warning_escalated_case_count: 1,
},
cases: [
{
case_id: "case_batch_000001",
case_type: "warning_escalated",
case_status: "open",
zone_label: "区域 1",
batch_id: "batch_000001",
updated_at: "2026-06-09T09:10:00+08:00",
handled_source: "",
},
{
case_id: "case_batch_000002",
case_type: "time_alarm",
case_status: "handled",
zone_label: "区域 2",
batch_id: "batch_000002",
updated_at: "2026-06-09T09:12:00+08:00",
handled_source: "manual",
},
],
});
assert.deepEqual(model.metrics, {
openCaseCount: 1,
handledCaseCount: 2,
timeAlarmCaseCount: 1,
pendingDisposalCaseCount: 1,
warningEscalatedCaseCount: 1,
});
assert.equal(model.rows[0].caseId, "case_batch_000002");
assert.equal(model.rows[0].statusLabel, "已处理");
assert.equal(model.rows[0].tone, "good");
assert.equal(model.rows[1].typeLabel, "升级警告");
assert.equal(model.rows[1].statusLabel, "待处理");
});
test("buildManualHandlePayload trims handled_by and keeps optional note", () => {
assert.deepEqual(buildManualHandlePayload(" alice ", " checked "), {
handled_by: "alice",
note: "checked",
});
assert.deepEqual(buildManualHandlePayload("bob", ""), {
handled_by: "bob",
});
});