From 25fa0c5825eebe4deec31a56168e995e5429bb34 Mon Sep 17 00:00:00 2001 From: Kunpeng Date: Thu, 13 Aug 2026 16:50:23 +0800 Subject: [PATCH] feat: SQLite persistence, non-blocking inference, modular frontend Backend: - Persist alert events and robot delivery counters in SQLite (data/app.db, YOLO_DB_PATH override); EventStore keeps its interface, DeliveryStatsStore uses read/write-through UPSERT; in-memory fallback remains for standalone AlertManager use - Serve /api/detect from a sync endpoint and serialize YOLO inference with a lock, so concurrent requests no longer block the event loop - Unify single-image and session detection: requests without session_id share the fixed single-image session and run full frame confirmation - Store UTC Z-suffixed timestamps for sortable string comparison; close executor and database on shutdown via FastAPI lifespan Frontend: - Split app.js into ES modules (main.js + modules/{dom,api,ui,charts,store} + views/{dashboard,inspection,events,robots,robotCards}), no build step - Escape all server data interpolated into innerHTML; guard missing event.classes; replace lazy element ID list with memoized qs() - Remove hardcoded fake stats (trend badge, device donut segment) Docs: - Rewrite README: accurate weight policy (only trained best.pt committed, no Git LFS), SQLite persistence, single-worker note, data asset inventory - Align models/pretrained/README.md with actual files; add YOLO_DB_PATH to .env.example; add persistence unit tests; ruff clean --- .env.example | 3 + README.md | 23 +- backend/alerting.py | 45 +- backend/events.py | 163 +++-- backend/main.py | 76 ++- backend/storage.py | 137 +++++ frontend/app.js | 571 ------------------ frontend/index.html | 10 +- frontend/main.js | 37 ++ frontend/modules/api.js | 8 + frontend/modules/charts.js | 30 + frontend/modules/dom.js | 57 ++ frontend/modules/store.js | 6 + frontend/modules/ui.js | 36 ++ frontend/styles.css | 5 +- frontend/views/dashboard.js | 104 ++++ frontend/views/events.js | 77 +++ frontend/views/inspection.js | 196 ++++++ frontend/views/robotCards.js | 39 ++ frontend/views/robots.js | 41 ++ models/pretrained/README.md | 7 +- runs/audit/render_missing_label_candidates.py | 1 + src/yolo/engine.py | 3 +- test/test_alerting.py | 25 + test/test_events.py | 27 + 25 files changed, 1058 insertions(+), 669 deletions(-) create mode 100644 backend/storage.py delete mode 100644 frontend/app.js create mode 100644 frontend/main.js create mode 100644 frontend/modules/api.js create mode 100644 frontend/modules/charts.js create mode 100644 frontend/modules/dom.js create mode 100644 frontend/modules/store.js create mode 100644 frontend/modules/ui.js create mode 100644 frontend/views/dashboard.js create mode 100644 frontend/views/events.js create mode 100644 frontend/views/inspection.js create mode 100644 frontend/views/robotCards.js create mode 100644 frontend/views/robots.js diff --git a/.env.example b/.env.example index bd24793..b293bf2 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ YOLO_IMGSZ=768 YOLO_CONF=0.40 YOLO_IOU=0.45 +# SQLite 数据库路径(事件与机器人投递统计持久化)。 +YOLO_DB_PATH=data/app.db + # 企业微信群机器人,可留空。 WECHAT_WEBHOOK_URL= diff --git a/README.md b/README.md index 4e263d9..8cb4036 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # Smoke and Fire YOLO -Ultralytics YOLO training, validation, prediction, and export pipeline for the Smoke-Fire-Detection-YOLO dataset. +Ultralytics YOLO training, validation, prediction, and export pipeline for the Smoke-Fire-Detection-YOLO dataset, plus an integrated fire prevention management platform (FastAPI + browser frontend). ## Project layout ```text configs/datasets/smoke_fire.yaml Dataset configuration -models/pretrained/yolo11s.pt Default pretrained model +models/pretrained/ Locally cached base weights (not committed) src/yolo/cli.py Command-line entry point src/yolo/config.py Training configuration src/yolo/defaults.py Project defaults src/yolo/engine.py Train, validate, predict, and export src/yolo/checkpoints.py Checkpoint discovery and resume support src/yolo/reporting.py Training result reporting +backend/ FastAPI inference API and alerting +frontend/ Browser management platform (no build step) ``` ## Setup @@ -21,6 +23,8 @@ src/yolo/reporting.py Training result reporting uv sync ``` +Trained deployment weights are committed as regular files under `runs/detect/*/weights/best.pt`, so the API works right after cloning. Checkpoint files (`epoch*.pt`, `last.pt`) and re-downloadable base weights are excluded by `.gitignore`. + ## Commands Fine-tune from the current best YOLO11s checkpoint: @@ -69,6 +73,9 @@ uv run fire-yolo export --weights runs/detect//weights/best.pt --forma - Output: `runs/detect` Training writes `last.pt`, `best.pt`, periodic checkpoints, and `best_point.json` to the run directory. + +The committed best weights and evaluation evidence are summarized in [`docs/model_comparison.md`](docs/model_comparison.md). Only trained `best.pt` files are committed; to make a newly trained model available for deployment, commit its `weights/best.pt` after the run finishes. + ## Web Detection Service After training finishes, install the web dependencies and run the integrated frontend and inference API: @@ -80,6 +87,16 @@ uv run uvicorn backend.main:app --host 127.0.0.1 --port 8000 Open `http://127.0.0.1:8000` to use the fire prevention management platform. It provides a system overview, local-video inspection, alert event handling, a risk register, robot channel status, and effective model settings. The browser plays selected videos locally and sends sequential JPEG frames to `POST /api/detect`; requests do not overlap. -Management endpoints include `GET /api/dashboard`, `GET /api/events`, `PATCH /api/events/{event_id}`, and `GET /api/robots`. Alert events can be marked as pending, acknowledged, or resolved. The robot endpoint reports Enterprise WeChat and Feishu configuration and delivery statistics without exposing webhook credentials. Use `POST /api/robots/{channel}/test` to send a connection test to a configured group robot. The current implementation retains the most recent 500 events and robot delivery counters in process memory, so they are cleared when the API service restarts. +Management endpoints include `GET /api/dashboard`, `GET /api/events`, `PATCH /api/events/{event_id}`, and `GET /api/robots`. Alert events can be marked as pending, acknowledged, or resolved. The robot endpoint reports Enterprise WeChat and Feishu configuration and delivery statistics without exposing webhook credentials. Use `POST /api/robots/{channel}/test` to send a connection test to a configured group robot. + +Events and robot delivery counters are persisted in SQLite (`data/app.db` by default, override with `YOLO_DB_PATH`), so they survive service restarts. The event store keeps the most recent 500 events. Run the API with a single uvicorn worker: per-session consecutive-frame state lives in the process. The local `.env` file contains optional robot settings. Set `WECHAT_WEBHOOK_URL` for an Enterprise WeChat group robot, `FEISHU_WEBHOOK_URL` for a Feishu custom group robot, or both. If Feishu signature verification is enabled, also set `FEISHU_SECRET`. Alerts require three consecutive positive frames by default and use separate 60-second cooldowns for fire and smoke. `ALERT_CONFIRM_FRAMES` and `ALERT_COOLDOWN_SECONDS` override these settings. Without a webhook, video detection still works and the UI reports that notifications are disabled. + +Detection requests without a `session_id` share the fixed `single-image` session, so the consecutive-frame confirmation also applies to them; reset it with `DELETE /api/sessions/single-image`. + +## Data assets + +- `data/Smoke-Fire-Detection-YOLO/` — main smoke/fire dataset (train/val/test). +- `data/fire-dataset/` — incremental hard-negative collection (395 train / 87 val images, Pascal VOC XML plus YOLO labels); not yet wired to a training config. +- `runs/audit/` — label audit tooling: `scan_missing_labels.py` finds unlabeled images that the current model detects, `render_missing_label_candidates.py` renders them for review. diff --git a/backend/alerting.py b/backend/alerting.py index 45965c0..42f1cf5 100644 --- a/backend/alerting.py +++ b/backend/alerting.py @@ -11,7 +11,6 @@ import time from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field -from datetime import datetime from io import BytesIO from typing import Any from urllib.error import HTTPError, URLError @@ -19,6 +18,8 @@ from urllib.request import Request, urlopen from PIL import Image, ImageDraw, ImageFont +from .storage import DeliveryStatsStore, utc_now_iso + LOGGER = logging.getLogger(__name__) ALERT_CLASSES = ("fire", "smoke") CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"} @@ -45,6 +46,7 @@ class AlertManager: confirm_frames: int = 3, cooldown_seconds: float = 60.0, session_ttl_seconds: float = 3600.0, + stats_store: DeliveryStatsStore | None = None, ) -> None: self.wechat_webhook_url = ( wechat_webhook_url @@ -58,6 +60,7 @@ class AlertManager: self.confirm_frames = max(1, confirm_frames) self.cooldown_seconds = max(0.0, cooldown_seconds) self.session_ttl_seconds = max(60.0, session_ttl_seconds) + self.stats_store = stats_store self._states: dict[str, SessionState] = {} self._lock = threading.Lock() self._delivery_stats = { @@ -92,11 +95,27 @@ class AlertManager: @property def channel_status(self) -> list[dict[str, Any]]: configured = self.channels - with self._lock: + if self.stats_store is not None: + saved = self.stats_store.get_all() stats = { - channel: dict(values) - for channel, values in self._delivery_stats.items() + channel: { + "delivered": 0, + "failed": 0, + "tests": 0, + "last_status": "idle", + "last_delivery_at": None, + "last_test_at": None, + "last_error": None, + **saved.get(channel, {}), + } + for channel in CHANNEL_NAMES } + else: + with self._lock: + stats = { + channel: dict(values) + for channel, values in self._delivery_stats.items() + } return [ { "id": channel, @@ -191,6 +210,10 @@ class AlertManager: with self._lock: self._states.pop(session_id, None) + def close(self) -> None: + """Shut down the alert dispatch executor (call on service shutdown).""" + self._executor.shutdown(wait=True, cancel_futures=True) + def _prune_sessions(self, now: float) -> None: expired = [ session_id @@ -230,7 +253,15 @@ class AlertManager: error: str | None = None, test: bool = False, ) -> None: - now = datetime.now().astimezone().isoformat(timespec="seconds") + if self.stats_store is not None: + self.stats_store.record( + channel, + success=success, + error=error, + test=test, + ) + return + now = utc_now_iso() with self._lock: stats = self._delivery_stats[channel] stats["last_status"] = "success" if success else "failed" @@ -386,7 +417,7 @@ def alert_summary( def feishu_signature(timestamp: str, secret: str) -> str: - string_to_sign = f"{timestamp}\n{secret}".encode("utf-8") + string_to_sign = f"{timestamp}\n{secret}".encode() digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest() return base64.b64encode(digest).decode("ascii") @@ -429,7 +460,7 @@ def annotate_image( class_name = str(detection.get("class", "target")) confidence = float(detection.get("confidence", 0.0)) color = colors.get(class_name, "#ffd166") - coordinates = tuple(int(round(value)) for value in box) + coordinates = tuple(round(value) for value in box) draw.rectangle(coordinates, outline=color, width=4) draw.text( (coordinates[0] + 4, max(0, coordinates[1] - 16)), diff --git a/backend/events.py b/backend/events.py index b1df80b..33b2303 100644 --- a/backend/events.py +++ b/backend/events.py @@ -1,18 +1,44 @@ from __future__ import annotations -import threading +import json import uuid -from collections import deque -from datetime import datetime +from datetime import UTC, datetime from typing import Any +from .storage import Database, utc_now_iso + EVENT_STATUSES = {"pending", "acknowledged", "resolved"} +def _local_today_utc_boundary() -> str: + """UTC Z-format timestamp of local midnight, for 'today' comparisons.""" + local_midnight = datetime.now().astimezone().replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + return ( + local_midnight.astimezone(UTC) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) + + class EventStore: - def __init__(self, max_events: int = 500) -> None: - self._events: deque[dict[str, Any]] = deque(maxlen=max_events) - self._lock = threading.Lock() + """Persistent alert event store backed by a single SQLite table. + + Keeps at most ``max_events`` rows (newest first, ordered by rowid so + events created within the same second keep a stable insertion order). + """ + + def __init__( + self, + db: Database | None = None, + max_events: int = 500, + ) -> None: + self._db = db or Database(":memory:") + self._max_events = max(1, max_events) def create( self, @@ -21,7 +47,6 @@ class EventStore: detections: list[dict[str, Any]], notification_channels: dict[str, bool], ) -> dict[str, Any]: - now = datetime.now().astimezone() confidences = [ float(detection.get("confidence", 0.0)) for detection in detections @@ -29,7 +54,7 @@ class EventStore: ] event = { "id": uuid.uuid4().hex[:12], - "created_at": now.isoformat(timespec="seconds"), + "created_at": utc_now_iso(), "session_id": session_id, "classes": classes, "max_confidence": round(max(confidences, default=0.0), 6), @@ -38,17 +63,44 @@ class EventStore: "notification_channels": notification_channels, "handled_at": None, } - with self._lock: - self._events.appendleft(event) - return dict(event) + self._db.execute( + """ + INSERT INTO events ( + id, created_at, session_id, classes, max_confidence, + detection_count, status, notification_channels, handled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event["id"], + event["created_at"], + session_id, + json.dumps(classes, ensure_ascii=False), + event["max_confidence"], + event["detection_count"], + event["status"], + json.dumps(notification_channels, ensure_ascii=False), + None, + ), + ) + self._db.execute( + """ + DELETE FROM events WHERE id IN ( + SELECT id FROM events ORDER BY rowid DESC LIMIT -1 OFFSET ? + ) + """, + (self._max_events,), + ) + return event def list( self, limit: int = 100, status: str | None = None, ) -> list[dict[str, Any]]: - with self._lock: - events = [dict(event) for event in self._events] + rows = self._db.execute( + "SELECT * FROM events ORDER BY rowid DESC", + ).fetchall() + events = [self._to_dict(row) for row in rows] if status: events = [event for event in events if event["status"] == status] return events[: max(1, min(limit, 500))] @@ -56,42 +108,57 @@ class EventStore: def update(self, event_id: str, status: str) -> dict[str, Any] | None: if status not in EVENT_STATUSES: raise ValueError(f"Unsupported event status: {status}") - with self._lock: - for event in self._events: - if event["id"] != event_id: - continue - event["status"] = status - event["handled_at"] = ( - None - if status == "pending" - else datetime.now().astimezone().isoformat( - timespec="seconds" - ) - ) - return dict(event) - return None + cursor = self._db.execute( + """ + UPDATE events SET status = ?, handled_at = ? + WHERE id = ? + """, + (status, None if status == "pending" else utc_now_iso(), event_id), + ) + if cursor.rowcount == 0: + return None + row = self._db.execute( + "SELECT * FROM events WHERE id = ?", + (event_id,), + ).fetchone() + return self._to_dict(row) def summary(self) -> dict[str, int]: - today = datetime.now().astimezone().date() - with self._lock: - events = [dict(event) for event in self._events] - today_events = [ - event - for event in events - if datetime.fromisoformat(event["created_at"]).date() == today - ] + rows = self._db.execute( + "SELECT created_at, classes, status FROM events", + ).fetchall() + today_boundary = _local_today_utc_boundary() + today_events = 0 + counts = {status: 0 for status in EVENT_STATUSES} + fire = 0 + smoke = 0 + for row in rows: + counts[row["status"]] += 1 + classes = json.loads(row["classes"]) + if row["created_at"] >= today_boundary: + today_events += 1 + fire += "fire" in classes + smoke += "smoke" in classes return { - "total": len(events), - "today": len(today_events), - "pending": sum( - event["status"] == "pending" for event in events - ), - "acknowledged": sum( - event["status"] == "acknowledged" for event in events - ), - "resolved": sum( - event["status"] == "resolved" for event in events - ), - "fire": sum("fire" in event["classes"] for event in events), - "smoke": sum("smoke" in event["classes"] for event in events), + "total": len(rows), + "today": today_events, + "pending": counts["pending"], + "acknowledged": counts["acknowledged"], + "resolved": counts["resolved"], + "fire": fire, + "smoke": smoke, + } + + @staticmethod + def _to_dict(row: Any) -> dict[str, Any]: + return { + "id": row["id"], + "created_at": row["created_at"], + "session_id": row["session_id"], + "classes": json.loads(row["classes"]), + "max_confidence": row["max_confidence"], + "detection_count": row["detection_count"], + "status": row["status"], + "notification_channels": json.loads(row["notification_channels"]), + "handled_at": row["handled_at"], } diff --git a/backend/main.py b/backend/main.py index 0759210..774970e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,21 +2,25 @@ from __future__ import annotations import io import os +import threading import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from functools import lru_cache from pathlib import Path from typing import Any +from dotenv import load_dotenv from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from PIL import Image, UnidentifiedImageError -from dotenv import load_dotenv from pydantic import BaseModel from ultralytics import YOLO from .alerting import AlertManager from .events import EVENT_STATUSES, EventStore +from .storage import Database, DeliveryStatsStore PROJECT_ROOT = Path(__file__).resolve().parents[1] FRONTEND_DIR = PROJECT_ROOT / "frontend" @@ -30,17 +34,37 @@ IOU = float(os.getenv("YOLO_IOU", "0.45")) MAX_UPLOAD_BYTES = 15 * 1024 * 1024 MAX_IMAGE_PIXELS = int(os.getenv("YOLO_MAX_IMAGE_PIXELS", "25000000")) CLASS_NAMES = {0: "smoke", 1: "fire"} +DB_PATH = Path( + os.getenv("YOLO_DB_PATH", str(PROJECT_ROOT / "data" / "app.db")) +).expanduser().resolve() +DB = Database(DB_PATH) +EVENT_STORE = EventStore(db=DB) ALERT_MANAGER = AlertManager( confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")), cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")), + stats_store=DeliveryStatsStore(DB), ) -EVENT_STORE = EventStore() +# Ultralytics models are not thread-safe: serialize inference so concurrent +# /api/detect requests from the FastAPI threadpool cannot race each other. +INFERENCE_LOCK = threading.Lock() class EventStatusUpdate(BaseModel): status: str -app = FastAPI(title="Smoke Fire Detector API", version="0.1.0") + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + yield + ALERT_MANAGER.close() + DB.close() + + +app = FastAPI( + title="Smoke Fire Detector API", + version="0.1.0", + lifespan=lifespan, +) app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -77,14 +101,15 @@ def validate_image(payload: bytes) -> Image.Image: def predict_image(image: Image.Image) -> dict[str, Any]: started_at = time.perf_counter() try: - result = get_model().predict( - source=image, - conf=CONFIDENCE, - iou=IOU, - imgsz=IMAGE_SIZE, - device=DEVICE, - verbose=False, - )[0] + with INFERENCE_LOCK: + result = get_model().predict( + source=image, + conf=CONFIDENCE, + iou=IOU, + imgsz=IMAGE_SIZE, + device=DEVICE, + verbose=False, + )[0] except FileNotFoundError as error: raise HTTPException(status_code=503, detail=str(error)) from error except Exception as error: @@ -118,30 +143,25 @@ def health() -> dict[str, Any]: @app.post("/api/detect") -async def detect( - file: UploadFile = File(...), +def detect( + file: UploadFile = File(...), # noqa: B008 — FastAPI dependency idiom session_id: str | None = None, ) -> dict[str, Any]: - payload = await file.read(MAX_UPLOAD_BYTES + 1) + payload = file.file.read(MAX_UPLOAD_BYTES + 1) image = validate_image(payload) result = predict_image(image) - result["alert"] = ( - ALERT_MANAGER.evaluate( - session_id, - result["detections"], - image, - ) - if session_id - else { - "triggered": False, - "classes": [], - "notification_enabled": ALERT_MANAGER.enabled, - "notification_channels": ALERT_MANAGER.channels, - } + # Requests without a session share the fixed "single-image" session, so + # the consecutive-frame confirmation works there as well. Reset it with + # DELETE /api/sessions/single-image. + active_session = session_id or "single-image" + result["alert"] = ALERT_MANAGER.evaluate( + active_session, + result["detections"], + image, ) if result["alert"]["triggered"]: result["event"] = EVENT_STORE.create( - session_id=session_id or "single-image", + session_id=active_session, classes=result["alert"]["classes"], detections=result["detections"], notification_channels=result["alert"].get( diff --git a/backend/storage.py b/backend/storage.py new file mode 100644 index 0000000..e4a8a8a --- /dev/null +++ b/backend/storage.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import sqlite3 +import threading +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + session_id TEXT NOT NULL, + classes TEXT NOT NULL, + max_confidence REAL NOT NULL, + detection_count INTEGER NOT NULL, + status TEXT NOT NULL, + notification_channels TEXT NOT NULL, + handled_at TEXT +); +CREATE TABLE IF NOT EXISTS delivery_stats ( + channel TEXT PRIMARY KEY, + delivered INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + tests INTEGER NOT NULL DEFAULT 0, + last_status TEXT NOT NULL DEFAULT 'idle', + last_delivery_at TEXT, + last_test_at TEXT, + last_error TEXT +); +""" + + +def utc_now_iso() -> str: + """Current UTC time as a sortable ISO string with a Z suffix.""" + return ( + datetime.now(UTC) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) + + +class Database: + """Single shared SQLite connection guarded by a lock. + + The API service is low-write and multi-threaded (FastAPI threadpool, + alert dispatch executor), so one connection with ``check_same_thread=False`` + plus a lock is simpler and safer on Windows than per-operation connections. + """ + + def __init__(self, path: str | Path) -> None: + resolved = Path(path) + resolved.parent.mkdir(parents=True, exist_ok=True) + self._connection = sqlite3.connect( + str(resolved), + check_same_thread=False, + ) + self._connection.row_factory = sqlite3.Row + self._lock = threading.Lock() + self._connection.execute("PRAGMA journal_mode=WAL") + self._connection.execute("PRAGMA busy_timeout=5000") + self._connection.execute("PRAGMA synchronous=NORMAL") + self._connection.executescript(SCHEMA) + self._connection.commit() + + def execute( + self, + statement: str, + parameters: tuple[Any, ...] = (), + ) -> sqlite3.Cursor: + with self._lock: + cursor = self._connection.execute(statement, parameters) + self._connection.commit() + return cursor + + def close(self) -> None: + with self._lock: + self._connection.close() + + +class DeliveryStatsStore: + """Persistent per-channel delivery counters (read-through / write-through). + + Each ``record`` call applies an atomic UPSERT carrying 0/1 increments and + the new ``last_*`` values, so there is no in-memory copy to drift from disk. + """ + + def __init__(self, db: Database) -> None: + self._db = db + + def get_all(self) -> dict[str, dict[str, Any]]: + rows = self._db.execute( + "SELECT * FROM delivery_stats", + ).fetchall() + return {row["channel"]: dict(row) for row in rows} + + def record( + self, + channel: str, + *, + success: bool, + error: str | None = None, + test: bool = False, + ) -> None: + now = utc_now_iso() + self._db.execute( + """ + INSERT INTO delivery_stats ( + channel, delivered, failed, tests, last_status, + last_delivery_at, last_test_at, last_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(channel) DO UPDATE SET + delivered = delivered + excluded.delivered, + failed = failed + excluded.failed, + tests = tests + excluded.tests, + last_status = excluded.last_status, + last_delivery_at = COALESCE( + excluded.last_delivery_at, + delivery_stats.last_delivery_at + ), + last_test_at = COALESCE( + excluded.last_test_at, + delivery_stats.last_test_at + ), + last_error = excluded.last_error + """, + ( + channel, + 1 if success else 0, + 0 if success else 1, + 1 if test else 0, + "success" if success else "failed", + now if not test else None, + now if test else None, + None if success else error, + ), + ) diff --git a/frontend/app.js b/frontend/app.js deleted file mode 100644 index 48fdd37..0000000 --- a/frontend/app.js +++ /dev/null @@ -1,571 +0,0 @@ -const API_ENDPOINT = "/api/detect"; -const VIEW_LABELS = { - overview: "系统总览", - inspection: "视频巡检", - events: "告警中心", - robots: "消息机器人", - risks: "风险台账", - settings: "系统设置", -}; -const STATUS_LABELS = { - pending: "待处置", - acknowledged: "已确认", - resolved: "已解决", -}; - -const elements = Object.fromEntries( - [ - "ackEventsCount", "alertStatus", "allEventsCount", "clearButton", "confirmFramesValue", "cooldownValue", - "currentClock", "detectionList", "emptyState", "eventTableBody", - "eventTableMeta", "fireCount", "globalStatus", - "inspectionStatus", "intervalSelect", "lastUpdated", "loadingState", - "maxConfidence", "menuButton", "modelStatusDetail", "modelStatusValue", - "notificationDot", "overlayCanvas", "overviewMessage", "pendingEventCount", - "pendingEventsCount", "pendingNavCount", "readyApi", "readyFeishu", - "readyWechat", "readyWeights", "recentEventList", "refreshEventsButton", - "resolvedEventsCount", "riskChannelValue", "riskConfidenceValue", - "riskPendingValue", "riskStatus", "runDetectionButton", "settingConfidence", - "settingConfirmFrames", "settingCooldown", "settingImageSize", "settingIou", - "settingWeights", "sidebar", "sidebarStatus", "sidebarStatusDot", "smokeCount", - "sourceLabel", "todayEventCount", "toastRegion", "trendChart", "videoClock", - "videoInput", "videoMeta", "videoPreview", "viewBreadcrumb", "viewTitle", - "deviceOnlineRate", "eventClosureRate", "fireRiskRatio", - "notificationCoverage", "onlineRobotCount", "overviewDeliveryCount", - "overviewFeishuState", "overviewPolicyText", "overviewWechatState", - "refreshRobotsButton", "riskDonut", "riskTotal", "robotCardGrid", - "robotConfiguredCount", "robotDeliveredCount", "robotFailedCount", - "robotNavCount", "robotPolicyConfirm", "robotPolicyCooldown", - "robotSummary", "robotTotalCount", "safetyGauge", "safetyScore", - "smokeRiskRatio", - ].map((id) => [id, document.querySelector(`#${id}`)]) -); - -const frameCanvas = document.createElement("canvas"); -let videoUrl = null; -let sessionId = null; -let detectionActive = false; -let requestInFlight = false; -let lastDetectionAt = 0; -let dashboardData = null; -let eventData = []; -let eventFilter = ""; - -function showToast(message, type = "info") { - const toast = document.createElement("div"); - toast.className = `toast ${type}`; - toast.textContent = message; - elements.toastRegion.appendChild(toast); - setTimeout(() => toast.remove(), 3600); -} - -function switchView(viewName) { - if (!VIEW_LABELS[viewName]) return; - document.querySelectorAll("[data-view-panel]").forEach((panel) => { - panel.classList.toggle("is-active", panel.dataset.viewPanel === viewName); - }); - document.querySelectorAll("[data-view]").forEach((button) => { - button.classList.toggle("is-active", button.dataset.view === viewName); - }); - elements.viewTitle.textContent = VIEW_LABELS[viewName]; - elements.viewBreadcrumb.textContent = VIEW_LABELS[viewName]; - elements.sidebar.classList.remove("is-open"); - if (viewName === "events") loadEvents(); - if (viewName === "robots") loadRobots(); - if (["overview", "risks", "settings"].includes(viewName)) loadDashboard(); -} - -function setServiceStatus(online) { - elements.globalStatus.textContent = online ? "服务运行正常" : "服务连接异常"; - elements.globalStatus.className = `status-chip ${online ? "status-online" : "status-offline"}`; - elements.sidebarStatus.textContent = online ? "服务在线" : "服务离线"; - elements.sidebarStatusDot.className = `status-dot ${online ? "is-online" : "is-offline"}`; - elements.readyApi.textContent = online ? "正常" : "异常"; - elements.readyApi.className = online ? "ready" : "not-ready"; -} - -function enabledChannelNames(channels = {}) { - const names = []; - if (channels.wechat) names.push("企业微信"); - if (channels.feishu) names.push("飞书"); - return names; -} - -function updateClock() { - elements.currentClock.textContent = new Date().toLocaleString("zh-CN", { - hour12: false, - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -function formatDate(value) { - if (!value) return "--"; - return new Date(value).toLocaleString("zh-CN", { hour12: false }); -} - -function formatTime(seconds) { - if (!Number.isFinite(seconds)) return "00:00"; - const minutes = Math.floor(seconds / 60); - const remaining = Math.floor(seconds % 60); - return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`; -} - -function updateDashboard(data) { - dashboardData = data; - const { summary, system, detection } = data; - const channels = system.alert_channels || {}; - const channelNames = enabledChannelNames(channels); - elements.todayEventCount.textContent = String(summary.today); - elements.pendingEventCount.textContent = String(summary.pending); - elements.pendingNavCount.textContent = String(summary.pending); - elements.notificationDot.hidden = summary.pending === 0; - elements.modelStatusValue.textContent = system.weights_available ? "运行正常" : "权重缺失"; - elements.modelStatusDetail.textContent = system.weights_available ? "模型文件已就绪" : "请检查 YOLO_WEIGHTS"; - elements.modelStatusValue.className = "text-value"; - const robotPayload = data.robots || { robots: [], summary: {} }; - const robotSummary = robotPayload.summary || {}; - elements.onlineRobotCount.textContent = String(robotSummary.configured || 0); - elements.robotSummary.textContent = `${robotSummary.configured || 0} / ${robotSummary.total || 2} 已配置 · 成功投递 ${robotSummary.delivered || 0} 次`; - elements.overviewMessage.textContent = summary.pending - ? `当前有 ${summary.pending} 条告警事件等待处置,请尽快进入告警中心确认。` - : "当前无待处置事件,模型与视频巡检服务保持监测状态。"; - elements.readyWeights.textContent = system.weights_available ? "已就绪" : "缺失"; - elements.readyWeights.className = system.weights_available ? "ready" : "not-ready"; - setChannelReady(elements.readyWechat, channels.wechat); - setChannelReady(elements.readyFeishu, channels.feishu); - elements.riskPendingValue.textContent = String(summary.pending); - elements.riskChannelValue.textContent = `${channelNames.length} / 2`; - elements.riskConfidenceValue.textContent = `${Math.round(detection.confidence * 100)}%`; - elements.confirmFramesValue.textContent = `${detection.confirm_frames} 帧`; - elements.cooldownValue.textContent = `${detection.cooldown_seconds} 秒`; - elements.settingWeights.textContent = system.weights; - elements.settingImageSize.textContent = `${detection.image_size} px`; - elements.settingConfidence.textContent = detection.confidence.toFixed(2); - elements.settingIou.textContent = detection.iou.toFixed(2); - elements.settingConfirmFrames.textContent = `${detection.confirm_frames} 帧`; - elements.settingCooldown.textContent = `${detection.cooldown_seconds} 秒`; - renderRecentEvents(data.recent_events || []); - const visualEvents = eventData.length ? eventData : data.recent_events || []; - renderTrend(visualEvents); - updateCommandVisuals(visualEvents, summary, channels, robotSummary); - renderRobots(robotPayload); -} - -function updateCommandVisuals(events, summary, channels, robotSummary = {}) { - const totalRobots = robotSummary.total || 2; - const configuredRobots = robotSummary.configured || 0; - const total = summary.total || 0; - const closureRate = total ? Math.round(((summary.resolved || 0) / total) * 100) : 100; - const notificationRate = Math.round((configuredRobots / totalRobots) * 100); - const safetyScore = Math.max(42, Math.min(98, Math.round(notificationRate * 0.35 + closureRate * 0.35 + Math.max(0, 100 - (summary.pending || 0) * 8) * 0.3))); - elements.deviceOnlineRate.textContent = `${notificationRate}%`; - elements.eventClosureRate.textContent = `${closureRate}%`; - elements.notificationCoverage.textContent = `${notificationRate}%`; - elements.safetyScore.textContent = String(safetyScore); - elements.safetyGauge.style.setProperty("--score", safetyScore); - elements.overviewWechatState.textContent = channels.wechat ? "已配置" : "未配置"; - elements.overviewFeishuState.textContent = channels.feishu ? "已配置" : "未配置"; - elements.overviewPolicyText.textContent = `${dashboardData?.detection?.confirm_frames || 3} 帧确认`; - elements.overviewDeliveryCount.textContent = `累计成功 ${robotSummary.delivered || 0} 次`; - - let fireEvents = 0; - let smokeEvents = 0; - events.forEach((event) => { - if ((event.classes || []).includes("fire")) fireEvents += 1; - if ((event.classes || []).includes("smoke")) smokeEvents += 1; - }); - const detectedTotal = fireEvents + smokeEvents; - const chartTotal = Math.max(detectedTotal, 1); - const fireArc = Math.round((fireEvents / chartTotal) * 88); - const smokeArc = detectedTotal ? 88 - fireArc : 0; - elements.riskTotal.textContent = String(detectedTotal); - elements.fireRiskRatio.textContent = `${detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0}%`; - elements.smokeRiskRatio.textContent = `${detectedTotal ? Math.round((smokeEvents / detectedTotal) * 100) : 0}%`; - elements.riskDonut.style.background = `conic-gradient(var(--red) 0 ${fireArc}%, var(--cyan) ${fireArc}% ${fireArc + smokeArc}%, var(--orange) ${fireArc + smokeArc}% 100%)`; -} - -async function loadRobots() { - try { - const response = await fetch("/api/robots"); - const result = await response.json(); - if (!response.ok) throw new Error(result.detail || `API ${response.status}`); - renderRobots(result); - setServiceStatus(true); - } catch (error) { - setServiceStatus(false); - showToast(`无法读取机器人状态:${error.message}`, "error"); - } -} - -function renderRobots(payload = { robots: [], summary: {} }) { - const robots = payload.robots || []; - const summary = payload.summary || {}; - elements.robotTotalCount.textContent = String(summary.total || robots.length); - elements.robotConfiguredCount.textContent = String(summary.configured || 0); - elements.robotDeliveredCount.textContent = String(summary.delivered || 0); - elements.robotFailedCount.textContent = String(summary.failed || 0); - elements.robotNavCount.textContent = String(summary.configured || 0); - elements.robotPolicyConfirm.textContent = `${dashboardData?.detection?.confirm_frames || 3} 帧连续命中后发送`; - elements.robotPolicyCooldown.textContent = `${dashboardData?.detection?.cooldown_seconds || 60} 秒内同类不重复发送`; - elements.robotCardGrid.innerHTML = robots.length - ? robots.map(robotCardTemplate).join("") - : '
暂无机器人通道数据
'; -} - -function robotCardTemplate(robot) { - const platformClass = robot.id === "wechat" ? "wechat" : "feishu"; - const platformMark = robot.id === "wechat" ? "微" : "飞"; - const statusLabel = robot.configured ? "已配置" : "未配置"; - const lastActivity = robot.last_test_at || robot.last_delivery_at; - const activityText = lastActivity ? formatDate(lastActivity) : "尚无发送记录"; - const statusText = robot.last_status === "success" ? "最近发送成功" : robot.last_status === "failed" ? "最近发送失败" : "等待首次发送"; - const credential = robot.id === "wechat" ? "WECHAT_WEBHOOK_URL" : "FEISHU_WEBHOOK_URL"; - return `
-
${robot.id.toUpperCase()} ROBOT

