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, }, "thresholds": { "queue_time_threshold_seconds": config.thresholds.queue_time_threshold_seconds, "crowded_count_threshold": config.thresholds.crowded_count_threshold, "normal_count_threshold": config.thresholds.normal_count_threshold, }, "event_sink": { "path": str(event_sink_path), }, "webhook": { "url": config.webhook.url, "timeout_seconds": config.webhook.timeout_seconds, }, } 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": [], }, } last_report_time = "" active_count = 0 longest_dwell_seconds = 0 queue_level = "" over_threshold_count = 0 under_threshold_count = 0 status_change = "" 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") == "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"], ) queue_level = stat["queue_level"] over_threshold_count = stat["over_threshold_count"] under_threshold_count = stat["under_threshold_count"] status_change = stat["status_change"] window_stats.sort( key=lambda item: _parse_timestamp(item["window_end"]), reverse=True, ) headline = "No reports yet" if last_report_time: headline = ( "Latest report shows " f"{queue_level or 'unknown'} queue, " f"{over_threshold_count} over 5 min and {under_threshold_count} under 5 min" ) return { "result_type": PROJECT_TYPE, "headline": headline, "last_result_time": _latest_timestamp(last_report_time), "metrics": { "last_half_hour_report_time": last_report_time, "active_customer_count": active_count, "longest_dwell_seconds": longest_dwell_seconds, "queue_level": queue_level, "over_threshold_count": over_threshold_count, "under_threshold_count": under_threshold_count, "status_change": status_change, "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", ) queue_metrics = ( payload.get("queue_metrics") if isinstance(payload.get("queue_metrics"), dict) else {} ) 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, "queue_level": _string_value(queue_metrics.get("queue_level")), "over_threshold_count": _int_value(queue_metrics.get("over_threshold_count")), "under_threshold_count": _int_value(queue_metrics.get("under_threshold_count")), "status_change": _string_value(queue_metrics.get("status_change")), "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())