Files
managed-portal/managed/store_dwell_alert/app/manage_api.py
2026-04-27 10:04:36 +08:00

395 lines
12 KiB
Python

from __future__ import annotations
from argparse import ArgumentParser
import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, request, send_file
from app.config import (
load_config,
load_config_document,
resolve_config_path,
resolve_project_path,
resolve_project_root,
save_config_document,
)
PROJECT_TYPE = "store_dwell_alert"
DEFAULT_MANAGE_PORT = 18081
MAX_PREVIEW_LINES = 2000
@dataclass(slots=True)
class ManageContext:
config_path: Path
project_root: Path
def create_app(config_path: str | Path | None = None) -> Flask:
resolved_config = resolve_config_path(config_path)
ctx = ManageContext(
config_path=resolved_config,
project_root=resolve_project_root(resolved_config),
)
app = Flask(__name__)
app.config["MANAGE_CONTEXT"] = ctx
@app.get("/api/manage/health")
def get_health():
return jsonify(
{
"status": "ok",
"project_type": PROJECT_TYPE,
"version": "dev",
"runtime_status": "running",
}
)
@app.get("/api/manage/config")
def get_config():
return jsonify(_config_payload(ctx))
@app.put("/api/manage/config")
def update_config():
payload = request.get_json(silent=True) or {}
rtsp_url = payload.get("rtsp_url")
if not isinstance(rtsp_url, str) or not rtsp_url.strip():
return jsonify({"error": "rtsp_url is required"}), 400
raw = load_config_document(ctx.config_path)
stream = raw.setdefault("stream", {})
stream["rtsp_url"] = rtsp_url.strip()
save_config_document(ctx.config_path, raw)
return jsonify(_config_payload(ctx))
@app.get("/api/manage/summary")
def get_summary():
return jsonify(_build_summary(ctx))
@app.get("/api/manage/windows")
def get_windows():
page = max(_int_arg("page", 1), 1)
page_size = max(_int_arg("page_size", 24), 1)
limit = request.args.get("limit")
items = list(_load_window_stats(ctx))
if limit is not None:
items = items[: max(_int_value(limit), 0)]
start = (page - 1) * page_size
end = start + page_size
return jsonify(
{
"items": items[start:end],
"page": page,
"page_size": page_size,
"total": len(items),
}
)
@app.get("/api/manage/files")
def get_files():
return jsonify({"files": _list_result_files(ctx)})
@app.get("/api/manage/files/preview")
def preview_file():
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
lines = _tail_lines(target, _bounded_preview_lines(request.args.get("lines")))
return jsonify(
{
"path": _relative_path(ctx, target),
"lines": lines,
"count": len(lines),
}
)
@app.get("/api/manage/files/download")
def download_file():
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
return send_file(target, as_attachment=True, download_name=target.name)
@app.errorhandler(ValueError)
def handle_value_error(error: ValueError):
return jsonify({"error": str(error)}), 400
@app.errorhandler(FileNotFoundError)
def handle_missing_file(error: FileNotFoundError):
return jsonify({"error": str(error)}), 404
return app
def run_manage_api(
config_path: str | Path | None = None,
host: str = "0.0.0.0",
port: int = DEFAULT_MANAGE_PORT,
) -> None:
app = create_app(config_path)
app.run(host=host, port=port)
def parse_args() -> ArgumentParser:
parser = ArgumentParser(description="Store dwell alert management API")
parser.add_argument("--config", help="Path to YAML config file")
parser.add_argument("--host", default="0.0.0.0", help="Host for the management API")
parser.add_argument("--port", type=int, default=DEFAULT_MANAGE_PORT, help="Port for the management API")
return parser
def main() -> int:
parser = parse_args()
args = parser.parse_args()
run_manage_api(args.config, host=args.host, port=args.port)
return 0
def _config_payload(ctx: ManageContext) -> dict:
config = load_config(ctx.config_path)
event_sink_path = resolve_project_path(ctx.project_root, config.event_sink.path)
return {
"project_type": PROJECT_TYPE,
"config_path": str(ctx.config_path),
"camera_id": config.camera_id,
"timezone": config.timezone,
"stream": {
"rtsp_url": config.stream.rtsp_url,
"sample_fps": config.stream.sample_fps,
"reconnect_backoff_seconds": config.stream.reconnect_backoff_seconds,
},
"event_sink": {
"path": str(event_sink_path),
},
}
def _build_summary(ctx: ManageContext) -> dict:
events_path = _events_path(ctx)
if not events_path.exists():
return {
"result_type": PROJECT_TYPE,
"headline": "No event output yet",
"metrics": {
"events_path": str(events_path),
"recent_window_stats": [],
"all_window_stats": [],
},
}
alert_count = 0
last_alert_time = ""
last_report_time = ""
active_count = 0
longest_dwell_seconds = 0
window_stats: list[dict] = []
with events_path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if payload.get("event") == "long_stay_alert":
alert_count += 1
last_alert_time = _string_value(payload.get("ts"))
elif payload.get("event") == "half_hour_report":
last_report_time = _string_value(payload.get("window_end"))
active_count = _int_value(payload.get("active_customer_count"))
stat = _build_window_stat(payload)
window_stats.append(stat)
longest_dwell_seconds = max(
longest_dwell_seconds,
stat["max_wait_seconds"],
)
window_stats.sort(
key=lambda item: _parse_timestamp(item["window_end"]),
reverse=True,
)
headline = "No alerts or reports yet"
if last_report_time:
headline = (
"Latest report shows "
f"{active_count} active customers, longest dwell {longest_dwell_seconds}s"
)
elif alert_count > 0:
headline = f"Observed {alert_count} alert(s), latest alert at {last_alert_time}"
return {
"result_type": PROJECT_TYPE,
"headline": headline,
"last_result_time": _latest_timestamp(last_alert_time, last_report_time),
"metrics": {
"alert_count": alert_count,
"last_alert_time": last_alert_time,
"last_half_hour_report_time": last_report_time,
"active_customer_count": active_count,
"longest_dwell_seconds": longest_dwell_seconds,
"events_path": str(events_path),
"recent_window_stats": window_stats[:24],
"all_window_stats": window_stats,
},
}
def _load_window_stats(ctx: ManageContext) -> list[dict]:
return list(_build_summary(ctx)["metrics"]["all_window_stats"])
def _list_result_files(ctx: ManageContext) -> list[dict]:
files: list[dict] = []
for path, label in (
(_events_path(ctx), "Events JSONL"),
(ctx.project_root / "logs" / "runtime.log", "Runtime Log"),
):
if not path.exists() or not path.is_file():
continue
info = path.stat()
files.append(
{
"path": _relative_path(ctx, path),
"name": path.name,
"label": label,
"kind": path.suffix.lstrip(".").lower(),
"size": info.st_size,
"modified_at": datetime.fromtimestamp(info.st_mtime).astimezone().isoformat(),
}
)
files.sort(key=lambda item: item["path"])
return files
def _events_path(ctx: ManageContext) -> Path:
config = load_config(ctx.config_path)
return resolve_project_path(ctx.project_root, config.event_sink.path)
def _build_window_stat(payload: dict) -> dict:
active_wait_seconds = _int_list(payload.get("active_customers"), "dwell_seconds")
closed_wait_seconds = _int_list(
payload.get("closed_customers"),
"final_dwell_seconds",
)
return {
"window_start": _string_value(payload.get("window_start")),
"window_end": _string_value(payload.get("window_end")),
"active_customer_count": _int_value(payload.get("active_customer_count")),
"active_wait_seconds": active_wait_seconds,
"closed_wait_seconds": closed_wait_seconds,
"max_wait_seconds": max(
max(active_wait_seconds, default=0),
max(closed_wait_seconds, default=0),
),
}
def _resolve_sandbox_file(ctx: ManageContext, raw_path: str) -> Path:
relative = raw_path.strip().lstrip("/")
if not relative:
raise ValueError("path is required")
target = (ctx.project_root / relative).resolve()
project_root = ctx.project_root.resolve()
if target != project_root and project_root not in target.parents:
raise ValueError("invalid file path")
if not target.exists() or not target.is_file():
raise FileNotFoundError(relative)
return target
def _relative_path(ctx: ManageContext, target: Path) -> str:
return target.resolve().relative_to(ctx.project_root.resolve()).as_posix()
def _tail_lines(path: Path, line_count: int) -> list[str]:
lines: list[str] = []
with path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
lines.append(raw_line.rstrip("\n"))
if len(lines) > line_count:
lines = lines[1:]
return lines
def _bounded_preview_lines(raw_value: str | None) -> int:
if raw_value is None:
return 200
value = _int_value(raw_value)
if value <= 0:
return 200
return min(value, MAX_PREVIEW_LINES)
def _int_arg(name: str, default: int) -> int:
value = request.args.get(name)
if value is None:
return default
return _int_value(value)
def _string_value(value) -> str:
if value is None:
return ""
return str(value)
def _int_value(value) -> int:
if value is None:
return 0
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
try:
return int(str(value).strip())
except ValueError:
return 0
def _int_list(value, field: str) -> list[int]:
if not isinstance(value, list):
return []
values: list[int] = []
for item in value:
if not isinstance(item, dict):
continue
values.append(_int_value(item.get(field)))
return values
def _latest_timestamp(*values: str) -> str:
latest_raw = ""
latest_at: datetime | None = None
for value in values:
if not value.strip():
continue
parsed = _parse_timestamp(value)
if parsed is None:
if not latest_raw:
latest_raw = value
continue
if latest_at is None or parsed > latest_at:
latest_at = parsed
latest_raw = value
return latest_raw
def _parse_timestamp(value: str) -> datetime:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=datetime.now().astimezone().tzinfo)
return parsed
if __name__ == "__main__":
raise SystemExit(main())