${robot.name}群机器人

${robot.id === "wechat" ? "群聊 Markdown 与告警截图" : "群聊交互卡片与签名校验"}

${statusLabel}
-
${robot.capabilities.map((capability) => `${capability}`).join("")}
-
成功投递${robot.delivered}
失败${robot.failed}
连接测试${robot.tests}
-
${statusText}${activityText}
-
服务端配置${credential}${robot.id === "feishu" ? " / FEISHU_SECRET" : ""}
- ${robot.last_error ? `

${escapeHtml(robot.last_error)}

` : ""} - -
`; -} - -async function testRobot(channel, button) { - const originalText = button.textContent; - button.disabled = true; - button.textContent = "正在发送"; - try { - const response = await fetch(`/api/robots/${encodeURIComponent(channel)}/test`, { method: "POST" }); - const result = await response.json(); - if (!response.ok) throw new Error(result.detail || `API ${response.status}`); - showToast(`${result.robot.name}测试消息发送成功`, "success"); - await Promise.all([loadRobots(), loadDashboard()]); - } catch (error) { - showToast(`测试消息发送失败:${error.message}`, "error"); - } finally { - button.disabled = false; - button.textContent = originalText; - } -} - -function escapeHtml(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} -function setChannelReady(element, enabled) { - element.textContent = enabled ? "已启用" : "未配置"; - element.className = enabled ? "ready" : "optional"; -} - -async function loadDashboard() { - try { - const response = await fetch("/api/dashboard"); - if (!response.ok) throw new Error(`API ${response.status}`); - const data = await response.json(); - setServiceStatus(true); - updateDashboard(data); - } catch (error) { - setServiceStatus(false); - showToast(`无法读取管理数据:${error.message}`, "error"); - } -} - -async function loadEvents() { - try { - const query = eventFilter ? `?status=${eventFilter}` : ""; - const response = await fetch(`/api/events${query}`); - if (!response.ok) throw new Error(`API ${response.status}`); - const result = await response.json(); - eventData = result.events || []; - renderEventTable(eventData); - updateEventSummary(result.summary); - renderTrend(eventData); - setServiceStatus(true); - } catch (error) { - setServiceStatus(false); - showToast(`无法读取告警事件:${error.message}`, "error"); - } -} - -function updateEventSummary(summary) { - elements.allEventsCount.textContent = String(summary.total); - elements.pendingEventsCount.textContent = String(summary.pending); - elements.ackEventsCount.textContent = String(summary.acknowledged); - elements.resolvedEventsCount.textContent = String(summary.resolved); - elements.pendingNavCount.textContent = String(summary.pending); - elements.notificationDot.hidden = summary.pending === 0; -} - -function renderRecentEvents(events) { - if (!events.length) { - elements.recentEventList.innerHTML = "

暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。

"; - return; - } - elements.recentEventList.innerHTML = events.map((event) => { - const primary = event.classes.includes("fire") ? "fire" : "smoke"; - const label = event.classes.map(classLabel).join("、"); - return `
${primary === "fire" ? "火" : "烟"}
${label}检测告警${formatDate(event.created_at)} · ${event.id}
${Math.round(event.max_confidence * 100)}%${STATUS_LABELS[event.status]}
`; - }).join(""); -} - -function renderEventTable(events) { - elements.eventTableMeta.textContent = `${events.length} 条记录`; - if (!events.length) { - elements.eventTableBody.innerHTML = '当前筛选条件下暂无告警事件'; - return; - } - elements.eventTableBody.innerHTML = events.map((event) => { - const classes = event.classes.map((name) => `${classLabel(name)}`).join(" "); - const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送"; - return `${event.id}${formatDate(event.created_at)}${classes}${Math.round(event.max_confidence * 100)}%${channels}${STATUS_LABELS[event.status]}
${event.status === "pending" ? `` : ""}${event.status !== "resolved" ? `` : ""}
`; - }).join(""); -} - -function classLabel(name) { - return name === "fire" ? "火焰" : "烟雾"; -} - -async function updateEventStatus(eventId, status) { - try { - const response = await fetch(`/api/events/${eventId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status }), - }); - if (!response.ok) throw new Error(`API ${response.status}`); - showToast(`事件 ${eventId} 已更新为${STATUS_LABELS[status]}`, "success"); - await Promise.all([loadEvents(), loadDashboard()]); - } catch (error) { - showToast(`事件更新失败:${error.message}`, "error"); - } -} - -function renderTrend(events) { - const days = Array.from({ length: 7 }, (_, index) => { - const date = new Date(); - date.setDate(date.getDate() - (6 - index)); - return { key: date.toISOString().slice(0, 10), label: `${date.getMonth() + 1}/${date.getDate()}`, fire: 0, smoke: 0 }; - }); - events.forEach((event) => { - const day = days.find((item) => item.key === String(event.created_at).slice(0, 10)); - if (!day) return; - if (event.classes.includes("fire")) day.fire += 1; - if (event.classes.includes("smoke")) day.smoke += 1; - }); - const width = 720; - const height = 225; - const padding = { left: 34, right: 12, top: 14, bottom: 28 }; - const maxValue = Math.max(3, ...days.flatMap((day) => [day.fire, day.smoke])); - const x = (index) => padding.left + index * ((width - padding.left - padding.right) / 6); - const y = (value) => height - padding.bottom - value * ((height - padding.top - padding.bottom) / maxValue); - const path = (key) => days.map((day, index) => `${index ? "L" : "M"}${x(index)},${y(day[key])}`).join(" "); - const gridValues = [0, Math.ceil(maxValue / 2), maxValue]; - elements.trendChart.innerHTML = `最近七天火焰与烟雾告警趋势${gridValues.map((value) => `${value}`).join("")}${days.map((day, index) => `${day.label}`).join("")}`; -} - -function clearDetectionResults() { - elements.smokeCount.textContent = "0"; - elements.fireCount.textContent = "0"; - elements.maxConfidence.textContent = "--"; - elements.riskStatus.textContent = "待机"; - elements.detectionList.innerHTML = '

暂无检测结果

'; - const context = elements.overlayCanvas.getContext("2d"); - context.clearRect(0, 0, elements.overlayCanvas.width, elements.overlayCanvas.height); -} - -function drawDetections(detections = []) { - const video = elements.videoPreview; - if (!video.videoWidth || !video.videoHeight) return; - const canvas = elements.overlayCanvas; - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - const context = canvas.getContext("2d"); - context.clearRect(0, 0, canvas.width, canvas.height); - detections.forEach((detection) => { - const [x1, y1, x2, y2] = detection.box || []; - const color = detection.class === "fire" ? "#ff665e" : "#41d6c3"; - context.strokeStyle = color; - context.lineWidth = Math.max(2, canvas.width / 320); - context.strokeRect(x1, y1, x2 - x1, y2 - y1); - context.fillStyle = color; - context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`; - context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6)); - }); -} - -function updateDetectionResults(result) { - const detections = result.detections || []; - const smoke = detections.filter((item) => item.class === "smoke").length; - const fire = detections.filter((item) => item.class === "fire").length; - const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0); - elements.smokeCount.textContent = String(smoke); - elements.fireCount.textContent = String(fire); - elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--"; - elements.riskStatus.textContent = fire ? "高风险" : smoke ? "需关注" : "正常"; - elements.detectionList.innerHTML = detections.length - ? detections.map((item) => `
${classLabel(item.class)}${Math.round(item.confidence * 100)}%
`).join("") - : '

未发现烟雾或火焰目标

'; - const alert = result.alert || {}; - const channelNames = enabledChannelNames(alert.notification_channels); - if (alert.triggered) { - const labels = alert.classes.map(classLabel).join("、"); - elements.alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`; - elements.alertStatus.className = "alert-status alert-triggered"; - showToast(`检测到${labels},已生成告警事件`, "error"); - loadDashboard(); - } else { - const fireFrames = alert.consecutive?.fire || 0; - const smokeFrames = alert.consecutive?.smoke || 0; - elements.alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`; - elements.alertStatus.className = "alert-status"; - } - drawDetections(detections); - elements.lastUpdated.textContent = `推理 ${result.inference_ms ?? "--"} ms`; -} - -function captureFrame() { - const video = elements.videoPreview; - frameCanvas.width = video.videoWidth; - frameCanvas.height = video.videoHeight; - frameCanvas.getContext("2d").drawImage(video, 0, 0); - return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88)); -} - -async function detectCurrentFrame(timestamp) { - if (!detectionActive || requestInFlight || !sessionId || elements.videoPreview.paused || elements.videoPreview.ended) return; - const interval = Number(elements.intervalSelect.value); - if (timestamp - lastDetectionAt < interval) return; - lastDetectionAt = timestamp; - requestInFlight = true; - elements.loadingState.hidden = false; - try { - const frame = await captureFrame(); - if (!frame) throw new Error("无法截取视频帧"); - const form = new FormData(); - form.append("file", frame, "video-frame.jpg"); - const response = await fetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form }); - if (!response.ok) throw new Error(`API ${response.status}`); - updateDetectionResults(await response.json()); - elements.inspectionStatus.textContent = "检测运行中"; - elements.inspectionStatus.className = "status-chip status-online"; - setServiceStatus(true); - } catch (error) { - elements.inspectionStatus.textContent = "检测异常"; - elements.inspectionStatus.className = "status-chip status-offline"; - showToast(`视频检测失败:${error.message}`, "error"); - } finally { - requestInFlight = false; - elements.loadingState.hidden = true; - } -} - -function scheduleDetection(timestamp) { - detectCurrentFrame(timestamp); - if (detectionActive) requestAnimationFrame(scheduleDetection); -} - -async function startDetection() { - if (!elements.videoPreview.src) return; - detectionActive = true; - lastDetectionAt = -Infinity; - elements.runDetectionButton.textContent = "停止检测"; - elements.intervalSelect.disabled = true; - try { - await elements.videoPreview.play(); - requestAnimationFrame(scheduleDetection); - } catch { - stopDetection(); - showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error"); - } -} - -function stopDetection() { - detectionActive = false; - elements.runDetectionButton.textContent = "开始连续检测"; - elements.intervalSelect.disabled = false; - elements.inspectionStatus.textContent = elements.videoPreview.src ? "检测已暂停" : "等待视频"; - elements.inspectionStatus.className = "status-chip status-idle"; -} - -async function resetSession() { - const previous = sessionId; - sessionId = crypto.randomUUID(); - if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {}); -} - -document.querySelectorAll("[data-view], [data-view-jump]").forEach((control) => { - control.addEventListener("click", () => switchView(control.dataset.view || control.dataset.viewJump)); -}); -document.querySelectorAll("[data-event-filter]").forEach((button) => { - button.addEventListener("click", () => { - eventFilter = button.dataset.eventFilter; - document.querySelectorAll("[data-event-filter]").forEach((item) => item.classList.toggle("is-active", item === button)); - loadEvents(); - }); -}); -elements.refreshRobotsButton.addEventListener("click", loadRobots); -elements.robotCardGrid.addEventListener("click", (event) => { - const button = event.target.closest("[data-test-robot]"); - if (button) testRobot(button.dataset.testRobot, button); -}); -elements.eventTableBody.addEventListener("click", (event) => { - const button = event.target.closest("[data-event-id]"); - if (button) updateEventStatus(button.dataset.eventId, button.dataset.eventStatus); -}); -elements.menuButton.addEventListener("click", () => elements.sidebar.classList.toggle("is-open")); -elements.refreshEventsButton.addEventListener("click", loadEvents); -elements.runDetectionButton.addEventListener("click", () => detectionActive ? stopDetection() : startDetection()); -elements.videoPreview.addEventListener("ended", stopDetection); -elements.videoPreview.addEventListener("timeupdate", () => { - elements.videoClock.textContent = `${formatTime(elements.videoPreview.currentTime)} / ${formatTime(elements.videoPreview.duration)}`; -}); -elements.videoPreview.addEventListener("seeked", async () => { drawDetections([]); await resetSession(); }); -elements.videoPreview.addEventListener("resize", () => drawDetections([])); -elements.videoInput.addEventListener("change", async () => { - const [file] = elements.videoInput.files; - if (!file) return; - stopDetection(); - elements.videoPreview.pause(); - if (videoUrl) URL.revokeObjectURL(videoUrl); - videoUrl = URL.createObjectURL(file); - elements.videoPreview.src = videoUrl; - elements.videoPreview.hidden = false; - elements.emptyState.hidden = true; - elements.sourceLabel.textContent = file.name; - elements.videoMeta.textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`; - elements.runDetectionButton.disabled = false; - elements.inspectionStatus.textContent = "视频已就绪"; - elements.inspectionStatus.className = "status-chip status-online"; - await resetSession(); - clearDetectionResults(); -}); -elements.clearButton.addEventListener("click", async () => { - stopDetection(); - elements.videoPreview.pause(); - elements.videoPreview.removeAttribute("src"); - elements.videoPreview.load(); - elements.videoPreview.hidden = true; - if (videoUrl) URL.revokeObjectURL(videoUrl); - videoUrl = null; - elements.videoInput.value = ""; - elements.sourceLabel.textContent = "未选择视频"; - elements.videoMeta.textContent = "支持 MP4、WebM 等浏览器可播放格式"; - elements.emptyState.hidden = false; - elements.runDetectionButton.disabled = true; - await resetSession(); - clearDetectionResults(); -}); - -updateClock(); -setInterval(updateClock, 1000); -resetSession(); -renderRobots(); -Promise.all([loadDashboard(), loadEvents()]); diff --git a/frontend/index.html b/frontend/index.html index bf0b116..fe82512 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -68,7 +68,7 @@
-

