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 <noreply@anthropic.com>
This commit is contained in:
Winkler, Stefan
2026-08-04 11:07:07 +02:00
co-authored by Claude Opus 4.8
parent c1b899ec8b
commit e149a9118f
15 changed files with 894 additions and 32 deletions
+181
View File
@@ -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-<Version>-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-<Version>-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-<Version>-amd64.deb`
— richtet Menüeintrag und Symbol automatisch ein.
- **AppImage**: Datei ausführbar machen (`chmod +x Claude-Live-Dashboard-<Version>-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.
+63
View File
@@ -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-<Version>-x64.exe` | Windows | Installer, ohne Administratorrechte |
| `Claude Live Dashboard-<Version>-portable.exe` | Windows | ohne Installation, direkt lauffähig |
| `Claude Live Dashboard-<Version>-amd64.deb` | Linux (Debian/Ubuntu) | `sudo apt install ./…deb` |
| `Claude Live Dashboard-<Version>-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.
+56 -6
View File
@@ -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-<version>-x64.exe` | NSIS-Installer, ~97 MB |
| `Claude Live Dashboard-<version>-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-<version>-x64.AppImage` | läuft ohne Installation |
| `Claude Live Dashboard-<version>-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
+1 -1
View File
@@ -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",
+18
View File
@@ -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'),
});
+80
View File
@@ -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);
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self';" />
<title>Endpunkt-Status</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="titlebar">
<span class="title">Endpunkt-Status</span>
<span class="spacer"></span>
<button class="icon-btn" id="btn-close" title="Schließen" aria-label="Schließen"></button>
</header>
<main class="content">
<section class="group">
<h2>Aktueller Status</h2>
<dl class="facts">
<dt>Lokale Transkripte</dt>
<dd id="status-local"><span class="status-dot" aria-hidden="true"></span><span class="status-label"></span></dd>
<dt>Serverabfrage</dt>
<dd id="status-oauth"><span class="status-dot" aria-hidden="true"></span><span class="status-label"></span></dd>
</dl>
</section>
<section class="group">
<h2>Verlauf</h2>
<ul class="log" id="log-list"></ul>
<p class="empty" id="log-empty" hidden>Keine Meldungen bisher.</p>
</section>
</main>
<footer class="actions">
<button class="btn ghost" id="btn-clear">Log löschen</button>
<span class="spacer"></span>
<span class="count" id="log-count"></span>
</footer>
<script src="app.js"></script>
</body>
</html>
+274
View File
@@ -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);
}
+67 -13
View File
@@ -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 },
};
+85 -2
View File
@@ -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)} %`);
+6
View File
@@ -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') {
+2
View File
@@ -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'),
});
+14 -7
View File
@@ -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);
+3 -1
View File
@@ -13,8 +13,10 @@
<span class="dot-brand" aria-hidden="true"></span>
<span class="title" id="plan-label">Claude</span>
<span class="spacer"></span>
<span class="badge warn" id="error-badge" hidden title="Details öffnen für die Meldung">Hinweis</span>
<span class="badge warn" id="stale-badge" hidden>veraltet</span>
<button class="icon-btn status" id="btn-endpoint-status" data-severity="ok" title="Endpunkt-Status" aria-label="Endpunkt-Status">
<span class="status-icon" aria-hidden="true"></span>
</button>
<button class="icon-btn" id="btn-settings" title="Einstellungen" aria-label="Einstellungen">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" focusable="false">
<path
+1 -1
View File
@@ -74,7 +74,7 @@ function setNum(input, value, fallback = '') {
/** Füllt die Oberfläche aus der Konfiguration. */
function fill(config, meta) {
el.path.textContent = meta.configPath;
el.path.textContent = meta.version ? `${meta.configPath} · Version ${meta.version}` : meta.configPath;
const opacity = Math.round((config.opacity ?? DEFAULTS.opacity) * 100);
el.opacity.value = String(opacity);