From 81ef0a82cec7075e011c113d5f5984484a0a5678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Sun, 5 Jul 2026 13:12:12 +0200 Subject: [PATCH] fix: Web sieht Collector-Daten; Collector-Robustheit + DB-Pfad im Container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/lib/db.ts: pro Request frische readonly-Verbindung statt langlebigem Singleton. Eine gecachte WAL-Verbindung bekommt Writes des Collectors über den Docker-FUSE-Mount nicht mit -> Seite blieb leer. - collector.py: connect_async + reconnect_delay_set statt connect(), damit ein nicht erreichbarer Broker keinen Container-Crash-Loop auslöst, sondern in-process mit Backoff neu versucht wird. - docker-compose.yml: DB_PATH=/data/charges.db für den collector-Service erzwungen, damit ein relatives DB_PATH aus .env (lokaler Lauf) im Container nicht neben das gemountete Volume schreibt. Co-Authored-By: Claude Opus 4.8 (1M context) --- collector/collector.py | 7 +++++- docker-compose.yml | 4 +++ web/lib/db.ts | 56 ++++++++++++++++++++++-------------------- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/collector/collector.py b/collector/collector.py index 993ef46..e667f48 100644 --- a/collector/collector.py +++ b/collector/collector.py @@ -121,8 +121,13 @@ def main() -> None: client.on_disconnect = on_disconnect client.on_message = on_message + # connect_async + loop_forever: bei nicht erreichbarem Broker wird + # in-process mit Backoff neu versucht, statt den Prozess zu beenden + # (kein Container-Crash-Loop). Verbindet sich selbst, sobald der + # Broker erreichbar ist. log(f"Verbinde zu {MQTT_HOST}:{MQTT_PORT} ...") - client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) + client.reconnect_delay_set(min_delay=1, max_delay=60) + client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60) client.loop_forever(retry_first_connection=True) diff --git a/docker-compose.yml b/docker-compose.yml index ad83871..ce7ec06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,10 @@ services: container_name: auto-charger-collector restart: unless-stopped env_file: .env + environment: + # überschreibt ein evtl. relatives DB_PATH aus .env (lokaler Lauf) — + # im Container muss die DB im gemounteten Volume /data liegen. + - DB_PATH=/data/charges.db volumes: - ./data:/data diff --git a/web/lib/db.ts b/web/lib/db.ts index 544a1bf..ddb079a 100644 --- a/web/lib/db.ts +++ b/web/lib/db.ts @@ -17,47 +17,48 @@ export interface Totals { install_date: string | null; } -let db: Database.Database | null = null; - /** - * Öffnet die DB read-only. Gibt null zurück, falls die Datei noch nicht - * existiert (Collector legt sie beim ersten Start an) oder noch kein Schema - * vorhanden ist — die Seite bleibt dann leer statt zu crashen. + * Öffnet die DB read-only und gibt sie an `fn`. Es wird bewusst pro Aufruf eine + * frische Verbindung geöffnet (und wieder geschlossen): Der Collector schreibt + * aus einem anderen Prozess/Container im WAL-Modus, und eine langlebige + * readonly-Verbindung bekommt diese Writes über den Docker-Desktop-FUSE-Mount + * nicht zuverlässig mit (inkohärenter WAL-Index im Shared-Memory). Eine frische + * Verbindung liest immer den aktuellen Stand. + * + * Gibt `fallback` zurück, wenn die Datei/das Schema noch nicht existiert + * (Collector legt sie beim ersten Start an) — die Seite bleibt dann leer statt + * zu crashen. */ -function getDb(): Database.Database | null { - if (db) return db; +function withDb(fallback: T, fn: (conn: Database.Database) => T): T { + let conn: Database.Database | null = null; try { - const conn = new Database(DB_PATH, { readonly: true, fileMustExist: true }); + conn = new Database(DB_PATH, { readonly: true, fileMustExist: true }); const hasTable = conn .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='charges'") .get(); - if (!hasTable) { - conn.close(); - return null; - } - db = conn; - return db; + if (!hasTable) return fallback; + return fn(conn); } catch { - return null; + return fallback; + } finally { + conn?.close(); } } export function getCharges(): Charge[] { - const conn = getDb(); - if (!conn) return []; - return conn - .prepare( - `SELECT id, start_time, end_time, duration_s, energy_wh - FROM charges ORDER BY start_time DESC`, - ) - .all() as Charge[]; + return withDb([], (conn) => + conn + .prepare( + `SELECT id, start_time, end_time, duration_s, energy_wh + FROM charges ORDER BY start_time DESC`, + ) + .all() as Charge[], + ); } export function getTotals(): Totals { - const conn = getDb(); - if (!conn) { - return { month_wh: 0, year_wh: 0, total_wh: 0, install_date: null }; - } + const empty: Totals = { month_wh: 0, year_wh: 0, total_wh: 0, install_date: null }; + return withDb(empty, (conn) => { const month = conn .prepare( `SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges @@ -83,4 +84,5 @@ export function getTotals(): Totals { total_wh: total.s, install_date: install?.value ?? null, }; + }); }