Files
yolo/backend/storage.py
Kunpeng 25fa0c5825 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
2026-08-13 16:50:23 +08:00

138 lines
4.2 KiB
Python

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,
),
)