From e149a9118fe0a7e42960b047f04315333b837a68 Mon Sep 17 00:00:00 2001 From: "Winkler, Stefan" Date: Tue, 4 Aug 2026 11:07:07 +0200 Subject: [PATCH] Endpunkt-Status-Icon mit Ereignis-Log und Update-Erkennung ergaenzen Ein neues Status-Icon im Titelbalken zeigt den Zustand der lokalen Transkripte und der Serverabfrage; ein Klick oeffnet ein eigenes Fenster mit persistiertem, zeitgestempeltem Ereignis-/Fehlerprotokoll (loeschbar). Ausserdem erkennt die App beim Start Erstinstallation vs. Update (plattformunabhaengig fuer Windows und Linux) und meldet Updates per nativer Benachrichtigung. Dazu: Versionierungs-Policy dokumentiert (package.json vor jedem Release erhoehen, sonst greift weder die Update-Erkennung noch bleiben alte Release-Dateien in release/ erhalten), sowie zwei neue Dokumente fuer Endanwender (BENUTZERHANDBUCH.md) und eine Download-Webseite (PRODUKTSEITE.md). Co-Authored-By: Claude Opus 4.8 --- BENUTZERHANDBUCH.md | 181 ++++++++++++++++++++++++++ PRODUKTSEITE.md | 63 +++++++++ README.md | 62 ++++++++- package.json | 2 +- src/errors-preload.js | 18 +++ src/errors/app.js | 80 ++++++++++++ src/errors/index.html | 42 ++++++ src/errors/styles.css | 274 ++++++++++++++++++++++++++++++++++++++++ src/main/collector.js | 82 ++++++++++-- src/main/index.js | 87 ++++++++++++- src/main/store.js | 6 + src/preload.js | 2 + src/renderer/app.js | 21 ++- src/renderer/index.html | 4 +- src/settings/app.js | 2 +- 15 files changed, 894 insertions(+), 32 deletions(-) create mode 100644 BENUTZERHANDBUCH.md create mode 100644 PRODUKTSEITE.md create mode 100644 src/errors-preload.js create mode 100644 src/errors/app.js create mode 100644 src/errors/index.html create mode 100644 src/errors/styles.css diff --git a/BENUTZERHANDBUCH.md b/BENUTZERHANDBUCH.md new file mode 100644 index 0000000..680220e --- /dev/null +++ b/BENUTZERHANDBUCH.md @@ -0,0 +1,181 @@ +# Claude Live Dashboard — Benutzerhandbuch + +Dieses Handbuch richtet sich an Anwender, die Claude Live Dashboard installieren +und nutzen möchten. Wer die Anwendung selbst bauen oder am Quellcode arbeiten +möchte, findet die technische Dokumentation in der `README.md`. + +## Was die Anwendung tut + +Claude Live Dashboard ist ein kleines, immer sichtbares Fenster, das anzeigt, +wie stark Ihr Claude-Abo gerade ausgelastet ist: das laufende 5-Stunden-Fenster, +das Wochenkontingent, der Zeitpunkt der nächsten Zurücksetzung und — sofern Ihr +Abo es ausweist — das verbleibende Nutzungsguthaben. + +Die Anzeige liest dafür ausschließlich die Protokolldateien, die Claude Code +auf Ihrem Rechner ohnehin selbst anlegt. Es wird nichts zusätzlich +mitgeschnitten. Optional (und standardmäßig aktiv) fragt die Anwendung +zusätzlich denselben Server-Endpunkt ab, den auch der Befehl `/usage` in +Claude Code nutzt, um exakte statt geschätzter Werte anzuzeigen — dazu weiter +unten mehr unter „Datenschutz und Sicherheit“. + +## Voraussetzungen + +- Windows 10/11 oder Ubuntu (bzw. eine vergleichbare Linux-Distribution) +- Claude Code muss installiert sein und mindestens einmal benutzt worden sein + — ohne vorhandene Protokolldateien hat das Dashboard nichts anzuzeigen + +## Installation + +### Windows + +Zwei Varianten stehen zur Wahl: + +- **Installer** (`Claude Live Dashboard--x64.exe`): Doppelklick + starten, Installationsordner bestätigen oder ändern, fertig. Es werden eine + Desktop- und eine Startmenü-Verknüpfung angelegt. Administratorrechte sind + **nicht** nötig, die Installation erfolgt in Ihr Benutzerprofil. +- **Portable** (`Claude Live Dashboard--portable.exe`): läuft ohne + Installation direkt von jedem Ort, z. B. einem USB-Stick. + +Da die Anwendung nicht mit einem kostenpflichtigen Code-Signing-Zertifikat +signiert ist, zeigt Windows beim allerersten Start eine SmartScreen-Warnung +(„Der Computer wurde durch Windows geschützt“). Über **Weitere Informationen** +und dann **Trotzdem ausführen** lässt sie sich bestätigen. Das ist normal für +Software ohne kommerzielles Zertifikat und kein Hinweis auf ein Problem. + +### Linux (Ubuntu und verwandte Distributionen) + +- **`.deb`-Paket**: `sudo apt install ./Claude-Live-Dashboard--amd64.deb` + — richtet Menüeintrag und Symbol automatisch ein. +- **AppImage**: Datei ausführbar machen (`chmod +x Claude-Live-Dashboard--x64.AppImage`) + und starten. Setzt `libfuse2` voraus; unter Ubuntu 24.04 ist das ggf. + nachzuinstallieren (`sudo apt install libfuse2t64` oder `libfuse2`, je nach + Version). + +Unter GNOME braucht das Tray-Symbol die Erweiterung **AppIndicator** — bei +Standard-Ubuntu ist sie bereits vorinstalliert. Fehlt sie, läuft die Anwendung +trotzdem, nur ohne sichtbares Symbol in der oberen Leiste. + +## Erster Start + +Nach dem Start erscheint das Fenster rechts oben auf dem Bildschirm und bleibt +über allen anderen Fenstern sichtbar. Zusätzlich landet ein Symbol im +Infobereich der Taskleiste (Windows) bzw. in der oberen Systemleiste (Linux). + +Unter Windows liegt das Symbol zunächst häufig im Überlaufbereich hinter dem +Pfeil `^` — per Drag & Drop lässt es sich dauerhaft sichtbar an die Taskleiste +heften. + +## Die Oberfläche + +**Kopfzeile** — per Ziehen lässt sich das Fenster frei positionieren, die +Position wird gemerkt. + +| Element | Bedeutung | +|---|---| +| Statuspunkt | Zeigt den Zustand der Datenquellen, siehe unten | +| Zahnrad | Öffnet die Einstellungen | +| `–` | Blendet das Fenster aus (Rückkehr über das Tray-Symbol) | + +**Anzeigebereich** + +- **Laufende Sitzung** — Auslastung des aktuellen 5-Stunden-Fensters, dazu + verbrauchte Tokens und Verbrauchstempo. +- **Wochenkontingent** — das übergeordnete Limit über alle Modelle, mit dem + Zeitpunkt der nächsten Zurücksetzung. +- **Nutzungsguthaben** — erscheint nur, wenn Ihr Abo ein zusätzliches + Geldbudget führt. +- Ein Satz darunter sagt, wie lange das Wochenkontingent bei aktuellem Tempo + noch reicht — gerechnet in tatsächlicher Arbeitszeit, nicht in Kalendertagen. + +**„Details“** klappt eine erweiterte Ansicht auf: Verbrauch je Modell, Tempo, +und wie verlässlich die angezeigten Prozentwerte sind. + +### Endpunkt-Status und Fehler-/Ereignis-Log + +Der Punkt links vom Zahnrad zeigt auf einen Blick, ob alles rund läuft: + +| Aussehen | Bedeutung | +|---|---| +| Grüner Punkt | Alles in Ordnung | +| Gelbe Raute | Die optionale Serverabfrage ist gerade gestört — die Anzeige rechnet währenddessen lokal weiter, ist also weiterhin nutzbar, nur mit geschätzten statt exakten Werten | +| Rote Raute | Die lokalen Protokolldateien sind nicht lesbar — das ist die eigentliche Datengrundlage, hier lohnt ein Blick ins Log | + +Ein Klick auf den Punkt öffnet ein eigenes Fenster mit einer Liste aller +bisherigen Störungen samt Zeitpunkt, sowie App-Ereignissen wie „App +installiert“ oder „Auf Version … aktualisiert“. Diese Liste bleibt auch über +einen Neustart hinweg erhalten und lässt sich über den Knopf **Log löschen** +jederzeit zurücksetzen. Bei einer Fehlermeldung im Programm ist ein Blick in +dieses Log meist der schnellste Weg herauszufinden, was los ist. + +## Einstellungen + +Das Zahnrad öffnet den Einstellungsdialog: + +- **Deckkraft** — wie durchscheinend das Fenster ist. +- **Klicks durchlassen** — macht das Fenster für die Maus „unsichtbar“, es + liegt dann über der Arbeit, ohne sie zu stören. Zurückschalten geht über das + Tray-Menü (siehe unten), da das Fenster in diesem Zustand selbst nicht mehr + anklickbar ist. +- **Mit Windows/dem System starten** — startet die Anwendung automatisch bei + der Anmeldung. +- **Serverabfrage** — kann abgeschaltet werden, falls die Anzeige rein lokal + arbeiten soll (dann sind alle Werte Näherungen statt exakter Zahlen). +- **Bezugsgrößen, Gewichtung, Wochenfenster** — fortgeschrittene Feineinstellungen + für den Fall, dass die Serverabfrage dauerhaft nicht zur Verfügung steht; + im Normalbetrieb müssen Sie hier nichts anpassen. + +Änderungen wirken sofort, ein Neustart ist nicht nötig. `Strg`+`S` speichert, +`Esc` schließt den Dialog ohne zu speichern. + +## Tray-Menü + +Ein Rechtsklick (Linux) bzw. Klick (Windows) auf das Symbol im +Infobereich/der Systemleiste öffnet ein Menü mit: Dashboard anzeigen/ausblenden, +Deckkraft, Klick-Durchlässigkeit, Autostart, Einstellungen, Datenordner öffnen +und Beenden. + +## Datenschutz und Sicherheit + +- Die Anwendung liest ausschließlich Dateien, die Claude Code selbst bereits + auf Ihrem Rechner ablegt. Es wird nichts zusätzlich protokolliert. +- Standardmäßig fragt sie zusätzlich einen offiziellen Anthropic-Endpunkt ab, + um exakte statt geschätzter Prozentwerte anzuzeigen. Dafür wird dasselbe + Zugriffstoken verwendet, das Claude Code selbst benutzt — es wird gelesen, + aber nie gespeichert, nie protokolliert und ausschließlich an diesen einen, + offiziellen Host gesendet. +- Diese Serverabfrage lässt sich in den Einstellungen jederzeit abschalten; + die Anwendung funktioniert dann weiter, nur mit geschätzten statt exakten + Werten. +- Es findet keine Übertragung an Dritte statt. + +## Deinstallation + +**Windows:** über *Einstellungen ▸ Apps* bzw. den mitinstallierten +Deinstallierer im Installationsordner. Ihre Konfiguration und die +Kalibrierungsdaten bleiben dabei erhalten, falls Sie die Anwendung später +erneut installieren. + +**Linux:** `.deb`-Installation mit `sudo apt remove claude-live-dashboard`; +ein AppImage genügt zu löschen. + +## Häufige Fragen + +**Das Fenster zeigt keine Zahlen an.** Vermutlich wurde Claude Code auf diesem +Rechner noch nicht benutzt — die Anzeige braucht mindestens einen +protokollierten Verlauf, um etwas darzustellen. + +**Der Statuspunkt ist rot.** Die lokalen Protokolldateien konnten nicht +gelesen werden. Ein Klick auf den Punkt zeigt im Log den genauen Grund. + +**Der Statuspunkt ist gelb.** Nur die optionale Serverabfrage ist gerade +gestört, meist vorübergehend (z. B. Netzwerkproblem oder ein abgelaufenes +Zugriffstoken, das Claude Code beim nächsten eigenen Start erneuert). Die +Anzeige selbst funktioniert währenddessen weiter. + +**Windows warnt beim ersten Start.** Normal bei unsignierter Software, siehe +Abschnitt „Installation“ oben. + +**Nach einem Update sehe ich eine Benachrichtigung.** Das ist beabsichtigt: +Die Anwendung erkennt neue Versionen automatisch und weist einmalig darauf +hin. Ihre Einstellungen und Kalibrierung bleiben davon unberührt. diff --git a/PRODUKTSEITE.md b/PRODUKTSEITE.md new file mode 100644 index 0000000..a26cfa3 --- /dev/null +++ b/PRODUKTSEITE.md @@ -0,0 +1,63 @@ +# Claude Live Dashboard + +**Immer im Blick, wie stark Ihr Claude-Abo gerade ausgelastet ist — ohne +einen Tab zu wechseln.** + +Claude Live Dashboard ist ein schlankes, immer sichtbares Widget für Windows +und Linux, das die Auslastung Ihres Claude-Abos live anzeigt: das laufende +5-Stunden-Fenster, das Wochenkontingent, den Zeitpunkt der nächsten +Zurücksetzung und — je nach Abo — das verbleibende Nutzungsguthaben. Dazu die +eine Zahl, die eigentlich zählt: wie lange das Kontingent beim aktuellen +Arbeitstempo noch reicht. + +## Funktionen + +- **5-Stunden-Fenster und Wochenkontingent** auf einen Blick, inklusive + Reset-Countdown +- **Exakte Werte statt Schätzungen** — fragt optional denselben Endpunkt ab, + den auch `/usage` in Claude Code nutzt +- **Verbrauch je Modell** in der aufklappbaren Detailansicht +- **Hochrechnung in aktiver Arbeitszeit**, nicht in Kalendertagen — sagt, wie + lange das Kontingent bei diesem Tempo noch trägt +- **Endpunkt-Status-Anzeige** mit chronologischem Ereignis-Log, damit auf + einen Blick klar ist, ob alle Datenquellen funktionieren +- **Automatische Update-Erkennung** mit einmaliger Benachrichtigung nach + einem Update +- **Läuft lokal** — liest nur, was Claude Code ohnehin bereits auf Ihrem + Rechner protokolliert; die optionale Serverabfrage lässt sich jederzeit + abschalten +- Frei positionierbar, transparent, mit einstellbarer Deckkraft und optional + klickdurchlässig, damit es nie im Weg ist +- Windows und Linux (Ubuntu und verwandte Distributionen), inklusive + Wayland-Unterstützung + +## Systemvoraussetzungen + +- Windows 10/11 oder Ubuntu (bzw. eine vergleichbare Linux-Distribution) +- Eine bestehende Claude-Code-Installation, aus deren lokalen Protokollen die + Anzeige ihre Daten bezieht + +## Download + +| Datei | Plattform | Hinweis | +|---|---|---| +| `Claude Live Dashboard--x64.exe` | Windows | Installer, ohne Administratorrechte | +| `Claude Live Dashboard--portable.exe` | Windows | ohne Installation, direkt lauffähig | +| `Claude Live Dashboard--amd64.deb` | Linux (Debian/Ubuntu) | `sudo apt install ./…deb` | +| `Claude Live Dashboard--x64.AppImage` | Linux | ohne Installation, `chmod +x` genügt | + +Ausführliche Installationsschritte und die Bedienung im Detail: siehe +Benutzerhandbuch. + +## Datenschutz zuerst + +Es wird nichts über das hinaus protokolliert, was Claude Code selbst bereits +auf Ihrem Rechner ablegt, und nichts an Dritte übertragen. Die einzige +optionale Netzwerkverbindung führt zu einem offiziellen Anthropic-Endpunkt, +um statt geschätzter exakte Werte anzuzeigen — mit dem ohnehin vorhandenen +Zugriffstoken, das nie gespeichert oder protokolliert wird. Diese Abfrage +lässt sich jederzeit abschalten, ohne dass die Anzeige ihre Funktion verliert. + +## Lizenz + +MIT — freie Nutzung, Quellcode einsehbar. diff --git a/README.md b/README.md index 706e0ae..e35f5ae 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ ELECTRON_OZONE_PLATFORM_HINT=wayland ./start.sh # nativ Wayland, ohne Position | Aktion | Wirkung | |---|---| | Kopfzeile ziehen | Fenster verschieben (Position wird gemerkt) | +| Statuspunkt (links vom Zahnrad) | Fehler-/Ereignis-Log öffnen | | ⚙ | Einstellungen öffnen | | „Details“ | Modell-Aufschlüsselung, Tempo und Datengrundlage aufklappen | | `–` | Ausblenden — Rückkehr über das Tray-Symbol | @@ -98,6 +99,23 @@ sofort beim Umschalten übernommen, unabhängig vom Speichern. dann über der Arbeit, ohne sie zu behindern. Zurückschalten geht über das Tray-Menü. +### Endpunkt-Status & Ereignis-Log + +Der Punkt links vom Zahnrad zeigt den Zustand der beiden Datenquellen — +Symbol *und* Farbe, nie Farbe allein: + +| Zustand | Bedeutung | +|---|---| +| ● grün | Beide Quellen funktionieren | +| ◆ gelb | Serverabfrage gestört (App rechnet lokal weiter) | +| ◆ rot | Lokale Transkripte nicht lesbar — die Kerndatenquelle fehlt | + +Ein Klick öffnet ein eigenes Fenster mit dem chronologischen, zeitgestempelten +Protokoll aller Störungen (Lesefehler, Dateiüberwachung, Serverabfrage) sowie +App-Ereignissen wie Erstinstallation und Updates. Das Protokoll übersteht +Neustarts und das Speichern der Einstellungen — es liegt in `state.json` — und +lässt sich über „Log löschen“ im selben Fenster zurücksetzen. + ## Was angezeigt wird **Laufende Sitzung** — Auslastung des aktuellen 5-Stunden-Fensters, dazu @@ -206,8 +224,10 @@ src/main/paths.js Speicherorte für Konfiguration und Zustand src/main/autostart.js Anmeldestart je Betriebssystem src/preload.js contextBridge fürs Widget src/settings-preload.js contextBridge für den Einstellungsdialog +src/errors-preload.js contextBridge fürs Fehler-/Ereignis-Log src/renderer/ Widget-Oberfläche src/settings/ Einstellungsdialog +src/errors/ Fehler-/Ereignis-Log-Fenster ``` Alle Dateizugriffe und Rechnungen passieren im Main-Prozess; der Renderer @@ -223,11 +243,41 @@ Zwei Details, die nicht offensichtlich sind: Retries und beim Kompaktieren mehrfach ins Transkript; ohne Dedup über `message.id` wären alle Zahlen zu hoch. +## Version & Updates + +Die App erkennt beim Start selbst, ob es sich um eine Erstinstallation oder +ein Update handelt: Sie vergleicht `app.getVersion()` (aus `package.json`, +plattformunabhängig für Windows und Linux) mit der zuletzt in `state.json` +gemerkten Version. Bei einer neuen Version erscheint einmalig eine native +Systembenachrichtigung, und das Ereignis landet zeitgestempelt im +Fehler-/Ereignis-Log (siehe oben). Bestehende Konfiguration und Kalibrierung +bleiben davon unberührt — geschrieben wird ausschließlich das eine Feld +`state.json:appVersion`. + +**Voraussetzung dafür ist eine bei jedem Release erhöhte Versionsnummer.** +Vor jedem Build deshalb zuerst `version` in `package.json` erhöhen (SemVer: +`1.1.0` → `1.2.0` für neue Funktionen, `1.1.1` für Fehlerbehebungen). Ohne +diesen Schritt erkennt die App den neuen Build nicht als Update, und der +Installer überschreibt in `release/` stillschweigend die Datei der +vorherigen Version, weil deren Name (`artifactName`) die Versionsnummer +enthält: + +```powershell +npm version minor # oder: patch / major — schreibt package.json, kein Tag/Commit nötig +npm run dist +``` + +`npm version` legt standardmäßig auch einen Git-Commit und -Tag an; ohne das +genügt es, `version` in `package.json` von Hand zu ändern. + ## Release bauen Gebaut wird jeweils auf dem Zielsystem — electron-builder kann `.deb` nicht sinnvoll von Windows aus erzeugen und umgekehrt keine signierten `.exe` unter -Linux. +Linux. Beide Linux-Ziele (`AppImage`, `.deb`) brauchen zudem Linux-eigene +Werkzeuge (`mksquashfs` bzw. `fpm`) und lassen sich nicht unter Windows +erzeugen, auch nicht über WSL ohne eine dort eingerichtete Node.js- bzw. +Ruby-Umgebung. ### Windows @@ -236,12 +286,12 @@ npm run dist # Installer + portable Fassung nach release/ npm run pack # nur entpackt nach release/win-unpacked (schneller Test) ``` -Ergebnis in `release/`: +Ergebnis in `release/` (Version aus `package.json`, siehe oben): | Datei | Zweck | |---|---| -| `Claude Live Dashboard-1.0.0-x64.exe` | NSIS-Installer, ~97 MB | -| `Claude Live Dashboard-1.0.0-portable.exe` | läuft ohne Installation | +| `Claude Live Dashboard--x64.exe` | NSIS-Installer, ~97 MB | +| `Claude Live Dashboard--portable.exe` | läuft ohne Installation | Der Installer läuft **ohne Administratorrechte** (`perMachine: false`), installiert also ins Benutzerprofil. Zielverzeichnis ist wählbar, Desktop- und @@ -265,8 +315,8 @@ Ergebnis in `release/`: | Datei | Zweck | |---|---| -| `Claude Live Dashboard-1.0.0-x64.AppImage` | läuft ohne Installation | -| `Claude Live Dashboard-1.0.0-amd64.deb` | Installation per `sudo apt install ./…deb` | +| `Claude Live Dashboard--x64.AppImage` | läuft ohne Installation | +| `Claude Live Dashboard--amd64.deb` | Installation per `sudo apt install ./…deb` | Das AppImage muss einmal ausführbar gemacht werden (`chmod +x`) und braucht `libfuse2`. Es trägt sich nicht selbst ins Anwendungsmenü ein — der Autostart diff --git a/package.json b/package.json index 2d4f038..00c2511 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-live-dashboard", "productName": "Claude Live Dashboard", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows und Linux", "main": "src/main/index.js", diff --git a/src/errors-preload.js b/src/errors-preload.js new file mode 100644 index 0000000..613de65 --- /dev/null +++ b/src/errors-preload.js @@ -0,0 +1,18 @@ +'use strict'; + +/** Brücke für das Fehler-/Ereignis-Log-Fenster. Bewusst getrennt vom Widget-Preload. */ + +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('errorsBridge', { + /** Registriert den Empfänger für Zustandsaktualisierungen. */ + onState(callback) { + ipcRenderer.on('state', (_event, state) => callback(state)); + }, + /** Fordert den aktuellen Zustand an (für den ersten Aufbau). */ + getState: () => ipcRenderer.invoke('get-state'), + /** Leert das Ereignisprotokoll. */ + clear: () => ipcRenderer.send('errors:clear'), + /** Schließt das Fenster. */ + close: () => ipcRenderer.send('errors:close'), +}); diff --git a/src/errors/app.js b/src/errors/app.js new file mode 100644 index 0000000..f48e29f --- /dev/null +++ b/src/errors/app.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Fehler-/Ereignis-Log. Rechnet nichts nach — Status und Meldungen kommen + * fertig aus dem Main-Prozess (`state.endpoints`, `state.errors`). + */ + +const $ = (id) => document.getElementById(id); + +const el = { + close: $('btn-close'), + clear: $('btn-clear'), + statusLocal: $('status-local'), + statusOauth: $('status-oauth'), + logList: $('log-list'), + logEmpty: $('log-empty'), + logCount: $('log-count'), +}; + +const SOURCE_LABEL = { + local: 'Lokal', + oauth: 'Server', + app: 'App', +}; + +function fmtTime(ts) { + const d = new Date(ts); + const now = new Date(); + const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + if (d.toDateString() === now.toDateString()) return time; + return `${d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} ${time}`; +} + +function renderStatusRow(node, severity, label) { + node.dataset.severity = severity; + node.querySelector('.status-label').textContent = label; +} + +function render(s) { + if (!s) return; + + const ep = s.endpoints || { local: { ok: true }, oauth: { ok: true, text: '—' } }; + renderStatusRow(el.statusLocal, ep.local.ok === false ? 'critical' : 'ok', ep.local.ok === false ? 'gestört' : 'aktiv'); + renderStatusRow(el.statusOauth, ep.oauth.ok === false ? 'warn' : 'ok', ep.oauth.text || '—'); + + const entries = (s.errors || []).slice().reverse(); + el.logCount.textContent = entries.length ? `${entries.length} Einträge` : ''; + el.logEmpty.hidden = entries.length > 0; + el.logList.replaceChildren( + ...entries.map((e) => { + const li = document.createElement('li'); + li.className = 'log-entry'; + + const time = document.createElement('span'); + time.className = 'log-time'; + time.textContent = fmtTime(e.ts); + + const source = document.createElement('span'); + source.className = 'log-source'; + source.dataset.source = e.source; + source.textContent = SOURCE_LABEL[e.source] || e.source; + + const message = document.createElement('span'); + message.className = 'log-message'; + message.textContent = e.message; + + li.append(time, source, message); + return li; + }), + ); +} + +el.close.addEventListener('click', () => window.errorsBridge.close()); +el.clear.addEventListener('click', () => window.errorsBridge.clear()); +document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') window.errorsBridge.close(); +}); + +window.errorsBridge.onState(render); +window.errorsBridge.getState().then(render); diff --git a/src/errors/index.html b/src/errors/index.html new file mode 100644 index 0000000..6794637 --- /dev/null +++ b/src/errors/index.html @@ -0,0 +1,42 @@ + + + + + + Endpunkt-Status + + + +
+ Endpunkt-Status + + +
+ +
+
+