SAFETY INDEX

综合安全指数

较昨日 +2.4%
+

SAFETY INDEX

综合安全指数

86安全
机器人可用率--
事件闭环率--
通知覆盖率--
@@ -84,7 +84,7 @@

RISK MIX

风险类型分布

近 7 日
-
0事件
火焰0%
烟雾0%
设备12%
+
0事件
火焰0%
烟雾0%
@@ -165,7 +165,7 @@
支持平台0企业微信与飞书
已配置0可接收告警消息
-
成功投递0本次服务运行期间
+
成功投递0累计(已持久化)
发送失败0需要检查网络或凭据
@@ -192,7 +192,7 @@

SYSTEM SETTINGS

系统设置

查看当前生效的模型和告警参数。配置修改后需重启服务。

MODEL

模型配置

权重文件
--
输入尺寸
--
置信度阈值
--
IOU 阈值
--
-

ALERT POLICY

告警策略

连续确认
--
分类冷却
--
事件存储
进程内最近 500 条
视频处理
浏览器本地抽帧
+

ALERT POLICY

告警策略

连续确认
--
分类冷却
--
事件存储
SQLite 持久化 · 最近 500 条
视频处理
浏览器本地抽帧

ENVIRONMENT

环境变量参考

YOLO_WEIGHTS=...
 YOLO_DEVICE=0
