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
This commit is contained in:
@@ -4,6 +4,9 @@ YOLO_IMGSZ=768
|
|||||||
YOLO_CONF=0.40
|
YOLO_CONF=0.40
|
||||||
YOLO_IOU=0.45
|
YOLO_IOU=0.45
|
||||||
|
|
||||||
|
# SQLite 数据库路径(事件与机器人投递统计持久化)。
|
||||||
|
YOLO_DB_PATH=data/app.db
|
||||||
|
|
||||||
# 企业微信群机器人,可留空。
|
# 企业微信群机器人,可留空。
|
||||||
WECHAT_WEBHOOK_URL=
|
WECHAT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -1,18 +1,20 @@
|
|||||||
# Smoke and Fire YOLO
|
# 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
|
## Project layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
configs/datasets/smoke_fire.yaml Dataset configuration
|
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/cli.py Command-line entry point
|
||||||
src/yolo/config.py Training configuration
|
src/yolo/config.py Training configuration
|
||||||
src/yolo/defaults.py Project defaults
|
src/yolo/defaults.py Project defaults
|
||||||
src/yolo/engine.py Train, validate, predict, and export
|
src/yolo/engine.py Train, validate, predict, and export
|
||||||
src/yolo/checkpoints.py Checkpoint discovery and resume support
|
src/yolo/checkpoints.py Checkpoint discovery and resume support
|
||||||
src/yolo/reporting.py Training result reporting
|
src/yolo/reporting.py Training result reporting
|
||||||
|
backend/ FastAPI inference API and alerting
|
||||||
|
frontend/ Browser management platform (no build step)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -21,6 +23,8 @@ src/yolo/reporting.py Training result reporting
|
|||||||
uv sync
|
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
|
## Commands
|
||||||
|
|
||||||
Fine-tune from the current best YOLO11s checkpoint:
|
Fine-tune from the current best YOLO11s checkpoint:
|
||||||
@@ -69,6 +73,9 @@ uv run fire-yolo export --weights runs/detect/<run-name>/weights/best.pt --forma
|
|||||||
- Output: `runs/detect`
|
- Output: `runs/detect`
|
||||||
|
|
||||||
Training writes `last.pt`, `best.pt`, periodic checkpoints, and `best_point.json` to the run directory.
|
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
|
## Web Detection Service
|
||||||
|
|
||||||
After training finishes, install the web dependencies and run the integrated frontend and inference API:
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import time
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
@@ -19,6 +18,8 @@ from urllib.request import Request, urlopen
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from .storage import DeliveryStatsStore, utc_now_iso
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
ALERT_CLASSES = ("fire", "smoke")
|
ALERT_CLASSES = ("fire", "smoke")
|
||||||
CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"}
|
CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"}
|
||||||
@@ -45,6 +46,7 @@ class AlertManager:
|
|||||||
confirm_frames: int = 3,
|
confirm_frames: int = 3,
|
||||||
cooldown_seconds: float = 60.0,
|
cooldown_seconds: float = 60.0,
|
||||||
session_ttl_seconds: float = 3600.0,
|
session_ttl_seconds: float = 3600.0,
|
||||||
|
stats_store: DeliveryStatsStore | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.wechat_webhook_url = (
|
self.wechat_webhook_url = (
|
||||||
wechat_webhook_url
|
wechat_webhook_url
|
||||||
@@ -58,6 +60,7 @@ class AlertManager:
|
|||||||
self.confirm_frames = max(1, confirm_frames)
|
self.confirm_frames = max(1, confirm_frames)
|
||||||
self.cooldown_seconds = max(0.0, cooldown_seconds)
|
self.cooldown_seconds = max(0.0, cooldown_seconds)
|
||||||
self.session_ttl_seconds = max(60.0, session_ttl_seconds)
|
self.session_ttl_seconds = max(60.0, session_ttl_seconds)
|
||||||
|
self.stats_store = stats_store
|
||||||
self._states: dict[str, SessionState] = {}
|
self._states: dict[str, SessionState] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._delivery_stats = {
|
self._delivery_stats = {
|
||||||
@@ -92,11 +95,27 @@ class AlertManager:
|
|||||||
@property
|
@property
|
||||||
def channel_status(self) -> list[dict[str, Any]]:
|
def channel_status(self) -> list[dict[str, Any]]:
|
||||||
configured = self.channels
|
configured = self.channels
|
||||||
with self._lock:
|
if self.stats_store is not None:
|
||||||
|
saved = self.stats_store.get_all()
|
||||||
stats = {
|
stats = {
|
||||||
channel: dict(values)
|
channel: {
|
||||||
for channel, values in self._delivery_stats.items()
|
"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 [
|
return [
|
||||||
{
|
{
|
||||||
"id": channel,
|
"id": channel,
|
||||||
@@ -191,6 +210,10 @@ class AlertManager:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._states.pop(session_id, None)
|
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:
|
def _prune_sessions(self, now: float) -> None:
|
||||||
expired = [
|
expired = [
|
||||||
session_id
|
session_id
|
||||||
@@ -230,7 +253,15 @@ class AlertManager:
|
|||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
test: bool = False,
|
test: bool = False,
|
||||||
) -> None:
|
) -> 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:
|
with self._lock:
|
||||||
stats = self._delivery_stats[channel]
|
stats = self._delivery_stats[channel]
|
||||||
stats["last_status"] = "success" if success else "failed"
|
stats["last_status"] = "success" if success else "failed"
|
||||||
@@ -386,7 +417,7 @@ def alert_summary(
|
|||||||
|
|
||||||
|
|
||||||
def feishu_signature(timestamp: str, secret: str) -> str:
|
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()
|
digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
|
||||||
return base64.b64encode(digest).decode("ascii")
|
return base64.b64encode(digest).decode("ascii")
|
||||||
|
|
||||||
@@ -429,7 +460,7 @@ def annotate_image(
|
|||||||
class_name = str(detection.get("class", "target"))
|
class_name = str(detection.get("class", "target"))
|
||||||
confidence = float(detection.get("confidence", 0.0))
|
confidence = float(detection.get("confidence", 0.0))
|
||||||
color = colors.get(class_name, "#ffd166")
|
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.rectangle(coordinates, outline=color, width=4)
|
||||||
draw.text(
|
draw.text(
|
||||||
(coordinates[0] + 4, max(0, coordinates[1] - 16)),
|
(coordinates[0] + 4, max(0, coordinates[1] - 16)),
|
||||||
|
|||||||
@@ -1,18 +1,44 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from collections import deque
|
from datetime import UTC, datetime
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .storage import Database, utc_now_iso
|
||||||
|
|
||||||
EVENT_STATUSES = {"pending", "acknowledged", "resolved"}
|
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:
|
class EventStore:
|
||||||
def __init__(self, max_events: int = 500) -> None:
|
"""Persistent alert event store backed by a single SQLite table.
|
||||||
self._events: deque[dict[str, Any]] = deque(maxlen=max_events)
|
|
||||||
self._lock = threading.Lock()
|
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(
|
def create(
|
||||||
self,
|
self,
|
||||||
@@ -21,7 +47,6 @@ class EventStore:
|
|||||||
detections: list[dict[str, Any]],
|
detections: list[dict[str, Any]],
|
||||||
notification_channels: dict[str, bool],
|
notification_channels: dict[str, bool],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
now = datetime.now().astimezone()
|
|
||||||
confidences = [
|
confidences = [
|
||||||
float(detection.get("confidence", 0.0))
|
float(detection.get("confidence", 0.0))
|
||||||
for detection in detections
|
for detection in detections
|
||||||
@@ -29,7 +54,7 @@ class EventStore:
|
|||||||
]
|
]
|
||||||
event = {
|
event = {
|
||||||
"id": uuid.uuid4().hex[:12],
|
"id": uuid.uuid4().hex[:12],
|
||||||
"created_at": now.isoformat(timespec="seconds"),
|
"created_at": utc_now_iso(),
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"classes": classes,
|
"classes": classes,
|
||||||
"max_confidence": round(max(confidences, default=0.0), 6),
|
"max_confidence": round(max(confidences, default=0.0), 6),
|
||||||
@@ -38,17 +63,44 @@ class EventStore:
|
|||||||
"notification_channels": notification_channels,
|
"notification_channels": notification_channels,
|
||||||
"handled_at": None,
|
"handled_at": None,
|
||||||
}
|
}
|
||||||
with self._lock:
|
self._db.execute(
|
||||||
self._events.appendleft(event)
|
"""
|
||||||
return dict(event)
|
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(
|
def list(
|
||||||
self,
|
self,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
with self._lock:
|
rows = self._db.execute(
|
||||||
events = [dict(event) for event in self._events]
|
"SELECT * FROM events ORDER BY rowid DESC",
|
||||||
|
).fetchall()
|
||||||
|
events = [self._to_dict(row) for row in rows]
|
||||||
if status:
|
if status:
|
||||||
events = [event for event in events if event["status"] == status]
|
events = [event for event in events if event["status"] == status]
|
||||||
return events[: max(1, min(limit, 500))]
|
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:
|
def update(self, event_id: str, status: str) -> dict[str, Any] | None:
|
||||||
if status not in EVENT_STATUSES:
|
if status not in EVENT_STATUSES:
|
||||||
raise ValueError(f"Unsupported event status: {status}")
|
raise ValueError(f"Unsupported event status: {status}")
|
||||||
with self._lock:
|
cursor = self._db.execute(
|
||||||
for event in self._events:
|
"""
|
||||||
if event["id"] != event_id:
|
UPDATE events SET status = ?, handled_at = ?
|
||||||
continue
|
WHERE id = ?
|
||||||
event["status"] = status
|
""",
|
||||||
event["handled_at"] = (
|
(status, None if status == "pending" else utc_now_iso(), event_id),
|
||||||
None
|
)
|
||||||
if status == "pending"
|
if cursor.rowcount == 0:
|
||||||
else datetime.now().astimezone().isoformat(
|
return None
|
||||||
timespec="seconds"
|
row = self._db.execute(
|
||||||
)
|
"SELECT * FROM events WHERE id = ?",
|
||||||
)
|
(event_id,),
|
||||||
return dict(event)
|
).fetchone()
|
||||||
return None
|
return self._to_dict(row)
|
||||||
|
|
||||||
def summary(self) -> dict[str, int]:
|
def summary(self) -> dict[str, int]:
|
||||||
today = datetime.now().astimezone().date()
|
rows = self._db.execute(
|
||||||
with self._lock:
|
"SELECT created_at, classes, status FROM events",
|
||||||
events = [dict(event) for event in self._events]
|
).fetchall()
|
||||||
today_events = [
|
today_boundary = _local_today_utc_boundary()
|
||||||
event
|
today_events = 0
|
||||||
for event in events
|
counts = {status: 0 for status in EVENT_STATUSES}
|
||||||
if datetime.fromisoformat(event["created_at"]).date() == today
|
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 {
|
return {
|
||||||
"total": len(events),
|
"total": len(rows),
|
||||||
"today": len(today_events),
|
"today": today_events,
|
||||||
"pending": sum(
|
"pending": counts["pending"],
|
||||||
event["status"] == "pending" for event in events
|
"acknowledged": counts["acknowledged"],
|
||||||
),
|
"resolved": counts["resolved"],
|
||||||
"acknowledged": sum(
|
"fire": fire,
|
||||||
event["status"] == "acknowledged" for event in events
|
"smoke": smoke,
|
||||||
),
|
}
|
||||||
"resolved": sum(
|
|
||||||
event["status"] == "resolved" for event in events
|
@staticmethod
|
||||||
),
|
def _to_dict(row: Any) -> dict[str, Any]:
|
||||||
"fire": sum("fire" in event["classes"] for event in events),
|
return {
|
||||||
"smoke": sum("smoke" in event["classes"] for event in events),
|
"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"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,25 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
from dotenv import load_dotenv
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from ultralytics import YOLO
|
from ultralytics import YOLO
|
||||||
|
|
||||||
from .alerting import AlertManager
|
from .alerting import AlertManager
|
||||||
from .events import EVENT_STATUSES, EventStore
|
from .events import EVENT_STATUSES, EventStore
|
||||||
|
from .storage import Database, DeliveryStatsStore
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
||||||
@@ -30,17 +34,37 @@ IOU = float(os.getenv("YOLO_IOU", "0.45"))
|
|||||||
MAX_UPLOAD_BYTES = 15 * 1024 * 1024
|
MAX_UPLOAD_BYTES = 15 * 1024 * 1024
|
||||||
MAX_IMAGE_PIXELS = int(os.getenv("YOLO_MAX_IMAGE_PIXELS", "25000000"))
|
MAX_IMAGE_PIXELS = int(os.getenv("YOLO_MAX_IMAGE_PIXELS", "25000000"))
|
||||||
CLASS_NAMES = {0: "smoke", 1: "fire"}
|
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(
|
ALERT_MANAGER = AlertManager(
|
||||||
confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")),
|
confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")),
|
||||||
cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")),
|
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):
|
class EventStatusUpdate(BaseModel):
|
||||||
status: str
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -77,14 +101,15 @@ def validate_image(payload: bytes) -> Image.Image:
|
|||||||
def predict_image(image: Image.Image) -> dict[str, Any]:
|
def predict_image(image: Image.Image) -> dict[str, Any]:
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
result = get_model().predict(
|
with INFERENCE_LOCK:
|
||||||
source=image,
|
result = get_model().predict(
|
||||||
conf=CONFIDENCE,
|
source=image,
|
||||||
iou=IOU,
|
conf=CONFIDENCE,
|
||||||
imgsz=IMAGE_SIZE,
|
iou=IOU,
|
||||||
device=DEVICE,
|
imgsz=IMAGE_SIZE,
|
||||||
verbose=False,
|
device=DEVICE,
|
||||||
)[0]
|
verbose=False,
|
||||||
|
)[0]
|
||||||
except FileNotFoundError as error:
|
except FileNotFoundError as error:
|
||||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -118,30 +143,25 @@ def health() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/detect")
|
@app.post("/api/detect")
|
||||||
async def detect(
|
def detect(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...), # noqa: B008 — FastAPI dependency idiom
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = await file.read(MAX_UPLOAD_BYTES + 1)
|
payload = file.file.read(MAX_UPLOAD_BYTES + 1)
|
||||||
image = validate_image(payload)
|
image = validate_image(payload)
|
||||||
result = predict_image(image)
|
result = predict_image(image)
|
||||||
result["alert"] = (
|
# Requests without a session share the fixed "single-image" session, so
|
||||||
ALERT_MANAGER.evaluate(
|
# the consecutive-frame confirmation works there as well. Reset it with
|
||||||
session_id,
|
# DELETE /api/sessions/single-image.
|
||||||
result["detections"],
|
active_session = session_id or "single-image"
|
||||||
image,
|
result["alert"] = ALERT_MANAGER.evaluate(
|
||||||
)
|
active_session,
|
||||||
if session_id
|
result["detections"],
|
||||||
else {
|
image,
|
||||||
"triggered": False,
|
|
||||||
"classes": [],
|
|
||||||
"notification_enabled": ALERT_MANAGER.enabled,
|
|
||||||
"notification_channels": ALERT_MANAGER.channels,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
if result["alert"]["triggered"]:
|
if result["alert"]["triggered"]:
|
||||||
result["event"] = EVENT_STORE.create(
|
result["event"] = EVENT_STORE.create(
|
||||||
session_id=session_id or "single-image",
|
session_id=active_session,
|
||||||
classes=result["alert"]["classes"],
|
classes=result["alert"]["classes"],
|
||||||
detections=result["detections"],
|
detections=result["detections"],
|
||||||
notification_channels=result["alert"].get(
|
notification_channels=result["alert"].get(
|
||||||
|
|||||||
137
backend/storage.py
Normal file
137
backend/storage.py
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
571
frontend/app.js
571
frontend/app.js
@@ -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("")
|
|
||||||
: '<article class="panel robot-empty">暂无机器人通道数据</article>';
|
|
||||||
}
|
|
||||||
|
|
||||||
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 `<article class="panel message-robot-card ${robot.configured ? "is-configured" : ""}" data-robot-id="${robot.id}">
|
|
||||||
<div class="message-robot-head"><div class="platform-logo ${platformClass}">${platformMark}</div><div><span class="robot-id">${robot.id.toUpperCase()} ROBOT</span><h3>${robot.name}群机器人</h3><p>${robot.id === "wechat" ? "群聊 Markdown 与告警截图" : "群聊交互卡片与签名校验"}</p></div><span class="robot-status ${robot.configured ? "active" : "offline"}"><i></i>${statusLabel}</span></div>
|
|
||||||
<div class="capability-list">${robot.capabilities.map((capability) => `<span>${capability}</span>`).join("")}</div>
|
|
||||||
<div class="delivery-metrics"><div><span>成功投递</span><strong>${robot.delivered}</strong></div><div><span>失败</span><strong class="${robot.failed ? "danger" : ""}">${robot.failed}</strong></div><div><span>连接测试</span><strong>${robot.tests}</strong></div></div>
|
|
||||||
<div class="robot-activity"><span>${statusText}</span><strong>${activityText}</strong></div>
|
|
||||||
<div class="credential-key"><span>服务端配置</span><code>${credential}${robot.id === "feishu" ? " / FEISHU_SECRET" : ""}</code></div>
|
|
||||||
${robot.last_error ? `<p class="delivery-error">${escapeHtml(robot.last_error)}</p>` : ""}
|
|
||||||
<button class="primary-button test-robot-button" type="button" data-test-robot="${robot.id}" ${robot.configured ? "" : "disabled"}>${robot.configured ? "发送测试消息" : "配置后可测试"}</button>
|
|
||||||
</article>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = "<p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p>";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
elements.recentEventList.innerHTML = events.map((event) => {
|
|
||||||
const primary = event.classes.includes("fire") ? "fire" : "smoke";
|
|
||||||
const label = event.classes.map(classLabel).join("、");
|
|
||||||
return `<div class="event-list-item"><span class="event-type-icon ${primary}">${primary === "fire" ? "火" : "烟"}</span><div class="event-description"><strong>${label}检测告警</strong><span>${formatDate(event.created_at)} · ${event.id}</span></div><strong class="event-confidence">${Math.round(event.max_confidence * 100)}%</strong><span class="event-status status-${event.status}">${STATUS_LABELS[event.status]}</span></div>`;
|
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEventTable(events) {
|
|
||||||
elements.eventTableMeta.textContent = `${events.length} 条记录`;
|
|
||||||
if (!events.length) {
|
|
||||||
elements.eventTableBody.innerHTML = '<tr><td colspan="7" class="empty-cell">当前筛选条件下暂无告警事件</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
elements.eventTableBody.innerHTML = events.map((event) => {
|
|
||||||
const classes = event.classes.map((name) => `<span class="type-tag type-${name}">${classLabel(name)}</span>`).join(" ");
|
|
||||||
const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送";
|
|
||||||
return `<tr><td>${event.id}</td><td>${formatDate(event.created_at)}</td><td>${classes}</td><td>${Math.round(event.max_confidence * 100)}%</td><td>${channels}</td><td><span class="event-status status-${event.status}">${STATUS_LABELS[event.status]}</span></td><td><div class="table-actions">${event.status === "pending" ? `<button class="table-action" data-event-id="${event.id}" data-event-status="acknowledged">确认</button>` : ""}${event.status !== "resolved" ? `<button class="table-action" data-event-id="${event.id}" data-event-status="resolved">解决</button>` : ""}</div></td></tr>`;
|
|
||||||
}).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 = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"><title>最近七天火焰与烟雾告警趋势</title>${gridValues.map((value) => `<line class="chart-grid" x1="${padding.left}" x2="${width - padding.right}" y1="${y(value)}" y2="${y(value)}"></line><text class="chart-axis-label" x="4" y="${y(value) + 3}">${value}</text>`).join("")}<path class="chart-fire" d="${path("fire")}"></path><path class="chart-smoke" d="${path("smoke")}"></path>${days.map((day, index) => `<circle class="chart-point-fire" cx="${x(index)}" cy="${y(day.fire)}" r="3"></circle><circle class="chart-point-smoke" cx="${x(index)}" cy="${y(day.smoke)}" r="3"></circle><text class="chart-axis-label" text-anchor="middle" x="${x(index)}" y="${height - 8}">${day.label}</text>`).join("")}</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDetectionResults() {
|
|
||||||
elements.smokeCount.textContent = "0";
|
|
||||||
elements.fireCount.textContent = "0";
|
|
||||||
elements.maxConfidence.textContent = "--";
|
|
||||||
elements.riskStatus.textContent = "待机";
|
|
||||||
elements.detectionList.innerHTML = '<p class="empty-copy">暂无检测结果</p>';
|
|
||||||
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) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
|
|
||||||
: '<p class="empty-copy">未发现烟雾或火焰目标</p>';
|
|
||||||
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()]);
|
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
<div class="dashboard-command-grid">
|
<div class="dashboard-command-grid">
|
||||||
<article class="panel safety-score-panel">
|
<article class="panel safety-score-panel">
|
||||||
<div class="panel-header"><div><p class="section-kicker">SAFETY INDEX</p><h3>综合安全指数</h3></div><span class="trend-badge">较昨日 +2.4%</span></div>
|
<div class="panel-header"><div><p class="section-kicker">SAFETY INDEX</p><h3>综合安全指数</h3></div></div>
|
||||||
<div class="safety-score-body"><div id="safetyGauge" class="safety-gauge" style="--score: 86"><div><strong id="safetyScore">86</strong><span>安全</span></div></div><div class="score-breakdown"><div><span>机器人可用率</span><strong id="deviceOnlineRate">--</strong></div><div><span>事件闭环率</span><strong id="eventClosureRate">--</strong></div><div><span>通知覆盖率</span><strong id="notificationCoverage">--</strong></div></div></div>
|
<div class="safety-score-body"><div id="safetyGauge" class="safety-gauge" style="--score: 86"><div><strong id="safetyScore">86</strong><span>安全</span></div></div><div class="score-breakdown"><div><span>机器人可用率</span><strong id="deviceOnlineRate">--</strong></div><div><span>事件闭环率</span><strong id="eventClosureRate">--</strong></div><div><span>通知覆盖率</span><strong id="notificationCoverage">--</strong></div></div></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="panel message-flow-panel">
|
<article class="panel message-flow-panel">
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
</article>
|
</article>
|
||||||
<article class="panel risk-donut-panel">
|
<article class="panel risk-donut-panel">
|
||||||
<div class="panel-header"><div><p class="section-kicker">RISK MIX</p><h3>风险类型分布</h3></div><span class="micro-copy">近 7 日</span></div>
|
<div class="panel-header"><div><p class="section-kicker">RISK MIX</p><h3>风险类型分布</h3></div><span class="micro-copy">近 7 日</span></div>
|
||||||
<div class="donut-layout"><div id="riskDonut" class="risk-donut"><div><strong id="riskTotal">0</strong><span>事件</span></div></div><div class="donut-legend"><div><span><i class="legend-fire"></i>火焰</span><strong id="fireRiskRatio">0%</strong></div><div><span><i class="legend-smoke"></i>烟雾</span><strong id="smokeRiskRatio">0%</strong></div><div><span><i class="legend-device"></i>设备</span><strong>12%</strong></div></div></div>
|
<div class="donut-layout"><div id="riskDonut" class="risk-donut"><div><strong id="riskTotal">0</strong><span>事件</span></div></div><div class="donut-legend"><div><span><i class="legend-fire"></i>火焰</span><strong id="fireRiskRatio">0%</strong></div><div><span><i class="legend-smoke"></i>烟雾</span><strong id="smokeRiskRatio">0%</strong></div></div></div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
<div class="fleet-stat-grid">
|
<div class="fleet-stat-grid">
|
||||||
<article class="fleet-stat"><span>支持平台</span><strong id="robotTotalCount">0</strong><small>企业微信与飞书</small></article>
|
<article class="fleet-stat"><span>支持平台</span><strong id="robotTotalCount">0</strong><small>企业微信与飞书</small></article>
|
||||||
<article class="fleet-stat"><span>已配置</span><strong id="robotConfiguredCount">0</strong><small>可接收告警消息</small></article>
|
<article class="fleet-stat"><span>已配置</span><strong id="robotConfiguredCount">0</strong><small>可接收告警消息</small></article>
|
||||||
<article class="fleet-stat"><span>成功投递</span><strong id="robotDeliveredCount">0</strong><small>本次服务运行期间</small></article>
|
<article class="fleet-stat"><span>成功投递</span><strong id="robotDeliveredCount">0</strong><small>累计(已持久化)</small></article>
|
||||||
<article class="fleet-stat"><span>发送失败</span><strong id="robotFailedCount">0</strong><small>需要检查网络或凭据</small></article>
|
<article class="fleet-stat"><span>发送失败</span><strong id="robotFailedCount">0</strong><small>需要检查网络或凭据</small></article>
|
||||||
</div>
|
</div>
|
||||||
<div class="robot-management-grid message-robot-layout">
|
<div class="robot-management-grid message-robot-layout">
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
<div class="page-intro"><div><p class="section-kicker">SYSTEM SETTINGS</p><h2>系统设置</h2><p>查看当前生效的模型和告警参数。配置修改后需重启服务。</p></div></div>
|
<div class="page-intro"><div><p class="section-kicker">SYSTEM SETTINGS</p><h2>系统设置</h2><p>查看当前生效的模型和告警参数。配置修改后需重启服务。</p></div></div>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">MODEL</p><h3>模型配置</h3></div></div><dl><div><dt>权重文件</dt><dd id="settingWeights">--</dd></div><div><dt>输入尺寸</dt><dd id="settingImageSize">--</dd></div><div><dt>置信度阈值</dt><dd id="settingConfidence">--</dd></div><div><dt>IOU 阈值</dt><dd id="settingIou">--</dd></div></dl></article>
|
<article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">MODEL</p><h3>模型配置</h3></div></div><dl><div><dt>权重文件</dt><dd id="settingWeights">--</dd></div><div><dt>输入尺寸</dt><dd id="settingImageSize">--</dd></div><div><dt>置信度阈值</dt><dd id="settingConfidence">--</dd></div><div><dt>IOU 阈值</dt><dd id="settingIou">--</dd></div></dl></article>
|
||||||
<article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">ALERT POLICY</p><h3>告警策略</h3></div></div><dl><div><dt>连续确认</dt><dd id="settingConfirmFrames">--</dd></div><div><dt>分类冷却</dt><dd id="settingCooldown">--</dd></div><div><dt>事件存储</dt><dd>进程内最近 500 条</dd></div><div><dt>视频处理</dt><dd>浏览器本地抽帧</dd></div></dl></article>
|
<article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">ALERT POLICY</p><h3>告警策略</h3></div></div><dl><div><dt>连续确认</dt><dd id="settingConfirmFrames">--</dd></div><div><dt>分类冷却</dt><dd id="settingCooldown">--</dd></div><div><dt>事件存储</dt><dd>SQLite 持久化 · 最近 500 条</dd></div><div><dt>视频处理</dt><dd>浏览器本地抽帧</dd></div></dl></article>
|
||||||
</div>
|
</div>
|
||||||
<article class="panel env-panel"><div class="panel-header"><div><p class="section-kicker">ENVIRONMENT</p><h3>环境变量参考</h3></div></div><pre><code>YOLO_WEIGHTS=...
|
<article class="panel env-panel"><div class="panel-header"><div><p class="section-kicker">ENVIRONMENT</p><h3>环境变量参考</h3></div></div><pre><code>YOLO_WEIGHTS=...
|
||||||
YOLO_DEVICE=0
|
YOLO_DEVICE=0
|
||||||
@@ -206,6 +206,6 @@ ALERT_COOLDOWN_SECONDS=60</code></pre></article>
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div id="toastRegion" class="toast-region" aria-live="polite"></div>
|
<div id="toastRegion" class="toast-region" aria-live="polite"></div>
|
||||||
<script src="./app.js" type="module"></script>
|
<script src="./main.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
37
frontend/main.js
Normal file
37
frontend/main.js
Normal file
@@ -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()]);
|
||||||
8
frontend/modules/api.js
Normal file
8
frontend/modules/api.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
30
frontend/modules/charts.js
Normal file
30
frontend/modules/charts.js
Normal file
@@ -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 = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"><title>最近七天火焰与烟雾告警趋势</title>${gridValues.map((value) => `<line class="chart-grid" x1="${padding.left}" x2="${width - padding.right}" y1="${y(value)}" y2="${y(value)}"></line><text class="chart-axis-label" x="4" y="${y(value) + 3}">${value}</text>`).join("")}<path class="chart-fire" d="${path("fire")}"></path><path class="chart-smoke" d="${path("smoke")}"></path>${days.map((day, index) => `<circle class="chart-point-fire" cx="${x(index)}" cy="${y(day.fire)}" r="3"></circle><circle class="chart-point-smoke" cx="${x(index)}" cy="${y(day.smoke)}" r="3"></circle><text class="chart-axis-label" text-anchor="middle" x="${x(index)}" y="${height - 8}">${day.label}</text>`).join("")}</svg>`;
|
||||||
|
}
|
||||||
57
frontend/modules/dom.js
Normal file
57
frontend/modules/dom.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
6
frontend/modules/store.js
Normal file
6
frontend/modules/store.js
Normal file
@@ -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: [],
|
||||||
|
};
|
||||||
36
frontend/modules/ui.js
Normal file
36
frontend/modules/ui.js
Normal file
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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; }
|
.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 { 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; }
|
.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-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 { --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); }
|
.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); }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.overview-grid-lower { margin-top: 14px; }
|
||||||
|
|
||||||
/* Messaging robots */
|
/* Messaging robots */
|
||||||
|
|||||||
104
frontend/views/dashboard.js
Normal file
104
frontend/views/dashboard.js
Normal file
@@ -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 = "<p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p>";
|
||||||
|
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 `<div class="event-list-item"><span class="event-type-icon ${primary}">${primary === "fire" ? "火" : "烟"}</span><div class="event-description"><strong>${label}检测告警</strong><span>${formatDate(event.created_at)} · ${escapeHtml(event.id)}</span></div><strong class="event-confidence">${Math.round(event.max_confidence * 100)}%</strong><span class="event-status status-${escapeHtml(event.status)}">${STATUS_LABELS[event.status] || escapeHtml(event.status)}</span></div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
77
frontend/views/events.js
Normal file
77
frontend/views/events.js
Normal file
@@ -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 = '<tr><td colspan="7" class="empty-cell">当前筛选条件下暂无告警事件</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qs("#eventTableBody").innerHTML = events.map((event) => {
|
||||||
|
const classes = (event.classes || [])
|
||||||
|
.map((name) => `<span class="type-tag type-${escapeHtml(name)}">${classLabel(name)}</span>`)
|
||||||
|
.join(" ");
|
||||||
|
const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送";
|
||||||
|
const statusLabel = STATUS_LABELS[event.status] || escapeHtml(event.status);
|
||||||
|
return `<tr><td>${escapeHtml(event.id)}</td><td>${formatDate(event.created_at)}</td><td>${classes}</td><td>${Math.round(event.max_confidence * 100)}%</td><td>${channels}</td><td><span class="event-status status-${escapeHtml(event.status)}">${statusLabel}</span></td><td><div class="table-actions">${event.status === "pending" ? `<button class="table-action" data-event-id="${escapeHtml(event.id)}" data-event-status="acknowledged">确认</button>` : ""}${event.status !== "resolved" ? `<button class="table-action" data-event-id="${escapeHtml(event.id)}" data-event-status="resolved">解决</button>` : ""}</div></td></tr>`;
|
||||||
|
}).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
196
frontend/views/inspection.js
Normal file
196
frontend/views/inspection.js
Normal file
@@ -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 = '<p class="empty-copy">暂无检测结果</p>';
|
||||||
|
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) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
|
||||||
|
: '<p class="empty-copy">未发现烟雾或火焰目标</p>';
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
39
frontend/views/robotCards.js
Normal file
39
frontend/views/robotCards.js
Normal file
@@ -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) => `<span>${escapeHtml(capability)}</span>`)
|
||||||
|
.join("");
|
||||||
|
return `<article class="panel message-robot-card ${robot.configured ? "is-configured" : ""}" data-robot-id="${id}">
|
||||||
|
<div class="message-robot-head"><div class="platform-logo ${platformClass}">${platformMark}</div><div><span class="robot-id">${escapeHtml(robot.id.toUpperCase())} ROBOT</span><h3>${escapeHtml(robot.name)}群机器人</h3><p>${robot.id === "wechat" ? "群聊 Markdown 与告警截图" : "群聊交互卡片与签名校验"}</p></div><span class="robot-status ${robot.configured ? "active" : "offline"}"><i></i>${statusLabel}</span></div>
|
||||||
|
<div class="capability-list">${capabilities}</div>
|
||||||
|
<div class="delivery-metrics"><div><span>成功投递</span><strong>${robot.delivered}</strong></div><div><span>失败</span><strong class="${robot.failed ? "danger" : ""}">${robot.failed}</strong></div><div><span>连接测试</span><strong>${robot.tests}</strong></div></div>
|
||||||
|
<div class="robot-activity"><span>${statusText}</span><strong>${activityText}</strong></div>
|
||||||
|
<div class="credential-key"><span>服务端配置</span><code>${credential}${robot.id === "feishu" ? " / FEISHU_SECRET" : ""}</code></div>
|
||||||
|
${robot.last_error ? `<p class="delivery-error">${escapeHtml(robot.last_error)}</p>` : ""}
|
||||||
|
<button class="primary-button test-robot-button" type="button" data-test-robot="${id}" ${robot.configured ? "" : "disabled"}>${robot.configured ? "发送测试消息" : "配置后可测试"}</button>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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("")
|
||||||
|
: '<article class="panel robot-empty">暂无机器人通道数据</article>';
|
||||||
|
}
|
||||||
41
frontend/views/robots.js
Normal file
41
frontend/views/robots.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
# Pretrained Models
|
# Pretrained Models
|
||||||
|
|
||||||
本目录保存本地基础模型权重,默认训练权重为 `yolov8n.pt`。
|
本目录保存本地缓存的基础模型权重,供首次训练或实验对照使用。这些文件由 `.gitignore` 排除、不提交到 Git,克隆后可从 Ultralytics 官方源重新下载。
|
||||||
|
|
||||||
当前本地权重:
|
当前本地权重:
|
||||||
|
|
||||||
|
- `yolo11s.pt` — 训练默认起点(`DEFAULT_PRETRAINED_MODEL`)
|
||||||
|
- `yolo26s.pt`
|
||||||
- `yolov8n.pt`
|
- `yolov8n.pt`
|
||||||
- `yolov8s.pt`
|
- `yolov8s.pt`
|
||||||
- `yolo26n.pt`
|
|
||||||
|
|
||||||
权重文件通过 `.gitignore` 排除,不提交到 Git。
|
`TrainConfig.model_weights` 的默认值是已训练好的 `runs/detect/smoke_fire_yolo11s_v1-4/weights/best.pt`,因此 `fire-yolo train` 默认执行微调(fine-tune)而不是从头训练。
|
||||||
|
|||||||
@@ -65,5 +65,6 @@ for index, image_path in enumerate(image_paths, start=1):
|
|||||||
|
|
||||||
manifest_path = output_dir / "manifest.json"
|
manifest_path = output_dir / "manifest.json"
|
||||||
import json
|
import json
|
||||||
|
|
||||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
print(f"completed={len(manifest)} manifest={manifest_path}")
|
print(f"completed={len(manifest)} manifest={manifest_path}")
|
||||||
@@ -56,8 +56,9 @@ def train(
|
|||||||
)
|
)
|
||||||
_configure_ultralytics()
|
_configure_ultralytics()
|
||||||
|
|
||||||
|
# Local wall-clock time is the intent for run directory names.
|
||||||
run_name = active_config.name or (
|
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))
|
model = YOLO(str(weights_path))
|
||||||
results = model.train(
|
results = model.train(
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from backend.alerting import AlertManager
|
from backend.alerting import AlertManager
|
||||||
|
from backend.storage import Database, DeliveryStatsStore
|
||||||
|
|
||||||
|
|
||||||
class AlertManagerRobotTests(unittest.TestCase):
|
class AlertManagerRobotTests(unittest.TestCase):
|
||||||
@@ -51,6 +54,28 @@ class AlertManagerRobotTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "Unsupported"):
|
with self.assertRaisesRegex(ValueError, "Unsupported"):
|
||||||
manager.test_channel("unknown")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.events import EventStore
|
from backend.events import EventStore
|
||||||
|
from backend.storage import Database
|
||||||
|
|
||||||
|
|
||||||
class EventStoreTests(unittest.TestCase):
|
class EventStoreTests(unittest.TestCase):
|
||||||
@@ -53,6 +56,30 @@ class EventStoreTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "Unsupported"):
|
with self.assertRaisesRegex(ValueError, "Unsupported"):
|
||||||
self.store.update(event["id"], "invalid")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in New Issue
Block a user