Refactor store dwell alert management API and dwell engine

- 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.
This commit is contained in:
2026-05-09 11:35:55 +08:00
parent be5014c582
commit ea618fd674
26 changed files with 1605 additions and 117 deletions

View File

@@ -17,7 +17,6 @@ from app.config import (
save_config_document,
)
PROJECT_TYPE = "store_dwell_alert"
DEFAULT_MANAGE_PORT = 18081
MAX_PREVIEW_LINES = 2000
@@ -136,7 +135,12 @@ 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")
parser.add_argument(
"--port",
type=int,
default=DEFAULT_MANAGE_PORT,
help="Port for the management API",
)
return parser
@@ -160,9 +164,18 @@ def _config_payload(ctx: ManageContext) -> dict:
"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,
},
}
@@ -179,11 +192,13 @@ def _build_summary(ctx: ManageContext) -> dict:
},
}
alert_count = 0
last_alert_time = ""
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:
@@ -196,10 +211,7 @@ def _build_summary(ctx: ManageContext) -> dict:
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":
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)
@@ -208,31 +220,36 @@ def _build_summary(ctx: ManageContext) -> dict:
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 alerts or reports yet"
headline = "No reports yet"
if last_report_time:
headline = (
"Latest report shows "
f"{active_count} active customers, longest dwell {longest_dwell_seconds}s"
f"{queue_level or 'unknown'} queue, "
f"{over_threshold_count} over 5 min and {under_threshold_count} under 5 min"
)
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),
"last_result_time": _latest_timestamp(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,
"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,
@@ -260,7 +277,9 @@ def _list_result_files(ctx: ManageContext) -> list[dict]:
"label": label,
"kind": path.suffix.lstrip(".").lower(),
"size": info.st_size,
"modified_at": datetime.fromtimestamp(info.st_mtime).astimezone().isoformat(),
"modified_at": datetime.fromtimestamp(info.st_mtime)
.astimezone()
.isoformat(),
}
)
@@ -279,12 +298,21 @@ def _build_window_stat(payload: dict) -> dict:
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),