- Updated argument parsing in manage_api.py to include new threshold parameters. - Enhanced _config_payload to include thresholds and webhook configurations. - Modified _build_summary to track queue metrics and adjust alert reporting. - Refactored DwellEngine to utilize queue thresholds for alerting and reporting. - Added queue metrics calculations and status change tracking in dwell_engine.py. - Updated notifier.py to support posting JSON events to webhooks. - Adjusted example configuration to reflect new threshold parameters. - Enhanced Docker entrypoint script for better process management. - Updated tests to cover new queue metrics and thresholds. - Improved ManagedServiceDetail and ManagedServices Vue components to display queue metrics.
423 lines
13 KiB
Python
423 lines
13 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,
|
|
},
|
|
"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())
|