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:
2026-08-13 16:50:23 +08:00
parent 4dee7f664d
commit 25fa0c5825
25 changed files with 1058 additions and 669 deletions

View File

@@ -1,18 +1,44 @@
from __future__ import annotations
import threading
import json
import uuid
from collections import deque
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
from .storage import Database, utc_now_iso
EVENT_STATUSES = {"pending", "acknowledged", "resolved"}
def _local_today_utc_boundary() -> str:
"""UTC Z-format timestamp of local midnight, for 'today' comparisons."""
local_midnight = datetime.now().astimezone().replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
return (
local_midnight.astimezone(UTC)
.isoformat(timespec="seconds")
.replace("+00:00", "Z")
)
class EventStore:
def __init__(self, max_events: int = 500) -> None:
self._events: deque[dict[str, Any]] = deque(maxlen=max_events)
self._lock = threading.Lock()
"""Persistent alert event store backed by a single SQLite table.
Keeps at most ``max_events`` rows (newest first, ordered by rowid so
events created within the same second keep a stable insertion order).
"""
def __init__(
self,
db: Database | None = None,
max_events: int = 500,
) -> None:
self._db = db or Database(":memory:")
self._max_events = max(1, max_events)
def create(
self,
@@ -21,7 +47,6 @@ class EventStore:
detections: list[dict[str, Any]],
notification_channels: dict[str, bool],
) -> dict[str, Any]:
now = datetime.now().astimezone()
confidences = [
float(detection.get("confidence", 0.0))
for detection in detections
@@ -29,7 +54,7 @@ class EventStore:
]
event = {
"id": uuid.uuid4().hex[:12],
"created_at": now.isoformat(timespec="seconds"),
"created_at": utc_now_iso(),
"session_id": session_id,
"classes": classes,
"max_confidence": round(max(confidences, default=0.0), 6),
@@ -38,17 +63,44 @@ class EventStore:
"notification_channels": notification_channels,
"handled_at": None,
}
with self._lock:
self._events.appendleft(event)
return dict(event)
self._db.execute(
"""
INSERT INTO events (
id, created_at, session_id, classes, max_confidence,
detection_count, status, notification_channels, handled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event["id"],
event["created_at"],
session_id,
json.dumps(classes, ensure_ascii=False),
event["max_confidence"],
event["detection_count"],
event["status"],
json.dumps(notification_channels, ensure_ascii=False),
None,
),
)
self._db.execute(
"""
DELETE FROM events WHERE id IN (
SELECT id FROM events ORDER BY rowid DESC LIMIT -1 OFFSET ?
)
""",
(self._max_events,),
)
return event
def list(
self,
limit: int = 100,
status: str | None = None,
) -> list[dict[str, Any]]:
with self._lock:
events = [dict(event) for event in self._events]
rows = self._db.execute(
"SELECT * FROM events ORDER BY rowid DESC",
).fetchall()
events = [self._to_dict(row) for row in rows]
if status:
events = [event for event in events if event["status"] == status]
return events[: max(1, min(limit, 500))]
@@ -56,42 +108,57 @@ class EventStore:
def update(self, event_id: str, status: str) -> dict[str, Any] | None:
if status not in EVENT_STATUSES:
raise ValueError(f"Unsupported event status: {status}")
with self._lock:
for event in self._events:
if event["id"] != event_id:
continue
event["status"] = status
event["handled_at"] = (
None
if status == "pending"
else datetime.now().astimezone().isoformat(
timespec="seconds"
)
)
return dict(event)
return None
cursor = self._db.execute(
"""
UPDATE events SET status = ?, handled_at = ?
WHERE id = ?
""",
(status, None if status == "pending" else utc_now_iso(), event_id),
)
if cursor.rowcount == 0:
return None
row = self._db.execute(
"SELECT * FROM events WHERE id = ?",
(event_id,),
).fetchone()
return self._to_dict(row)
def summary(self) -> dict[str, int]:
today = datetime.now().astimezone().date()
with self._lock:
events = [dict(event) for event in self._events]
today_events = [
event
for event in events
if datetime.fromisoformat(event["created_at"]).date() == today
]
rows = self._db.execute(
"SELECT created_at, classes, status FROM events",
).fetchall()
today_boundary = _local_today_utc_boundary()
today_events = 0
counts = {status: 0 for status in EVENT_STATUSES}
fire = 0
smoke = 0
for row in rows:
counts[row["status"]] += 1
classes = json.loads(row["classes"])
if row["created_at"] >= today_boundary:
today_events += 1
fire += "fire" in classes
smoke += "smoke" in classes
return {
"total": len(events),
"today": len(today_events),
"pending": sum(
event["status"] == "pending" for event in events
),
"acknowledged": sum(
event["status"] == "acknowledged" for event in events
),
"resolved": sum(
event["status"] == "resolved" for event in events
),
"fire": sum("fire" in event["classes"] for event in events),
"smoke": sum("smoke" in event["classes"] for event in events),
"total": len(rows),
"today": today_events,
"pending": counts["pending"],
"acknowledged": counts["acknowledged"],
"resolved": counts["resolved"],
"fire": fire,
"smoke": smoke,
}
@staticmethod
def _to_dict(row: Any) -> dict[str, Any]:
return {
"id": row["id"],
"created_at": row["created_at"],
"session_id": row["session_id"],
"classes": json.loads(row["classes"]),
"max_confidence": row["max_confidence"],
"detection_count": row["detection_count"],
"status": row["status"],
"notification_channels": json.loads(row["notification_channels"]),
"handled_at": row["handled_at"],
}