feat: initialize managed portal
This commit is contained in:
394
managed/store_dwell_alert/app/manage_api.py
Normal file
394
managed/store_dwell_alert/app/manage_api.py
Normal file
@@ -0,0 +1,394 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user