Aktueller Status

+
+
Lokale Transkripte
+
+
Serverabfrage
+
+
+
+ +
+

Verlauf

+
    + +
    +
    + +
    + + + +
    + + + + diff --git a/src/errors/styles.css b/src/errors/styles.css new file mode 100644 index 0000000..4cf4ed9 --- /dev/null +++ b/src/errors/styles.css @@ -0,0 +1,274 @@ +/* Fehler-/Ereignis-Log-Fenster — gleiche Farbwelt wie das Widget, aber auf + Lesbarkeit in einem größeren Fenster ausgelegt. */ + +:root { + color-scheme: dark; + + --surface: #1e1e22; + --surface-raised: rgba(255, 255, 255, 0.05); + + --text-primary: #ffffff; + --text-secondary: #c3c2b7; + --text-muted: #898781; + --hairline: rgba(255, 255, 255, 0.1); + + --accent: #3987e5; + --good: #0ca30c; + --warning: #fab219; + --critical: #d03b3b; + + --font: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + margin: 0; + height: 100%; + background: var(--surface); + color: var(--text-primary); + font-family: var(--font); + font-size: 13px; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; +} + +/* ── Titelleiste ─────────────────────────────────────────────────────── */ + +.titlebar { + -webkit-app-region: drag; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px 9px 14px; + border-bottom: 1px solid var(--hairline); + flex: 0 0 auto; +} + +.title { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.spacer { + flex: 1; +} + +.icon-btn { + -webkit-app-region: no-drag; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; +} + +.icon-btn:hover { + background: var(--surface-raised); + color: var(--text-primary); +} + +/* ── Inhalt ──────────────────────────────────────────────────────────── */ + +.content { + flex: 1 1 auto; + overflow-y: auto; + padding: 14px 16px 18px; +} + +.content::-webkit-scrollbar { + width: 8px; +} + +.content::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 4px; +} + +.group + .group { + margin-top: 20px; + padding-top: 18px; + border-top: 1px solid var(--hairline); +} + +.group h2 { + margin: 0 0 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +/* ── Aktueller Status ────────────────────────────────────────────────── */ + +.facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 12px; + margin: 0; + font-size: 12px; +} + +.facts dt { + color: var(--text-secondary); +} + +.facts dd { + margin: 0; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); +} + +/* Status trägt immer Punkt + Text, nie Farbe allein. */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--good); + flex: 0 0 auto; +} + +dd[data-severity="warn"] .status-dot { + background: var(--warning); + border-radius: 2px; + transform: rotate(45deg); +} + +dd[data-severity="critical"] .status-dot { + background: var(--critical); + border-radius: 2px; +} + +dd[data-severity="warn"] .status-label { + color: var(--warning); +} + +dd[data-severity="critical"] .status-label { + color: var(--critical); +} + +/* ── Verlauf ─────────────────────────────────────────────────────────── */ + +.log { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.log-entry { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: baseline; + gap: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--hairline); + font-size: 11.5px; +} + +.log-entry:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.log-time { + color: var(--text-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.log-source { + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.03em; + text-transform: uppercase; + border: 1px solid currentColor; + border-radius: 3px; + padding: 1px 4px; + white-space: nowrap; + color: var(--text-muted); +} + +.log-source[data-source="oauth"] { + color: var(--warning); +} + +.log-source[data-source="local"] { + color: var(--critical); +} + +.log-source[data-source="app"] { + color: var(--accent); +} + +.log-message { + color: var(--text-secondary); + line-height: 1.4; + word-break: break-word; +} + +.empty { + margin: 0; + font-size: 11.5px; + color: var(--text-muted); +} + +/* ── Fußzeile ────────────────────────────────────────────────────────── */ + +.actions { + display: flex; + align-items: center; + gap: 8px; + padding: 11px 16px; + border-top: 1px solid var(--hairline); + flex: 0 0 auto; +} + +.btn { + padding: 6px 14px; + border: 1px solid var(--hairline); + border-radius: 5px; + background: transparent; + color: var(--text-secondary); + font-family: inherit; + font-size: 12.5px; + cursor: pointer; +} + +.btn:hover { + background: var(--surface-raised); + color: var(--text-primary); +} + +.btn:disabled { + opacity: 0.45; + cursor: default; +} + +.count { + font-size: 11px; + color: var(--text-muted); +} diff --git a/src/main/collector.js b/src/main/collector.js index f65ce1a..b701bec 100644 --- a/src/main/collector.js +++ b/src/main/collector.js @@ -22,6 +22,8 @@ const DEBOUNCE_MS = 300; const SAFETY_POLL_MS = 15000; /** Taktung der Neuberechnung, damit Countdown und Burn-Rate nicht einfrieren. */ const RECOMPUTE_MS = 5000; +/** Obergrenze des Ereignis-/Fehlerprotokolls — ein Log braucht mehr Tiefe als ein Badge. */ +const MAX_ERRORS = 50; /** Übersetzt den Schweregrad des Servers in die eigenen Stufen. */ function serverSeverity(value) { @@ -41,9 +43,10 @@ function serverSeverity(value) { class Collector extends EventEmitter { /** - * @param {{config: Object, store: import('./store').Store, configError?: string|null}} deps + * @param {{config: Object, store: import('./store').Store, configError?: string|null, + * versionEvent?: {type: 'install'|'update', previousVersion: string|null, currentVersion: string}|null}} deps */ - constructor({ config, store, configError = null }) { + constructor({ config, store, configError = null, versionEvent = null }) { super(); this.config = config || {}; this.store = store; @@ -64,8 +67,19 @@ class Collector extends EventEmitter { this.subscription = { subscriptionType: null, rateLimitTier: null }; this.limits = null; this.state = null; - this.errors = []; - if (configError) this.errors.push(configError); + // Zeitgestempeltes Ereignis-/Fehlerprotokoll — überdauert Neustarts über + // store.data.errors, siehe _note()/clearErrors(). + this.errors = Array.isArray(store.data.errors) ? store.data.errors : []; + this._localOk = true; + this._lastOauthError = null; + if (configError) this._note(configError, 'local'); + if (versionEvent) { + const msg = + versionEvent.type === 'update' + ? `Aktualisiert: ${versionEvent.previousVersion} → ${versionEvent.currentVersion}` + : `Erstinstallation: Version ${versionEvent.currentVersion}`; + this._note(msg, 'app'); + } this._watcher = null; this._debounce = null; @@ -114,9 +128,10 @@ class Collector extends EventEmitter { this.oauth = new OAuthUsage(this.config.oauth || {}, this.store); this.limits = new LimitModel(this.config, this.store.data.calibration, this.subscription); - this.errors = []; + // this.errors bleibt bewusst erhalten — das Ereignisprotokoll soll + // Einstellungs-Änderungen überstehen, nicht bei jedem Speichern verfallen. - this._recompute().catch((err) => this._note(`Neuberechnung fehlgeschlagen: ${err.message}`)); + this._recompute().catch((err) => this._note(`Neuberechnung fehlgeschlagen: ${err.message}`, 'local')); } stop() { @@ -140,19 +155,33 @@ class Collector extends EventEmitter { this._debounce = setTimeout(() => this._poll(), DEBOUNCE_MS); }); this._watcher.on('error', (err) => { - this._note(`Dateiüberwachung gestört: ${err.message} — es wird weiter regelmäßig abgefragt.`); + this._note(`Dateiüberwachung gestört: ${err.message} — es wird weiter regelmäßig abgefragt.`, 'local'); }); } catch (err) { // Ohne Watcher greift das Sicherheitsnetz alle 15 s. - this._note(`Dateiüberwachung nicht verfügbar: ${err.message}`); + this._note(`Dateiüberwachung nicht verfügbar: ${err.message}`, 'local'); } } - _note(message) { - if (!this.errors.includes(message)) { - this.errors.push(message); - if (this.errors.length > 5) this.errors.shift(); - } + /** + * Hält das Ereignis-/Fehlerprotokoll fest — zeitgestempelt, dedupliziert + * nach Meldungstext und in `store.data.errors` persistiert, damit es + * Neustarts und Einstellungs-Änderungen übersteht. + */ + _note(message, source = 'local') { + if (this.errors.some((e) => e.message === message)) return; + this.errors.push({ ts: Date.now(), message, source }); + if (this.errors.length > MAX_ERRORS) this.errors.shift(); + this.store.data.errors = this.errors; + this.store.save(); + } + + /** Leert das Ereignisprotokoll — ausgelöst über den „Log löschen"-Knopf im Fehler-Log-Fenster. */ + clearErrors() { + this.errors = []; + this.store.data.errors = this.errors; + this.store.save(); + this._recompute(); } async _poll() { @@ -167,9 +196,14 @@ class Collector extends EventEmitter { if (entries.length) this.aggregator.add(entries); this.aggregator.prune(); this._lastPollMs = Date.now() - t0; + if (!this._localOk) { + this._localOk = true; + this._note('Dateizugriff wieder erreichbar', 'local'); + } await this._recompute(); } catch (err) { - this._note(`Lesen fehlgeschlagen: ${err.message}`); + this._localOk = false; + this._note(`Lesen fehlgeschlagen: ${err.message}`, 'local'); } finally { this._polling = false; if (this._pollAgain) { @@ -207,6 +241,17 @@ class Collector extends EventEmitter { // Damit stimmt das lokale Fenster ohne jede Konfiguration mit dem echten // überein — Voraussetzung dafür, dass die Live-Kalibrierung unten trägt. const server = await this.oauth.fetch(now); + + // Übergänge des Server-Fehlers ins Ereignisprotokoll übernehmen — nicht + // jeden Poll, nur den Wechsel. "abgeschaltet"/"nicht konfiguriert"/ + // "wartet" sind kein Fehler und lösen bewusst keinen Eintrag aus. + const oauthError = this.oauth.lastError; + if (oauthError !== this._lastOauthError) { + if (oauthError) this._note(`Serverabfrage: ${oauthError}`, 'oauth'); + else if (this._lastOauthError) this._note('Serverabfrage wieder erreichbar', 'oauth'); + this._lastOauthError = oauthError; + } + const anchor = server?.weekResetAt ? server.weekResetAt - this.aggregator.weekMs : weekAnchor(now, this.config); @@ -327,6 +372,15 @@ class Collector extends EventEmitter { extras: server?.extras || [], history: snapshot.blockHistory, oauth: this.oauth.status(), + // Für das Endpunkt-Status-Icon im Titelbalken: lokale Transkripte sind + // die Kerndatenquelle (ohne sie funktioniert das Dashboard gar nicht) + // → critical; der Server-Endpunkt ist laut Doku in oauth-usage.js + // ausdrücklich optional/best-effort → nur warn. + endpoints: { + severity: !this._localOk ? 'critical' : oauthError ? 'warn' : 'ok', + local: { ok: this._localOk }, + oauth: { ok: !oauthError, text: this.oauth.status().text }, + }, errors: this.errors.slice(), stats: { lastPollMs: this._lastPollMs }, }; diff --git a/src/main/index.js b/src/main/index.js index f90766c..f5b9260 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -7,7 +7,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeImage, shell } = require('electron'); +const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeImage, shell, Notification } = require('electron'); const { Store, loadConfig } = require('./store'); const { Collector } = require('./collector'); @@ -26,8 +26,12 @@ const ROOT = path.join(__dirname, '..', '..'); const SETTINGS_WIDTH = 520; const SETTINGS_HEIGHT = 660; +const ERRORS_WIDTH = 420; +const ERRORS_HEIGHT = 480; + let win = null; let settingsWin = null; +let errorsWin = null; let tray = null; let collector = null; let store = null; @@ -301,6 +305,50 @@ function openSettings() { }); } +/** Fehler-/Ereignis-Log — ein Fenster, mehrfaches Öffnen holt es nur nach vorn. */ +function openErrorLog() { + if (errorsWin && !errorsWin.isDestroyed()) { + errorsWin.show(); + errorsWin.focus(); + return; + } + + const target = win && !win.isDestroyed() + ? screen.getDisplayMatching(win.getBounds()) + : screen.getPrimaryDisplay(); + const area = target.workArea; + + errorsWin = new BrowserWindow({ + width: ERRORS_WIDTH, + height: ERRORS_HEIGHT, + x: Math.round(area.x + (area.width - ERRORS_WIDTH) / 2), + y: Math.round(area.y + Math.max(0, (area.height - ERRORS_HEIGHT) / 2)), + minWidth: 360, + minHeight: 320, + frame: false, + resizable: true, + skipTaskbar: false, + // Über dem Widget, das selbst always-on-top ist — sonst verschwindet der + // Dialog dahinter. + alwaysOnTop: true, + show: false, + backgroundColor: '#1e1e22', + icon: path.join(ROOT, 'assets', 'icon.png'), + webPreferences: { + preload: path.join(__dirname, '..', 'errors-preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + errorsWin.loadFile(path.join(__dirname, '..', 'errors', 'index.html')); + errorsWin.once('ready-to-show', () => errorsWin.show()); + errorsWin.on('closed', () => { + errorsWin = null; + }); +} + /** * Übernimmt geänderte Einstellungen ohne Neustart und schreibt sie in die * Datei. Unbekannte Felder und die `_`-Kommentare bleiben erhalten, weil die @@ -345,6 +393,9 @@ function registerIpc() { ipcMain.on('set-expanded', (_event, expanded) => applyExpanded(Boolean(expanded))); ipcMain.on('hide-window', () => win && win.hide()); ipcMain.on('open-settings', openSettings); + ipcMain.on('open-error-log', openErrorLog); + ipcMain.on('errors:clear', () => collector && collector.clearErrors()); + ipcMain.on('errors:close', () => errorsWin && errorsWin.close()); ipcMain.handle('settings:load', () => ({ config, @@ -352,6 +403,7 @@ function registerIpc() { configPath: CONFIG_FILE, autostart: getAutostart(), platform: process.platform, + version: app.getVersion(), }, })); @@ -386,6 +438,23 @@ async function main() { store = new Store(STATE_FILE); store.load(); + // Neuinstallations-/Update-Erkennung: rein additiv, überschreibt oder + // löscht keine bestehenden Einstellungsdaten — nur `store.data.appVersion` + // wird gesetzt. Funktioniert identisch unter Windows (NSIS/portable) und + // Linux (AppImage/deb), da app.getVersion() plattformunabhängig aus den + // Paketmetadaten liest und state.json auf beiden Plattformen im selben + // Nutzerprofil-Ordner liegt (siehe paths.js). + const currentVersion = app.getVersion(); + const previousVersion = store.data.appVersion; + let versionEvent = null; + if (previousVersion == null) { + versionEvent = { type: 'install', previousVersion: null, currentVersion }; + } else if (previousVersion !== currentVersion) { + versionEvent = { type: 'update', previousVersion, currentVersion }; + } + store.data.appVersion = currentVersion; + store.save(); + registerIpc(); createWindow(); createTray(); @@ -393,9 +462,23 @@ async function main() { if (config.opacity != null) win.setOpacity(config.opacity); if (config.clickThrough) win.setIgnoreMouseEvents(true, { forward: true }); - collector = new Collector({ config, store, configError: error }); + collector = new Collector({ config, store, configError: error, versionEvent }); + + // Hinweis auf ein Update über die native Systembenachrichtigung — der + // Titelbalken ist mit dem Endpunkt-Status-Icon bereits eng, und eine + // Notification ist naturgemäß einmalig. Eine Neuinstallation bekommt + // bewusst keine Meldung: dafür gibt es nichts, worüber zu informieren wäre. + if (versionEvent?.type === 'update' && Notification.isSupported()) { + new Notification({ + title: 'Claude Live Dashboard aktualisiert', + body: `Version ${versionEvent.previousVersion} → ${versionEvent.currentVersion}`, + icon: path.join(ROOT, 'assets', 'icon.png'), + }).show(); + } + collector.on('update', (state) => { if (win && !win.isDestroyed()) win.webContents.send('state', state); + if (errorsWin && !errorsWin.isDestroyed()) errorsWin.webContents.send('state', state); if (tray) { const parts = [`Claude · Woche ${Math.round(state.week.percent)} %`]; if (state.block.hasLimit) parts.push(`5h ${Math.round(state.block.percent)} %`); diff --git a/src/main/store.js b/src/main/store.js index 5d14cd7..4bb25bf 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -23,6 +23,11 @@ const DEFAULT_STATE = { calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} }, window: { x: null, y: null, expanded: false }, oauth: { lastFetch: 0, pausedUntil: 0 }, + // Zeitgestempeltes Ereignis-/Fehlerprotokoll (lokale Transkripte, + // Serverabfrage, App-Lebenszyklus) — überdauert bewusst Neustarts. + errors: [], + // Zuletzt gestartete App-Version, für die Neuinstallations-/Update-Erkennung. + appVersion: null, }; class Store { @@ -46,6 +51,7 @@ class Store { calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) }, window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) }, oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) }, + errors: Array.isArray(parsed.errors) ? parsed.errors : [], }; } catch (err) { if (err.code !== 'ENOENT') { diff --git a/src/preload.js b/src/preload.js index f4082c8..7954490 100644 --- a/src/preload.js +++ b/src/preload.js @@ -22,4 +22,6 @@ contextBridge.exposeInMainWorld('dashboard', { hide: () => ipcRenderer.send('hide-window'), /** Öffnet den Einstellungsdialog. */ openSettings: () => ipcRenderer.send('open-settings'), + /** Öffnet das Fehler-/Ereignis-Log. */ + openErrorLog: () => ipcRenderer.send('open-error-log'), }); diff --git a/src/renderer/app.js b/src/renderer/app.js index b88d705..937227f 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -46,7 +46,7 @@ const el = { widget: $('widget'), planLabel: $('plan-label'), staleBadge: $('stale-badge'), - errorBadge: $('error-badge'), + btnEndpointStatus: $('btn-endpoint-status'), btnHide: $('btn-hide'), btnSettings: $('btn-settings'), btnExpand: $('btn-expand'), @@ -275,6 +275,15 @@ function renderSpend(spend) { el.spendA11y.textContent = `Nutzungsguthaben: ${money.format(spend.used)} von ${money.format(spend.limit)} verbraucht.`; } +/** Endpunkt-Status-Icon im Titelbalken — Zustand der beiden Datenquellen. */ +function renderEndpointStatus(s) { + const ep = s.endpoints || { severity: 'ok', local: { ok: true }, oauth: { ok: true, text: '—' } }; + el.btnEndpointStatus.dataset.severity = ep.severity; + el.btnEndpointStatus.title = + `Lokale Transkripte: ${ep.local.ok === false ? 'gestört' : 'aktiv'} · ` + + `Serverabfrage: ${ep.oauth.text || '—'}`; +} + function renderWeek(week, burn) { renderMeter('week', week); renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity); @@ -368,9 +377,9 @@ function renderDetails(s) { if (s.errors && s.errors.length) { el.errors.hidden = false; el.errors.replaceChildren( - ...s.errors.map((msg) => { + ...s.errors.slice(-5).reverse().map((e) => { const li = document.createElement('li'); - li.textContent = msg; + li.textContent = e.message; return li; }), ); @@ -387,10 +396,7 @@ function render(s) { renderBlock(s.block, s.burn); renderWeek(s.week, s.burn); renderSpend(s.spend); - - // Meldungen stehen in der Detailansicht — in der Kopfzeile muss aber - // sichtbar sein, dass es überhaupt eine gibt. - el.errorBadge.hidden = !(s.errors && s.errors.length); + renderEndpointStatus(s); if (expanded) { renderModels(s.block); @@ -455,6 +461,7 @@ el.btnExpand.addEventListener('click', () => applyExpanded(!expanded)); el.btnHide.addEventListener('click', () => window.dashboard.hide()); el.btnSettings.addEventListener('click', () => window.dashboard.openSettings()); +el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog()); window.dashboard.onState(render); diff --git a/src/renderer/index.html b/src/renderer/index.html index a20249d..f7dcd12 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -13,8 +13,10 @@ Claude - +