fix: Web sieht Collector-Daten; Collector-Robustheit + DB-Pfad im Container

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 13:12:12 +02:00
parent 845ef67e6a
commit 81ef0a82ce
3 changed files with 39 additions and 28 deletions
+6 -1
View File
@@ -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)
+4
View File
@@ -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
+25 -23
View File
@@ -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<T>(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
return withDb<Charge[]>([], (conn) =>
conn
.prepare(
`SELECT id, start_time, end_time, duration_s, energy_wh
FROM charges ORDER BY start_time DESC`,
)
.all() as Charge[];
.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<Totals>(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,
};
});
}