@@ -206,6 +206,6 @@ ALERT_COOLDOWN_SECONDS=60
- + diff --git a/frontend/main.js b/frontend/main.js new file mode 100644 index 0000000..22d92fc --- /dev/null +++ b/frontend/main.js @@ -0,0 +1,37 @@ +import { qs, VIEW_LABELS } from "./modules/dom.js"; +import { updateClock } from "./modules/ui.js"; +import { loadDashboard } from "./views/dashboard.js"; +import { loadEvents, initEvents } from "./views/events.js"; +import { loadRobots, initRobots } from "./views/robots.js"; +import { initInspection, resetSession } from "./views/inspection.js"; +import { renderRobots } from "./views/robotCards.js"; + +function switchView(viewName) { + if (!VIEW_LABELS[viewName]) return; + document.querySelectorAll("[data-view-panel]").forEach((panel) => { + panel.classList.toggle("is-active", panel.dataset.viewPanel === viewName); + }); + document.querySelectorAll("[data-view]").forEach((button) => { + button.classList.toggle("is-active", button.dataset.view === viewName); + }); + qs("#viewTitle").textContent = VIEW_LABELS[viewName]; + qs("#viewBreadcrumb").textContent = VIEW_LABELS[viewName]; + qs("#sidebar").classList.remove("is-open"); + if (viewName === "events") loadEvents(); + if (viewName === "robots") loadRobots(); + if (["overview", "risks", "settings"].includes(viewName)) loadDashboard(); +} + +document.querySelectorAll("[data-view], [data-view-jump]").forEach((control) => { + control.addEventListener("click", () => switchView(control.dataset.view || control.dataset.viewJump)); +}); +qs("#menuButton").addEventListener("click", () => qs("#sidebar").classList.toggle("is-open")); + +initInspection(); +initEvents(); +initRobots(); +updateClock(); +setInterval(updateClock, 1000); +resetSession(); +renderRobots(); +Promise.all([loadDashboard(), loadEvents()]); diff --git a/frontend/modules/api.js b/frontend/modules/api.js new file mode 100644 index 0000000..908cf76 --- /dev/null +++ b/frontend/modules/api.js @@ -0,0 +1,8 @@ +export async function apiFetch(url, options = {}) { + const response = await fetch(url, options); + const result = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(result.detail || `API ${response.status}`); + } + return result; +} diff --git a/frontend/modules/charts.js b/frontend/modules/charts.js new file mode 100644 index 0000000..f6363dd --- /dev/null +++ b/frontend/modules/charts.js @@ -0,0 +1,30 @@ +import { qs } from "./dom.js"; + +export function renderTrend(events = []) { + const days = Array.from({ length: 7 }, (_, index) => { + const date = new Date(); + date.setDate(date.getDate() - (6 - index)); + return { + key: date.toISOString().slice(0, 10), + label: `${date.getMonth() + 1}/${date.getDate()}`, + fire: 0, + smoke: 0, + }; + }); + events.forEach((event) => { + const classes = event.classes || []; + const day = days.find((item) => item.key === String(event.created_at).slice(0, 10)); + if (!day) return; + if (classes.includes("fire")) day.fire += 1; + if (classes.includes("smoke")) day.smoke += 1; + }); + const width = 720; + const height = 225; + const padding = { left: 34, right: 12, top: 14, bottom: 28 }; + const maxValue = Math.max(3, ...days.flatMap((day) => [day.fire, day.smoke])); + const x = (index) => padding.left + index * ((width - padding.left - padding.right) / 6); + const y = (value) => height - padding.bottom - value * ((height - padding.top - padding.bottom) / maxValue); + const path = (key) => days.map((day, index) => `${index ? "L" : "M"}${x(index)},${y(day[key])}`).join(" "); + const gridValues = [0, Math.ceil(maxValue / 2), maxValue]; + qs("#trendChart").innerHTML = `最近七天火焰与烟雾告警趋势${gridValues.map((value) => `${value}`).join("")}${days.map((day, index) => `${day.label}`).join("")}`; +} diff --git a/frontend/modules/dom.js b/frontend/modules/dom.js new file mode 100644 index 0000000..f4db7ef --- /dev/null +++ b/frontend/modules/dom.js @@ -0,0 +1,57 @@ +const elementCache = new Map(); + +export function qs(selector) { + if (!elementCache.has(selector)) { + elementCache.set(selector, document.querySelector(selector)); + } + return elementCache.get(selector); +} + +export function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export const VIEW_LABELS = { + overview: "系统总览", + inspection: "视频巡检", + events: "告警中心", + robots: "消息机器人", + risks: "风险台账", + settings: "系统设置", +}; + +export const STATUS_LABELS = { + pending: "待处置", + acknowledged: "已确认", + resolved: "已解决", +}; + +const CLASS_LABELS = { fire: "火焰", smoke: "烟雾" }; + +export function classLabel(name) { + return CLASS_LABELS[name] || escapeHtml(String(name)); +} + +export function formatDate(value) { + if (!value) return "--"; + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} + +export function formatTime(seconds) { + if (!Number.isFinite(seconds)) return "00:00"; + const minutes = Math.floor(seconds / 60); + const remaining = Math.floor(seconds % 60); + return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`; +} + +export function enabledChannelNames(channels = {}) { + const names = []; + if (channels.wechat) names.push("企业微信"); + if (channels.feishu) names.push("飞书"); + return names; +} diff --git a/frontend/modules/store.js b/frontend/modules/store.js new file mode 100644 index 0000000..637972e --- /dev/null +++ b/frontend/modules/store.js @@ -0,0 +1,6 @@ +// Shared view state. Keeping it here (instead of inside one view module) +// lets views depend on each other without import cycles. +export const store = { + dashboardData: null, + eventData: [], +}; diff --git a/frontend/modules/ui.js b/frontend/modules/ui.js new file mode 100644 index 0000000..b20d165 --- /dev/null +++ b/frontend/modules/ui.js @@ -0,0 +1,36 @@ +import { qs } from "./dom.js"; + +export function showToast(message, type = "info") { + const toast = document.createElement("div"); + toast.className = `toast ${type}`; + toast.textContent = message; + qs("#toastRegion").appendChild(toast); + setTimeout(() => toast.remove(), 3600); +} + +export function setServiceStatus(online) { + const statusChip = qs("#globalStatus"); + statusChip.textContent = online ? "服务运行正常" : "服务连接异常"; + statusChip.className = `status-chip ${online ? "status-online" : "status-offline"}`; + qs("#sidebarStatus").textContent = online ? "服务在线" : "服务离线"; + qs("#sidebarStatusDot").className = `status-dot ${online ? "is-online" : "is-offline"}`; + const readyApi = qs("#readyApi"); + readyApi.textContent = online ? "正常" : "异常"; + readyApi.className = online ? "ready" : "not-ready"; +} + +export function setChannelReady(element, enabled) { + element.textContent = enabled ? "已启用" : "未配置"; + element.className = enabled ? "ready" : "optional"; +} + +export function updateClock() { + qs("#currentClock").textContent = new Date().toLocaleString("zh-CN", { + hour12: false, + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} diff --git a/frontend/styles.css b/frontend/styles.css index c4df6f7..565a7dc 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -207,7 +207,6 @@ body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-even .stat-card:hover { transform: translateY(-2px); border-color: #31545d; } .dashboard-command-grid { display: grid; grid-template-columns: minmax(280px, .9fr) minmax(390px, 1.35fr) minmax(260px, .78fr); gap: 14px; margin-top: 14px; } .dashboard-command-grid .panel { min-height: 290px; } -.trend-badge { border: 1px solid #29664b; padding: 5px 8px; background: var(--green-soft); color: var(--green); font-size: 9px; } .safety-score-body { display: grid; height: calc(100% - 48px); grid-template-columns: 150px 1fr; align-items: center; gap: 20px; } .safety-gauge { --score: 86; position: relative; display: grid; width: 144px; height: 144px; place-items: center; border-radius: 50%; background: conic-gradient(var(--cyan) calc(var(--score) * 1%), #1a3039 0); box-shadow: 0 0 36px #41d6c316; } .safety-gauge::before { content: ""; position: absolute; inset: 12px; border: 1px solid #2c444d; border-radius: 50%; background: var(--surface); } @@ -222,9 +221,9 @@ body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-even .message-flow { display: grid; min-height: 190px; grid-template-columns: minmax(95px, 1fr) 36px minmax(95px, 1fr) 36px minmax(120px, 1.2fr); align-items: center; gap: 8px; border: 1px solid var(--line); padding: 18px; background: linear-gradient(145deg, #0b1820, #0e222b); } .flow-node { display: grid; min-height: 100px; align-content: center; justify-items: center; gap: 7px; border: 1px solid #2a4650; padding: 12px; background: #102630c7; text-align: center; }.flow-node > span { color: var(--cyan); font-size: 9px; letter-spacing: .12em; }.flow-node strong { font-size: 11px; }.flow-node small { color: var(--muted); font-size: 8px; line-height: 1.5; }.flow-node.policy { border-color: #37645e; background: #13302f; }.flow-node.channel { min-height: 76px; grid-template-columns: 28px 1fr; justify-items: start; gap: 2px 9px; text-align: left; }.flow-node.channel > span { grid-row: 1 / 3; display: grid; width: 28px; height: 28px; place-items: center; color: white; font-weight: 800; }.flow-node.channel small { grid-column: 2; }.wechat-dot { background: #20b969; }.feishu-dot { background: #3370ff; }.flow-destinations { display: grid; gap: 9px; }.flow-connector { position: relative; height: 1px; background: #31515b; }.flow-connector::after { content: ""; position: absolute; top: -3px; right: -1px; border-width: 4px 0 4px 6px; border-style: solid; border-color: transparent transparent transparent var(--cyan); }.flow-foot { display: flex; justify-content: space-between; gap: 15px; padding-top: 12px; color: var(--muted); font-size: 9px; }.flow-foot strong { color: var(--cyan); font-size: 9px; } .donut-layout { display: grid; height: calc(100% - 45px); align-items: center; justify-items: center; gap: 20px; } -.risk-donut { position: relative; display: grid; width: 142px; height: 142px; place-items: center; border-radius: 50%; background: conic-gradient(var(--red) 0 45%, var(--cyan) 45% 78%, var(--orange) 78% 100%); } +.risk-donut { position: relative; display: grid; width: 142px; height: 142px; place-items: center; border-radius: 50%; background: conic-gradient(var(--line) 0 100%); } .risk-donut::before { content: ""; position: absolute; inset: 18px; border-radius: 50%; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }.risk-donut > div { position: relative; display: grid; justify-items: center; }.risk-donut strong { font-size: 30px; }.risk-donut span { color: var(--muted); font-size: 9px; } -.donut-legend { display: grid; width: 100%; gap: 8px; }.donut-legend div { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); padding: 7px 0; font-size: 10px; }.donut-legend span { display: flex; align-items: center; gap: 7px; color: var(--muted); }.donut-legend i { width: 8px; height: 8px; }.legend-device { display: inline-block; background: var(--orange); } +.donut-legend { display: grid; width: 100%; gap: 8px; }.donut-legend div { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); padding: 7px 0; font-size: 10px; }.donut-legend span { display: flex; align-items: center; gap: 7px; color: var(--muted); }.donut-legend i { width: 8px; height: 8px; } .overview-grid-lower { margin-top: 14px; } /* Messaging robots */ diff --git a/frontend/views/dashboard.js b/frontend/views/dashboard.js new file mode 100644 index 0000000..cc3a210 --- /dev/null +++ b/frontend/views/dashboard.js @@ -0,0 +1,104 @@ +import { qs, escapeHtml, formatDate, STATUS_LABELS, classLabel, enabledChannelNames } from "../modules/dom.js"; +import { apiFetch } from "../modules/api.js"; +import { showToast, setServiceStatus, setChannelReady } from "../modules/ui.js"; +import { renderTrend } from "../modules/charts.js"; +import { renderRobots } from "./robotCards.js"; +import { store } from "../modules/store.js"; + +export async function loadDashboard() { + try { + const data = await apiFetch("/api/dashboard"); + setServiceStatus(true); + updateDashboard(data); + } catch (error) { + setServiceStatus(false); + showToast(`无法读取管理数据:${error.message}`, "error"); + } +} + +function updateDashboard(data) { + store.dashboardData = data; + const { summary, system, detection } = data; + const channels = system.alert_channels || {}; + const channelNames = enabledChannelNames(channels); + qs("#todayEventCount").textContent = String(summary.today); + qs("#pendingEventCount").textContent = String(summary.pending); + qs("#pendingNavCount").textContent = String(summary.pending); + qs("#notificationDot").hidden = summary.pending === 0; + qs("#modelStatusValue").textContent = system.weights_available ? "运行正常" : "权重缺失"; + qs("#modelStatusDetail").textContent = system.weights_available ? "模型文件已就绪" : "请检查 YOLO_WEIGHTS"; + qs("#modelStatusValue").className = "text-value"; + const robotPayload = data.robots || { robots: [], summary: {} }; + const robotSummary = robotPayload.summary || {}; + qs("#onlineRobotCount").textContent = String(robotSummary.configured || 0); + qs("#robotSummary").textContent = `${robotSummary.configured || 0} / ${robotSummary.total || 2} 已配置 · 成功投递 ${robotSummary.delivered || 0} 次`; + qs("#overviewMessage").textContent = summary.pending + ? `当前有 ${summary.pending} 条告警事件等待处置,请尽快进入告警中心确认。` + : "当前无待处置事件,模型与视频巡检服务保持监测状态。"; + qs("#readyWeights").textContent = system.weights_available ? "已就绪" : "缺失"; + qs("#readyWeights").className = system.weights_available ? "ready" : "not-ready"; + setChannelReady(qs("#readyWechat"), channels.wechat); + setChannelReady(qs("#readyFeishu"), channels.feishu); + qs("#riskPendingValue").textContent = String(summary.pending); + qs("#riskChannelValue").textContent = `${channelNames.length} / 2`; + qs("#riskConfidenceValue").textContent = `${Math.round(detection.confidence * 100)}%`; + qs("#confirmFramesValue").textContent = `${detection.confirm_frames} 帧`; + qs("#cooldownValue").textContent = `${detection.cooldown_seconds} 秒`; + qs("#settingWeights").textContent = system.weights; + qs("#settingImageSize").textContent = `${detection.image_size} px`; + qs("#settingConfidence").textContent = detection.confidence.toFixed(2); + qs("#settingIou").textContent = detection.iou.toFixed(2); + qs("#settingConfirmFrames").textContent = `${detection.confirm_frames} 帧`; + qs("#settingCooldown").textContent = `${detection.cooldown_seconds} 秒`; + renderRecentEvents(data.recent_events || []); + const visualEvents = store.eventData.length ? store.eventData : data.recent_events || []; + renderTrend(visualEvents); + updateCommandVisuals(visualEvents, summary, channels, robotSummary); + renderRobots(robotPayload, detection); +} + +function updateCommandVisuals(events, summary, channels, robotSummary = {}) { + const totalRobots = robotSummary.total || 2; + const configuredRobots = robotSummary.configured || 0; + const total = summary.total || 0; + const closureRate = total ? Math.round(((summary.resolved || 0) / total) * 100) : 100; + const notificationRate = Math.round((configuredRobots / totalRobots) * 100); + const safetyScore = Math.max(42, Math.min(98, Math.round(notificationRate * 0.35 + closureRate * 0.35 + Math.max(0, 100 - (summary.pending || 0) * 8) * 0.3))); + qs("#deviceOnlineRate").textContent = `${notificationRate}%`; + qs("#eventClosureRate").textContent = `${closureRate}%`; + qs("#notificationCoverage").textContent = `${notificationRate}%`; + qs("#safetyScore").textContent = String(safetyScore); + qs("#safetyGauge").style.setProperty("--score", safetyScore); + qs("#overviewWechatState").textContent = channels.wechat ? "已配置" : "未配置"; + qs("#overviewFeishuState").textContent = channels.feishu ? "已配置" : "未配置"; + qs("#overviewPolicyText").textContent = `${store.dashboardData?.detection?.confirm_frames || 3} 帧确认`; + qs("#overviewDeliveryCount").textContent = `累计成功 ${robotSummary.delivered || 0} 次`; + + let fireEvents = 0; + let smokeEvents = 0; + events.forEach((event) => { + const classes = event.classes || []; + if (classes.includes("fire")) fireEvents += 1; + if (classes.includes("smoke")) smokeEvents += 1; + }); + const detectedTotal = fireEvents + smokeEvents; + const fireArc = detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0; + const smokeArc = detectedTotal ? 100 - fireArc : 0; + qs("#riskTotal").textContent = String(detectedTotal); + qs("#fireRiskRatio").textContent = `${detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0}%`; + qs("#smokeRiskRatio").textContent = `${detectedTotal ? Math.round((smokeEvents / detectedTotal) * 100) : 0}%`; + qs("#riskDonut").style.background = `conic-gradient(var(--red) 0 ${fireArc}%, var(--cyan) ${fireArc}% ${fireArc + smokeArc}%, var(--line) ${fireArc + smokeArc}% 100%)`; +} + +function renderRecentEvents(events) { + if (!events.length) { + qs("#recentEventList").innerHTML = "

暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。

"; + return; + } + qs("#recentEventList").innerHTML = events.map((event) => { + const classes = event.classes || []; + const primary = classes.includes("fire") ? "fire" : "smoke"; + const label = classes.map(classLabel).join("、"); + return `
${primary === "fire" ? "火" : "烟"}
${label}检测告警${formatDate(event.created_at)} · ${escapeHtml(event.id)}
${Math.round(event.max_confidence * 100)}%${STATUS_LABELS[event.status] || escapeHtml(event.status)}
`; + }).join(""); +} diff --git a/frontend/views/events.js b/frontend/views/events.js new file mode 100644 index 0000000..188d630 --- /dev/null +++ b/frontend/views/events.js @@ -0,0 +1,77 @@ +import { qs, escapeHtml, STATUS_LABELS, classLabel, formatDate, enabledChannelNames } from "../modules/dom.js"; +import { apiFetch } from "../modules/api.js"; +import { showToast, setServiceStatus } from "../modules/ui.js"; +import { renderTrend } from "../modules/charts.js"; +import { store } from "../modules/store.js"; +import { loadDashboard } from "./dashboard.js"; + +let eventFilter = ""; + +export async function loadEvents() { + try { + const query = eventFilter ? `?status=${eventFilter}` : ""; + const result = await apiFetch(`/api/events${query}`); + store.eventData = result.events || []; + renderEventTable(store.eventData); + updateEventSummary(result.summary); + renderTrend(store.eventData); + setServiceStatus(true); + } catch (error) { + setServiceStatus(false); + showToast(`无法读取告警事件:${error.message}`, "error"); + } +} + +function updateEventSummary(summary) { + qs("#allEventsCount").textContent = String(summary.total); + qs("#pendingEventsCount").textContent = String(summary.pending); + qs("#ackEventsCount").textContent = String(summary.acknowledged); + qs("#resolvedEventsCount").textContent = String(summary.resolved); + qs("#pendingNavCount").textContent = String(summary.pending); + qs("#notificationDot").hidden = summary.pending === 0; +} + +function renderEventTable(events) { + qs("#eventTableMeta").textContent = `${events.length} 条记录`; + if (!events.length) { + qs("#eventTableBody").innerHTML = '当前筛选条件下暂无告警事件'; + return; + } + qs("#eventTableBody").innerHTML = events.map((event) => { + const classes = (event.classes || []) + .map((name) => `${classLabel(name)}`) + .join(" "); + const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送"; + const statusLabel = STATUS_LABELS[event.status] || escapeHtml(event.status); + return `${escapeHtml(event.id)}${formatDate(event.created_at)}${classes}${Math.round(event.max_confidence * 100)}%${channels}${statusLabel}
${event.status === "pending" ? `` : ""}${event.status !== "resolved" ? `` : ""}
`; + }).join(""); +} + +export async function updateEventStatus(eventId, status) { + try { + await apiFetch(`/api/events/${encodeURIComponent(eventId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + showToast(`事件 ${eventId} 已更新为${STATUS_LABELS[status]}`, "success"); + await Promise.all([loadEvents(), loadDashboard()]); + } catch (error) { + showToast(`事件更新失败:${error.message}`, "error"); + } +} + +export function initEvents() { + qs("#refreshEventsButton").addEventListener("click", loadEvents); + qs("#eventTableBody").addEventListener("click", (event) => { + const button = event.target.closest("[data-event-id]"); + if (button) updateEventStatus(button.dataset.eventId, button.dataset.eventStatus); + }); + document.querySelectorAll("[data-event-filter]").forEach((button) => { + button.addEventListener("click", () => { + eventFilter = button.dataset.eventFilter; + document.querySelectorAll("[data-event-filter]").forEach((item) => item.classList.toggle("is-active", item === button)); + loadEvents(); + }); + }); +} diff --git a/frontend/views/inspection.js b/frontend/views/inspection.js new file mode 100644 index 0000000..5736b37 --- /dev/null +++ b/frontend/views/inspection.js @@ -0,0 +1,196 @@ +import { qs, classLabel, formatTime, enabledChannelNames } from "../modules/dom.js"; +import { apiFetch } from "../modules/api.js"; +import { showToast, setServiceStatus } from "../modules/ui.js"; +import { loadDashboard } from "./dashboard.js"; + +const API_ENDPOINT = "/api/detect"; +const frameCanvas = document.createElement("canvas"); +let videoUrl = null; +let sessionId = null; +let detectionActive = false; +let requestInFlight = false; +let lastDetectionAt = 0; + +function setInspectionStatus(text, className) { + const status = qs("#inspectionStatus"); + status.textContent = text; + status.className = className; +} + +function clearDetectionResults() { + qs("#smokeCount").textContent = "0"; + qs("#fireCount").textContent = "0"; + qs("#maxConfidence").textContent = "--"; + qs("#riskStatus").textContent = "待机"; + qs("#detectionList").innerHTML = '

暂无检测结果

'; + const context = qs("#overlayCanvas").getContext("2d"); + context.clearRect(0, 0, qs("#overlayCanvas").width, qs("#overlayCanvas").height); +} + +function drawDetections(detections = []) { + const video = qs("#videoPreview"); + if (!video.videoWidth || !video.videoHeight) return; + const canvas = qs("#overlayCanvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvas.width, canvas.height); + detections.forEach((detection) => { + const [x1, y1, x2, y2] = detection.box || []; + const color = detection.class === "fire" ? "#ff665e" : "#41d6c3"; + context.strokeStyle = color; + context.lineWidth = Math.max(2, canvas.width / 320); + context.strokeRect(x1, y1, x2 - x1, y2 - y1); + context.fillStyle = color; + context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`; + context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6)); + }); +} + +function updateDetectionResults(result) { + const detections = result.detections || []; + const smoke = detections.filter((item) => item.class === "smoke").length; + const fire = detections.filter((item) => item.class === "fire").length; + const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0); + qs("#smokeCount").textContent = String(smoke); + qs("#fireCount").textContent = String(fire); + qs("#maxConfidence").textContent = max ? `${Math.round(max * 100)}%` : "--"; + qs("#riskStatus").textContent = fire ? "高风险" : smoke ? "需关注" : "正常"; + qs("#detectionList").innerHTML = detections.length + ? detections.map((item) => `
${classLabel(item.class)}${Math.round(item.confidence * 100)}%
`).join("") + : '

未发现烟雾或火焰目标

'; + const alert = result.alert || {}; + const channelNames = enabledChannelNames(alert.notification_channels); + if (alert.triggered) { + const labels = alert.classes.map(classLabel).join("、"); + const alertStatus = qs("#alertStatus"); + alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`; + alertStatus.className = "alert-status alert-triggered"; + showToast(`检测到${labels},已生成告警事件`, "error"); + loadDashboard(); + } else { + const fireFrames = alert.consecutive?.fire || 0; + const smokeFrames = alert.consecutive?.smoke || 0; + const alertStatus = qs("#alertStatus"); + alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`; + alertStatus.className = "alert-status"; + } + drawDetections(detections); + qs("#lastUpdated").textContent = `推理 ${result.inference_ms ?? "--"} ms`; +} + +function captureFrame() { + const video = qs("#videoPreview"); + frameCanvas.width = video.videoWidth; + frameCanvas.height = video.videoHeight; + frameCanvas.getContext("2d").drawImage(video, 0, 0); + return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88)); +} + +async function detectCurrentFrame(timestamp) { + const video = qs("#videoPreview"); + if (!detectionActive || requestInFlight || !sessionId || video.paused || video.ended) return; + const interval = Number(qs("#intervalSelect").value); + if (timestamp - lastDetectionAt < interval) return; + lastDetectionAt = timestamp; + requestInFlight = true; + qs("#loadingState").hidden = false; + try { + const frame = await captureFrame(); + if (!frame) throw new Error("无法截取视频帧"); + const form = new FormData(); + form.append("file", frame, "video-frame.jpg"); + const result = await apiFetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form }); + updateDetectionResults(result); + setInspectionStatus("检测运行中", "status-chip status-online"); + setServiceStatus(true); + } catch (error) { + setInspectionStatus("检测异常", "status-chip status-offline"); + showToast(`视频检测失败:${error.message}`, "error"); + } finally { + requestInFlight = false; + qs("#loadingState").hidden = true; + } +} + +function scheduleDetection(timestamp) { + detectCurrentFrame(timestamp); + if (detectionActive) requestAnimationFrame(scheduleDetection); +} + +async function startDetection() { + const video = qs("#videoPreview"); + if (!video.src) return; + detectionActive = true; + lastDetectionAt = -Infinity; + qs("#runDetectionButton").textContent = "停止检测"; + qs("#intervalSelect").disabled = true; + try { + await video.play(); + requestAnimationFrame(scheduleDetection); + } catch { + stopDetection(); + showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error"); + } +} + +function stopDetection() { + detectionActive = false; + qs("#runDetectionButton").textContent = "开始连续检测"; + qs("#intervalSelect").disabled = false; + const video = qs("#videoPreview"); + setInspectionStatus(video.src ? "检测已暂停" : "等待视频", "status-chip status-idle"); +} + +export async function resetSession() { + const previous = sessionId; + sessionId = crypto.randomUUID(); + if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {}); +} + +export function initInspection() { + const video = qs("#videoPreview"); + qs("#runDetectionButton").addEventListener("click", () => detectionActive ? stopDetection() : startDetection()); + video.addEventListener("ended", stopDetection); + video.addEventListener("timeupdate", () => { + qs("#videoClock").textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`; + }); + video.addEventListener("seeked", async () => { + drawDetections([]); + await resetSession(); + }); + video.addEventListener("resize", () => drawDetections([])); + qs("#videoInput").addEventListener("change", async () => { + const [file] = qs("#videoInput").files; + if (!file) return; + stopDetection(); + video.pause(); + if (videoUrl) URL.revokeObjectURL(videoUrl); + videoUrl = URL.createObjectURL(file); + video.src = videoUrl; + video.hidden = false; + qs("#emptyState").hidden = true; + qs("#sourceLabel").textContent = file.name; + qs("#videoMeta").textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`; + qs("#runDetectionButton").disabled = false; + setInspectionStatus("视频已就绪", "status-chip status-online"); + await resetSession(); + clearDetectionResults(); + }); + qs("#clearButton").addEventListener("click", async () => { + stopDetection(); + video.pause(); + video.removeAttribute("src"); + video.load(); + video.hidden = true; + if (videoUrl) URL.revokeObjectURL(videoUrl); + videoUrl = null; + qs("#videoInput").value = ""; + qs("#sourceLabel").textContent = "未选择视频"; + qs("#videoMeta").textContent = "支持 MP4、WebM 等浏览器可播放格式"; + qs("#emptyState").hidden = false; + qs("#runDetectionButton").disabled = true; + await resetSession(); + clearDetectionResults(); + }); +} diff --git a/frontend/views/robotCards.js b/frontend/views/robotCards.js new file mode 100644 index 0000000..8ec1194 --- /dev/null +++ b/frontend/views/robotCards.js @@ -0,0 +1,39 @@ +import { qs, escapeHtml, formatDate } from "../modules/dom.js"; + +export function robotCardTemplate(robot) { + const id = escapeHtml(robot.id); + const platformClass = robot.id === "wechat" ? "wechat" : "feishu"; + const platformMark = robot.id === "wechat" ? "微" : "飞"; + const statusLabel = robot.configured ? "已配置" : "未配置"; + const lastActivity = robot.last_test_at || robot.last_delivery_at; + const activityText = lastActivity ? formatDate(lastActivity) : "尚无发送记录"; + const statusText = robot.last_status === "success" ? "最近发送成功" : robot.last_status === "failed" ? "最近发送失败" : "等待首次发送"; + const credential = robot.id === "wechat" ? "WECHAT_WEBHOOK_URL" : "FEISHU_WEBHOOK_URL"; + const capabilities = (robot.capabilities || []) + .map((capability) => `${escapeHtml(capability)}`) + .join(""); + return `
+
${escapeHtml(robot.id.toUpperCase())} ROBOT

${escapeHtml(robot.name)}群机器人

${robot.id === "wechat" ? "群聊 Markdown 与告警截图" : "群聊交互卡片与签名校验"}

${statusLabel}
+
${capabilities}
+
成功投递${robot.delivered}
失败${robot.failed}
连接测试${robot.tests}
+
${statusText}${activityText}
+
服务端配置${credential}${robot.id === "feishu" ? " / FEISHU_SECRET" : ""}
+ ${robot.last_error ? `

${escapeHtml(robot.last_error)}

` : ""} + +
`; +} + +export function renderRobots(payload = { robots: [], summary: {} }, policy = {}) { + const robots = payload.robots || []; + const summary = payload.summary || {}; + qs("#robotTotalCount").textContent = String(summary.total || robots.length); + qs("#robotConfiguredCount").textContent = String(summary.configured || 0); + qs("#robotDeliveredCount").textContent = String(summary.delivered || 0); + qs("#robotFailedCount").textContent = String(summary.failed || 0); + qs("#robotNavCount").textContent = String(summary.configured || 0); + qs("#robotPolicyConfirm").textContent = `${policy.confirm_frames || 3} 帧连续命中后发送`; + qs("#robotPolicyCooldown").textContent = `${policy.cooldown_seconds || 60} 秒内同类不重复发送`; + qs("#robotCardGrid").innerHTML = robots.length + ? robots.map(robotCardTemplate).join("") + : '
暂无机器人通道数据
'; +} diff --git a/frontend/views/robots.js b/frontend/views/robots.js new file mode 100644 index 0000000..e1e4c4f --- /dev/null +++ b/frontend/views/robots.js @@ -0,0 +1,41 @@ +import { qs } from "../modules/dom.js"; +import { apiFetch } from "../modules/api.js"; +import { showToast, setServiceStatus } from "../modules/ui.js"; +import { renderRobots } from "./robotCards.js"; +import { store } from "../modules/store.js"; +import { loadDashboard } from "./dashboard.js"; + +export async function loadRobots() { + try { + const result = await apiFetch("/api/robots"); + renderRobots(result, store.dashboardData?.detection); + setServiceStatus(true); + } catch (error) { + setServiceStatus(false); + showToast(`无法读取机器人状态:${error.message}`, "error"); + } +} + +export async function testRobot(channel, button) { + const originalText = button.textContent; + button.disabled = true; + button.textContent = "正在发送"; + try { + const result = await apiFetch(`/api/robots/${encodeURIComponent(channel)}/test`, { method: "POST" }); + showToast(`${result.robot.name}测试消息发送成功`, "success"); + await Promise.all([loadRobots(), loadDashboard()]); + } catch (error) { + showToast(`测试消息发送失败:${error.message}`, "error"); + } finally { + button.disabled = false; + button.textContent = originalText; + } +} + +export function initRobots() { + qs("#refreshRobotsButton").addEventListener("click", loadRobots); + qs("#robotCardGrid").addEventListener("click", (event) => { + const button = event.target.closest("[data-test-robot]"); + if (button) testRobot(button.dataset.testRobot, button); + }); +} diff --git a/models/pretrained/README.md b/models/pretrained/README.md index 82d471e..1b2c3d6 100644 --- a/models/pretrained/README.md +++ b/models/pretrained/README.md @@ -1,11 +1,12 @@ # Pretrained Models -本目录保存本地基础模型权重,默认训练权重为 `yolov8n.pt`。 +本目录保存本地缓存的基础模型权重,供首次训练或实验对照使用。这些文件由 `.gitignore` 排除、不提交到 Git,克隆后可从 Ultralytics 官方源重新下载。 当前本地权重: +- `yolo11s.pt` — 训练默认起点(`DEFAULT_PRETRAINED_MODEL`) +- `yolo26s.pt` - `yolov8n.pt` - `yolov8s.pt` -- `yolo26n.pt` -权重文件通过 `.gitignore` 排除,不提交到 Git。 \ No newline at end of file +`TrainConfig.model_weights` 的默认值是已训练好的 `runs/detect/smoke_fire_yolo11s_v1-4/weights/best.pt`,因此 `fire-yolo train` 默认执行微调(fine-tune)而不是从头训练。 diff --git a/runs/audit/render_missing_label_candidates.py b/runs/audit/render_missing_label_candidates.py index fc62e0a..f1b1ac0 100644 --- a/runs/audit/render_missing_label_candidates.py +++ b/runs/audit/render_missing_label_candidates.py @@ -65,5 +65,6 @@ for index, image_path in enumerate(image_paths, start=1): manifest_path = output_dir / "manifest.json" import json + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") print(f"completed={len(manifest)} manifest={manifest_path}") \ No newline at end of file diff --git a/src/yolo/engine.py b/src/yolo/engine.py index 5448a2f..489d94c 100644 --- a/src/yolo/engine.py +++ b/src/yolo/engine.py @@ -56,8 +56,9 @@ def train( ) _configure_ultralytics() + # Local wall-clock time is the intent for run directory names. run_name = active_config.name or ( - f"smoke_fire_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + f"smoke_fire_{datetime.now().strftime('%Y%m%d_%H%M%S')}" # noqa: DTZ005 ) model = YOLO(str(weights_path)) results = model.train( diff --git a/test/test_alerting.py b/test/test_alerting.py index 0e340bf..02f8207 100644 --- a/test/test_alerting.py +++ b/test/test_alerting.py @@ -1,9 +1,12 @@ from __future__ import annotations +import tempfile import unittest +from pathlib import Path from unittest.mock import patch from backend.alerting import AlertManager +from backend.storage import Database, DeliveryStatsStore class AlertManagerRobotTests(unittest.TestCase): @@ -51,6 +54,28 @@ class AlertManagerRobotTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "Unsupported"): manager.test_channel("unknown") + def test_delivery_stats_persist_across_store_instances(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + db_path = Path(tmp_dir) / "app.db" + first_db = Database(db_path) + store = DeliveryStatsStore(first_db) + store.record("wechat", success=True) + store.record("wechat", success=False, error="boom") + store.record("feishu", success=True, test=True) + first_db.close() + + second_db = Database(db_path) + reloaded = DeliveryStatsStore(second_db) + stats = reloaded.get_all() + second_db.close() + + self.assertEqual(stats["wechat"]["delivered"], 1) + self.assertEqual(stats["wechat"]["failed"], 1) + self.assertEqual(stats["wechat"]["last_status"], "failed") + self.assertEqual(stats["wechat"]["last_error"], "boom") + self.assertEqual(stats["feishu"]["tests"], 1) + self.assertIsNotNone(stats["feishu"]["last_test_at"]) + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/test/test_events.py b/test/test_events.py index 11471f5..55f24b5 100644 --- a/test/test_events.py +++ b/test/test_events.py @@ -1,8 +1,11 @@ from __future__ import annotations +import tempfile import unittest +from pathlib import Path from backend.events import EventStore +from backend.storage import Database class EventStoreTests(unittest.TestCase): @@ -53,6 +56,30 @@ class EventStoreTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "Unsupported"): self.store.update(event["id"], "invalid") + def test_events_persist_across_store_instances(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + db_path = Path(tmp_dir) / "app.db" + first_db = Database(db_path) + first = EventStore(db=first_db) + first.create( + session_id="session", + classes=["smoke"], + detections=[{"class": "smoke", "confidence": 0.55}], + notification_channels={"wechat": False, "feishu": False}, + ) + first_db.close() + + second_db = Database(db_path) + second = EventStore(db=second_db) + events = second.list() + summary_total = second.summary()["total"] + second_db.close() + + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["classes"], ["smoke"]) + self.assertEqual(events[0]["session_id"], "session") + self.assertEqual(summary_total, 1) + if __name__ == "__main__": unittest.main() \ No newline at end of file