Initial commit: Claude Live Dashboard
Always-on-Top Electron-Widget für die Claude-Plan-Auslastung unter Windows. Liest Transkripte lokal, fragt optional den /usage-Endpunkt ab, bietet einen Einstellungsdialog und lässt sich als NSIS-Installer oder portable Fassung bauen.
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Nutzerspezifische Claude-Code-Sitzungseinstellungen, nicht projektweit.
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Laufzeitzustand liegt im Benutzerprofil, nicht im Projekt —
|
||||||
|
# ältere Fassungen legten diese Dateien noch hier ab.
|
||||||
|
state.json
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Claude Live Dashboard
|
||||||
|
|
||||||
|
Ein rahmenloses Always-on-Top-Widget für Windows, das die Auslastung des
|
||||||
|
Claude-Plans live anzeigt: 5-Stunden-Fenster, Wochenverbrauch, Reset-Countdown,
|
||||||
|
Tokenverbrauch je Modell und die Hochrechnung auf das Fensterende.
|
||||||
|
|
||||||
|
Die Daten stammen aus den Transkripten, die Claude Code ohnehin unter
|
||||||
|
`~/.claude/projects/**/*.jsonl` schreibt. Es wird nichts installiert, nichts
|
||||||
|
mitgeschnitten und standardmäßig keine Netzwerkverbindung aufgebaut.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
Node.js wird benötigt. Ist es nicht im `PATH` (etwa bei nvm-windows ohne
|
||||||
|
aktive Version), genügt der Versionspfad:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PATH = "$env:APPDATA\nvm\v22.22.1;$env:PATH"
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Tray-Symbol landet unter Windows zunächst im Überlaufbereich hinter dem
|
||||||
|
`^` in der Taskleiste; per Drag lässt es sich dauerhaft sichtbar machen.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
| Aktion | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Kopfzeile ziehen | Fenster verschieben (Position wird gemerkt) |
|
||||||
|
| ⚙ | Einstellungen öffnen |
|
||||||
|
| „Details“ | Modell-Aufschlüsselung, Tempo und Datengrundlage aufklappen |
|
||||||
|
| `–` | Ausblenden — Rückkehr über das Tray-Symbol |
|
||||||
|
| Tray-Klick | Ein-/Ausblenden |
|
||||||
|
| Tray-Menü | Deckkraft, Klick-Durchlässigkeit, Autostart, Einstellungen, Beenden |
|
||||||
|
|
||||||
|
### Einstellungen
|
||||||
|
|
||||||
|
Das Zahnrad in der Kopfzeile öffnet einen Dialog für alle Felder der
|
||||||
|
`config.json`: Deckkraft, Klick-Durchlässigkeit, Autostart, Serverabfrage,
|
||||||
|
Bezugsgrößen, Gewichtung und Wochenfenster.
|
||||||
|
|
||||||
|
Gespeicherte Änderungen greifen **sofort** — ohne Neustart. Der Dialog liest
|
||||||
|
und schreibt dieselbe Datei, die sich auch von Hand bearbeiten lässt:
|
||||||
|
Kommentarfelder (`_hinweis` und Verwandte) und unbekannte Einträge bleiben
|
||||||
|
dabei unangetastet, weil die vorhandene Datei nur ergänzt und nicht ersetzt
|
||||||
|
wird. `Strg`+`S` speichert, `Esc` schließt.
|
||||||
|
|
||||||
|
Autostart ist kein Feld der Datei, sondern ein Systemzustand — er wird deshalb
|
||||||
|
sofort beim Umschalten übernommen, unabhängig vom Speichern.
|
||||||
|
|
||||||
|
„Klicks durchlassen“ macht das Widget für Mausereignisse transparent — es liegt
|
||||||
|
dann über der Arbeit, ohne sie zu behindern. Zurückschalten geht über das
|
||||||
|
Tray-Menü.
|
||||||
|
|
||||||
|
## Was angezeigt wird
|
||||||
|
|
||||||
|
**Laufende Sitzung** — Auslastung des aktuellen 5-Stunden-Fensters, dazu
|
||||||
|
verbrauchte Tokens und Tempo.
|
||||||
|
|
||||||
|
**Wochenkontingent** — das übergeordnete Limit über alle Modelle, mit exaktem
|
||||||
|
Reset-Zeitpunkt.
|
||||||
|
|
||||||
|
**Nutzungsguthaben** — der Budget-Zustand jenseits des Plan-Kontingents
|
||||||
|
(verbraucht von Monatslimit). Erscheint nur, wenn der Server ihn meldet.
|
||||||
|
|
||||||
|
Darunter die eigentliche Kernaussage: *wie lange das Kontingent bei diesem
|
||||||
|
Tempo noch reicht* — gerechnet in **aktiver Arbeitszeit**, nicht in
|
||||||
|
Kalenderzeit. Eine Hochrechnung der Burn-Rate auf fünf Kalendertage wäre
|
||||||
|
Unsinn, weil niemand durchgehend arbeitet.
|
||||||
|
|
||||||
|
## Woher die Werte kommen
|
||||||
|
|
||||||
|
Standardmäßig fragt das Dashboard denselben Endpunkt ab, den auch `/usage`
|
||||||
|
nutzt (`GET /api/oauth/usage` bei `api.anthropic.com`). Damit sind die
|
||||||
|
Prozentwerte, Schweregrade und Reset-Zeitpunkte **exakt** — sekundengenau und
|
||||||
|
ohne Schätzung.
|
||||||
|
|
||||||
|
Das Zugriffstoken wird aus `~/.claude/.credentials.json` gelesen, nie
|
||||||
|
gespeichert, nie protokolliert und nur an diesen Host gesendet. Es gibt bewusst
|
||||||
|
keinen eigenen Refresh-Flow: Läuft das Token ab, pausiert die Abfrage, bis
|
||||||
|
Claude Code es erneuert hat. Nach drei Fehlversuchen schaltet sie sich für die
|
||||||
|
Sitzung ab.
|
||||||
|
|
||||||
|
Abschalten über `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "oauth": { "enabled": false } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** Der Endpunkt ist nicht dokumentiert und kann durch ein Update
|
||||||
|
brechen. Deshalb rechnet das Dashboard *immer* auch lokal weiter — fällt die
|
||||||
|
Abfrage aus, läuft es ohne Unterbrechung auf den eigenen Zahlen weiter, nur
|
||||||
|
eben mit Näherungswerten. Die Anzeige sagt jederzeit, welcher Fall vorliegt:
|
||||||
|
|
||||||
|
| Anzeige | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| **exakt (Server)** | Wert vom Server, entspricht `/usage` — nur mit Stufe 2 |
|
||||||
|
| **eigener Wert** | `limits` aus `config.json` |
|
||||||
|
| **kalibriert** | aus mindestens 5 eigenen abgeschlossenen Fenstern abgeleitet |
|
||||||
|
| **geschätzt** | noch zu wenige Messungen, grober Anhaltspunkt |
|
||||||
|
|
||||||
|
Solange die Serverabfrage läuft, kalibriert sich das Dashboard **selbst**: Der
|
||||||
|
Server nennt den exakten Anteil, lokal ist der gewichtete Verbrauch im selben
|
||||||
|
Fenster bekannt — daraus folgt die Bezugsgröße unmittelbar. Auch das
|
||||||
|
Wochenfenster wird aus dem gemeldeten Reset-Zeitpunkt abgeleitet, nicht
|
||||||
|
geraten.
|
||||||
|
|
||||||
|
Die folgenden Abschnitte betreffen nur den Fall, dass die Abfrage **nicht**
|
||||||
|
verfügbar ist.
|
||||||
|
|
||||||
|
### Kalibrieren (Rückfallebene)
|
||||||
|
|
||||||
|
Ohne Serverabfrage nimmt das Dashboard das höchste je beobachtete
|
||||||
|
Fenstervolumen als untere Schranke des Limits. Das ist grob. Besser: den
|
||||||
|
Prozentwert in **Einstellungen ▸ Nutzung** ablesen und unmittelbar danach:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run calibrate -- --week 14 # nur rechnen
|
||||||
|
npm run calibrate -- --week 14 --write # in config.json übernehmen
|
||||||
|
npm run calibrate -- --block 37 --write # 5-Stunden-Fenster
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Werkzeug teilt den aktuellen Verbrauch durch den angezeigten Anteil und
|
||||||
|
erhält so die Bezugsgröße exakt. Danach das Dashboard neu starten.
|
||||||
|
|
||||||
|
Steht in der Nutzungsanzeige ein fester Reset-Zeitpunkt („Zurücksetzung Mo.,
|
||||||
|
21:59“), gehört er in `config.json` — sonst passt das Fenster nicht zum
|
||||||
|
kalibrierten Wert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "weekResetWeekday": 1, "weekResetHour": 21, "weekResetMinute": 59 }
|
||||||
|
```
|
||||||
|
|
||||||
|
(0 = Sonntag, 1 = Montag …) Ohne diese Angabe rechnet das Dashboard mit einem
|
||||||
|
rollierenden 7-Tage-Fenster und zeigt bewusst keinen Countdown, weil ein
|
||||||
|
rollierendes Fenster keinen Reset-Zeitpunkt hat.
|
||||||
|
|
||||||
|
### Gewichtung
|
||||||
|
|
||||||
|
Tokens zählen nicht gleich: Ausgabe wiegt stärker als ein Cache-Treffer, Opus
|
||||||
|
stärker als Sonnet oder Haiku. Die Faktoren stehen in `config.json`.
|
||||||
|
|
||||||
|
Weichen zwei Kalibrierungen mit **unterschiedlichem Modellmix** deutlich
|
||||||
|
voneinander ab, stimmen diese Gewichte nicht — dann dort nachziehen, statt das
|
||||||
|
Limit hin- und herzuschieben. Ein einzelner Kalibrierpunkt legt nur den
|
||||||
|
Maßstab fest, nicht die Gewichte.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/index.js Fenster, Tray, IPC, Autostart
|
||||||
|
src/main/collector.js Watch-Schleife, Zustandsaufbau
|
||||||
|
src/main/jsonl.js inkrementeller Reader, Parser, Dedup
|
||||||
|
src/main/blocks.js 5h-Blöcke, Wochenfenster, Burn-Rate
|
||||||
|
src/main/limits.js Plan-Erkennung, Kalibrierung, Gewichtung
|
||||||
|
src/main/oauth-usage.js Serverabfrage, gekapselt und ausfallsicher
|
||||||
|
src/main/calibrate.js Kalibrierung gegen die echte Anzeige
|
||||||
|
src/main/store.js state.json, config.json
|
||||||
|
src/main/paths.js Speicherorte für Konfiguration und Zustand
|
||||||
|
src/preload.js contextBridge fürs Widget
|
||||||
|
src/settings-preload.js contextBridge für den Einstellungsdialog
|
||||||
|
src/renderer/ Widget-Oberfläche
|
||||||
|
src/settings/ Einstellungsdialog
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Dateizugriffe und Rechnungen passieren im Main-Prozess; der Renderer
|
||||||
|
bekommt fertige Zahlen und lässt nur den Countdown lokal weiterlaufen.
|
||||||
|
|
||||||
|
Zwei Details, die nicht offensichtlich sind:
|
||||||
|
|
||||||
|
- **Datei-Offsets werden nicht persistiert.** Innerhalb einer Sitzung sorgen sie
|
||||||
|
dafür, dass nur das Delta gelesen wird. Über einen Neustart hinweg wären sie
|
||||||
|
schädlich, weil der Aggregator leer startet und die Historie sonst
|
||||||
|
übersprungen würde. Ein Vollscan über 15 MB kostet rund 70 ms.
|
||||||
|
- **Dedup ist nicht optional.** Claude Code schreibt dieselbe Nachricht bei
|
||||||
|
Retries und beim Kompaktieren mehrfach ins Transkript; ohne Dedup über
|
||||||
|
`message.id` wären alle Zahlen zu hoch.
|
||||||
|
|
||||||
|
## Release bauen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dist # Installer + portable Fassung nach release/
|
||||||
|
npm run pack # nur entpackt nach release/win-unpacked (schneller Test)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis in `release/`:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
Der Installer läuft **ohne Administratorrechte** (`perMachine: false`),
|
||||||
|
installiert also ins Benutzerprofil. Zielverzeichnis ist wählbar, Desktop- und
|
||||||
|
Startmenü-Verknüpfung werden angelegt. Bei der Deinstallation bleiben
|
||||||
|
Konfiguration und Kalibrierung erhalten (`deleteAppDataOnUninstall: false`).
|
||||||
|
|
||||||
|
**Nicht signiert.** Ohne Code-Signing-Zertifikat zeigt Windows beim ersten
|
||||||
|
Start einen SmartScreen-Hinweis („Der Computer wurde durch Windows geschützt“)
|
||||||
|
— über *Weitere Informationen ▸ Trotzdem ausführen* zu bestätigen. Für eine
|
||||||
|
Weitergabe an andere wäre ein Zertifikat nötig; für den Eigenbedarf ist es
|
||||||
|
verzichtbar.
|
||||||
|
|
||||||
|
### Konfiguration und Zustand
|
||||||
|
|
||||||
|
Beides liegt im Benutzerprofil, nicht im Programmverzeichnis — dort wäre es
|
||||||
|
nach der Installation im asar-Archiv und schreibgeschützt:
|
||||||
|
|
||||||
|
```
|
||||||
|
%APPDATA%\Claude Live Dashboard\config.json
|
||||||
|
%APPDATA%\Claude Live Dashboard\state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Start wird `config.json` aus der mitgelieferten Vorlage angelegt.
|
||||||
|
Erreichbar über das Tray-Menü (*Konfiguration öffnen* / *Datenordner öffnen*).
|
||||||
|
|
||||||
|
> Die mitgelieferte Vorlage enthält unter `limits` einen auf **diesem** System
|
||||||
|
> kalibrierten Wert als Rückfallebene. Für eine Weitergabe an andere sollte er
|
||||||
|
> entfernt werden — bei aktiver Serverabfrage wird er ohnehin nicht benutzt.
|
||||||
|
|
||||||
|
## Gegenprobe
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dump # Modell-Summen und alle 5h-Blöcke auf der Konsole
|
||||||
|
npx ccusage blocks # unabhängige Referenz für Blockgrenzen und Summen
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run dump` gibt außerdem die Dauer des zweiten Polls aus — ohne neue Daten
|
||||||
|
muss er 0 Bytes lesen und im einstelligen Millisekundenbereich liegen.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 184 B |
Binary file not shown.
|
After Width: | Height: | Size: 319 B |
+30
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"_hinweis": "Alle Felder sind optional. Fehlt die Datei oder ist sie fehlerhaft, laufen die Voreinstellungen.",
|
||||||
|
"opacity": 1,
|
||||||
|
"clickThrough": false,
|
||||||
|
"_limits": "Rückfallebene, falls die Serverabfrage ausfällt. Bezugsgröße für 100 % in gewichteten Tokens; per `npm run calibrate` ermittelbar.",
|
||||||
|
"limits": {
|
||||||
|
"week": 249013747
|
||||||
|
},
|
||||||
|
"_tokenWeights": "Wie stark die Token-Arten ins Kontingent zählen.",
|
||||||
|
"tokenWeights": {
|
||||||
|
"input": 1,
|
||||||
|
"cacheCreate": 1.25,
|
||||||
|
"cacheRead": 0.1,
|
||||||
|
"output": 5
|
||||||
|
},
|
||||||
|
"_modelWeights": "Wie stark die Modellfamilien ins Kontingent zählen.",
|
||||||
|
"modelWeights": {
|
||||||
|
"opus": 5,
|
||||||
|
"sonnet": 1,
|
||||||
|
"haiku": 0.3
|
||||||
|
},
|
||||||
|
"_weekReset": "Echter Reset-Zeitpunkt laut Einstellungen > Nutzung (0 = Sonntag). Weglassen = rollierendes 7-Tage-Fenster.",
|
||||||
|
"weekResetWeekday": 1,
|
||||||
|
"weekResetHour": 21,
|
||||||
|
"weekResetMinute": 59,
|
||||||
|
"_oauth": "Serverabfrage für exakte Werte. Standardmäßig aktiv; mit enabled:false abschalten. endpoint nur setzen, um den Standard zu überschreiben.",
|
||||||
|
"oauth": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4522
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-live-dashboard",
|
||||||
|
"productName": "Claude Live Dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows",
|
||||||
|
"main": "src/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dump": "node src/main/dump.js",
|
||||||
|
"calibrate": "node src/main/calibrate.js",
|
||||||
|
"icons": "node tools/make-icon.js",
|
||||||
|
"dist": "electron-builder --win",
|
||||||
|
"pack": "electron-builder --win --dir"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^43.2.0",
|
||||||
|
"electron-builder": "^25.1.8"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "de.local.claude-live-dashboard",
|
||||||
|
"productName": "Claude Live Dashboard",
|
||||||
|
"copyright": "Copyright © 2026",
|
||||||
|
"directories": {
|
||||||
|
"output": "release",
|
||||||
|
"buildResources": "assets"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/**/*",
|
||||||
|
"assets/**/*",
|
||||||
|
"config.json",
|
||||||
|
"!**/*.map"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "portable",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": "assets/icon.png",
|
||||||
|
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "Claude Live Dashboard",
|
||||||
|
"installerLanguages": [
|
||||||
|
"de_DE"
|
||||||
|
],
|
||||||
|
"language": "1031",
|
||||||
|
"deleteAppDataOnUninstall": false
|
||||||
|
},
|
||||||
|
"portable": {
|
||||||
|
"artifactName": "${productName}-${version}-portable.${ext}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregation des Tokenverbrauchs zu rollierenden 5-Stunden-Blöcken,
|
||||||
|
* Wochenfenster, Burn-Rate und Projektion.
|
||||||
|
*
|
||||||
|
* Die Rohdaten werden zu Minuten-Buckets je Modell verdichtet. Für alle
|
||||||
|
* Anzeigen des Dashboards reicht Minutenauflösung, und der Speicherbedarf
|
||||||
|
* bleibt auch bei intensiver Nutzung über eine Woche im niedrigen
|
||||||
|
* einstelligen Megabyte-Bereich.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MINUTE_MS = 60 * 1000;
|
||||||
|
const HOUR_MS = 60 * MINUTE_MS;
|
||||||
|
const FIVE_HOURS_MS = 5 * HOUR_MS;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
|
||||||
|
/** Aufbewahrung: 8 Tage, damit das 7-Tage-Fenster immer vollständig ist. */
|
||||||
|
const RETENTION_MS = 8 * DAY_MS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relatives Gewicht der Token-Arten. Die Plan-Limits sind nicht
|
||||||
|
* token-linear — Output wiegt deutlich schwerer als ein Cache-Treffer.
|
||||||
|
* Die Werte orientieren sich an den Preisrelationen der API und sind über
|
||||||
|
* config.json überschreibbar.
|
||||||
|
*/
|
||||||
|
const DEFAULT_TOKEN_WEIGHTS = {
|
||||||
|
input: 1,
|
||||||
|
cacheCreate: 1.25,
|
||||||
|
cacheRead: 0.1,
|
||||||
|
output: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relatives Gewicht der Modellfamilien. Opus zehrt spürbar stärker am
|
||||||
|
* Kontingent als Sonnet. Die exakten Faktoren sind nicht öffentlich —
|
||||||
|
* Startwerte als Annahme, per config.json justierbar.
|
||||||
|
*/
|
||||||
|
const DEFAULT_MODEL_WEIGHTS = {
|
||||||
|
opus: 5,
|
||||||
|
sonnet: 1,
|
||||||
|
fable: 1,
|
||||||
|
haiku: 0.3,
|
||||||
|
unknown: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Leeres Token-Tupel. */
|
||||||
|
function emptyTotals() {
|
||||||
|
return { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTotals(target, src) {
|
||||||
|
target.input += src.input;
|
||||||
|
target.output += src.output;
|
||||||
|
target.cacheRead += src.cacheRead;
|
||||||
|
target.cacheCreate += src.cacheCreate;
|
||||||
|
target.count += src.count || 0;
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rohe Tokensumme ohne jede Gewichtung — für die Detailanzeige. */
|
||||||
|
function rawTotal(t) {
|
||||||
|
return t.input + t.output + t.cacheRead + t.cacheCreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UsageAggregator {
|
||||||
|
/**
|
||||||
|
* @param {{tokenWeights?: Object, modelWeights?: Object,
|
||||||
|
* sessionMs?: number, weekMs?: number}} options
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.tokenWeights = { ...DEFAULT_TOKEN_WEIGHTS, ...(options.tokenWeights || {}) };
|
||||||
|
this.modelWeights = { ...DEFAULT_MODEL_WEIGHTS, ...(options.modelWeights || {}) };
|
||||||
|
this.sessionMs = options.sessionMs || FIVE_HOURS_MS;
|
||||||
|
this.weekMs = options.weekMs || 7 * DAY_MS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minuten-Buckets: Key `${minuteIndex}|${model}` → Totals + Metadaten.
|
||||||
|
* @type {Map<string, {minute: number, model: string, family: string} & ReturnType<emptyTotals>}
|
||||||
|
*/
|
||||||
|
this.buckets = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gewichteter Beitrag eines Token-Tupels für eine Modellfamilie. */
|
||||||
|
weigh(totals, family) {
|
||||||
|
const w = this.tokenWeights;
|
||||||
|
const base =
|
||||||
|
totals.input * w.input +
|
||||||
|
totals.output * w.output +
|
||||||
|
totals.cacheRead * w.cacheRead +
|
||||||
|
totals.cacheCreate * w.cacheCreate;
|
||||||
|
const mw = this.modelWeights[family] ?? this.modelWeights.unknown ?? 1;
|
||||||
|
return base * mw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Array} entries Einträge aus jsonl.js (beliebige Reihenfolge) */
|
||||||
|
add(entries) {
|
||||||
|
for (const e of entries) {
|
||||||
|
const minute = Math.floor(e.ts / MINUTE_MS);
|
||||||
|
const key = `${minute}|${e.model}`;
|
||||||
|
let b = this.buckets.get(key);
|
||||||
|
if (!b) {
|
||||||
|
b = { minute, model: e.model, family: e.family, ...emptyTotals() };
|
||||||
|
this.buckets.set(key, b);
|
||||||
|
}
|
||||||
|
b.input += e.input;
|
||||||
|
b.output += e.output;
|
||||||
|
b.cacheRead += e.cacheRead;
|
||||||
|
b.cacheCreate += e.cacheCreate;
|
||||||
|
b.count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt Buckets außerhalb des Aufbewahrungsfensters. */
|
||||||
|
prune(now = Date.now()) {
|
||||||
|
const cutoff = Math.floor((now - RETENTION_MS) / MINUTE_MS);
|
||||||
|
for (const [key, b] of this.buckets) {
|
||||||
|
if (b.minute < cutoff) this.buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buckets aufsteigend nach Zeit. */
|
||||||
|
sortedBuckets() {
|
||||||
|
return [...this.buckets.values()].sort((a, b) => a.minute - b.minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet rollierende 5h-Blöcke.
|
||||||
|
*
|
||||||
|
* Ein Block startet zur vollen Stunde des ersten Eintrags. Ein neuer Block
|
||||||
|
* beginnt, sobald seit Blockstart mehr als die Sessiondauer vergangen ist
|
||||||
|
* oder eine Lücke von mindestens der Sessiondauer zwischen zwei Einträgen
|
||||||
|
* liegt — dasselbe Verfahren, das auch ccusage anwendet.
|
||||||
|
*/
|
||||||
|
buildBlocks(now = Date.now()) {
|
||||||
|
const buckets = this.sortedBuckets();
|
||||||
|
const blocks = [];
|
||||||
|
let current = null;
|
||||||
|
let lastTs = null;
|
||||||
|
|
||||||
|
for (const b of buckets) {
|
||||||
|
const ts = b.minute * MINUTE_MS;
|
||||||
|
const startNew =
|
||||||
|
!current ||
|
||||||
|
ts >= current.start + this.sessionMs ||
|
||||||
|
(lastTs !== null && ts - lastTs >= this.sessionMs);
|
||||||
|
|
||||||
|
if (startNew) {
|
||||||
|
current = {
|
||||||
|
start: Math.floor(ts / HOUR_MS) * HOUR_MS,
|
||||||
|
totals: emptyTotals(),
|
||||||
|
byModel: new Map(),
|
||||||
|
weighted: 0,
|
||||||
|
lastActivity: ts,
|
||||||
|
};
|
||||||
|
current.end = current.start + this.sessionMs;
|
||||||
|
blocks.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTotals(current.totals, b);
|
||||||
|
current.weighted += this.weigh(b, b.family);
|
||||||
|
current.lastActivity = ts;
|
||||||
|
|
||||||
|
let m = current.byModel.get(b.model);
|
||||||
|
if (!m) {
|
||||||
|
m = { model: b.model, family: b.family, ...emptyTotals() };
|
||||||
|
current.byModel.set(b.model, m);
|
||||||
|
}
|
||||||
|
addTotals(m, b);
|
||||||
|
|
||||||
|
lastTs = ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
block.isActive = now >= block.start && now < block.end;
|
||||||
|
block.models = [...block.byModel.values()]
|
||||||
|
.map((m) => ({ ...m, raw: rawTotal(m), weighted: this.weigh(m, m.family) }))
|
||||||
|
.sort((a, b) => b.weighted - a.weighted);
|
||||||
|
delete block.byModel;
|
||||||
|
block.raw = rawTotal(block.totals);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summiert alle Buckets in einem Zeitfenster. */
|
||||||
|
windowTotals(fromTs, toTs) {
|
||||||
|
const fromMin = Math.floor(fromTs / MINUTE_MS);
|
||||||
|
const toMin = Math.floor(toTs / MINUTE_MS);
|
||||||
|
const totals = emptyTotals();
|
||||||
|
const byModel = new Map();
|
||||||
|
let weighted = 0;
|
||||||
|
|
||||||
|
for (const b of this.buckets.values()) {
|
||||||
|
if (b.minute < fromMin || b.minute > toMin) continue;
|
||||||
|
addTotals(totals, b);
|
||||||
|
weighted += this.weigh(b, b.family);
|
||||||
|
let m = byModel.get(b.model);
|
||||||
|
if (!m) {
|
||||||
|
m = { model: b.model, family: b.family, ...emptyTotals() };
|
||||||
|
byModel.set(b.model, m);
|
||||||
|
}
|
||||||
|
addTotals(m, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: fromTs,
|
||||||
|
to: toTs,
|
||||||
|
totals,
|
||||||
|
raw: rawTotal(totals),
|
||||||
|
weighted,
|
||||||
|
models: [...byModel.values()]
|
||||||
|
.map((m) => ({ ...m, raw: rawTotal(m), weighted: this.weigh(m, m.family) }))
|
||||||
|
.sort((a, b) => b.weighted - a.weighted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gewichteter Verbrauch pro Minute über die letzten `windowMs`.
|
||||||
|
* Basis für Projektion und Restzeit-Schätzung.
|
||||||
|
*/
|
||||||
|
burnRate(now = Date.now(), windowMs = 30 * MINUTE_MS) {
|
||||||
|
const w = this.windowTotals(now - windowMs, now);
|
||||||
|
return w.weighted / (windowMs / MINUTE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständiger Zustand für die UI.
|
||||||
|
* @param {number} now
|
||||||
|
* @param {{weekAnchor?: number}} options weekAnchor = Beginn des Wochenfensters
|
||||||
|
*/
|
||||||
|
snapshot(now = Date.now(), options = {}) {
|
||||||
|
const blocks = this.buildBlocks(now);
|
||||||
|
const active = blocks.find((b) => b.isActive) || null;
|
||||||
|
|
||||||
|
const weekFrom = options.weekAnchor ?? now - this.weekMs;
|
||||||
|
const week = this.windowTotals(weekFrom, now);
|
||||||
|
week.resetAt = weekFrom + this.weekMs;
|
||||||
|
|
||||||
|
const rate = this.burnRate(now);
|
||||||
|
|
||||||
|
let block = null;
|
||||||
|
if (active) {
|
||||||
|
const remainingMs = active.end - now;
|
||||||
|
block = {
|
||||||
|
...active,
|
||||||
|
remainingMs,
|
||||||
|
// Hochrechnung auf das Blockende bei gleichbleibendem Tempo.
|
||||||
|
projectedWeighted: active.weighted + rate * (remainingMs / MINUTE_MS),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Kein aktiver Block: Das nächste Fenster beginnt mit der nächsten Anfrage.
|
||||||
|
block = {
|
||||||
|
start: null,
|
||||||
|
end: null,
|
||||||
|
totals: emptyTotals(),
|
||||||
|
raw: 0,
|
||||||
|
weighted: 0,
|
||||||
|
models: [],
|
||||||
|
isActive: false,
|
||||||
|
remainingMs: 0,
|
||||||
|
projectedWeighted: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
now,
|
||||||
|
block,
|
||||||
|
week,
|
||||||
|
burnRatePerMinute: rate,
|
||||||
|
blockHistory: blocks.slice(-12).map((b) => ({
|
||||||
|
start: b.start,
|
||||||
|
end: b.end,
|
||||||
|
weighted: b.weighted,
|
||||||
|
raw: b.raw,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
UsageAggregator,
|
||||||
|
DEFAULT_TOKEN_WEIGHTS,
|
||||||
|
DEFAULT_MODEL_WEIGHTS,
|
||||||
|
FIVE_HOURS_MS,
|
||||||
|
MINUTE_MS,
|
||||||
|
HOUR_MS,
|
||||||
|
DAY_MS,
|
||||||
|
rawTotal,
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalibrierung gegen die echte Nutzungsanzeige.
|
||||||
|
*
|
||||||
|
* Die Plan-Limits sind nicht als Token-Zahlen veröffentlicht. Wer den echten
|
||||||
|
* Prozentwert kennt — Einstellungen ▸ Nutzung, oder `/usage` in Claude Code —
|
||||||
|
* kann daraus die Bezugsgröße exakt bestimmen: Sie ist der aktuelle Verbrauch
|
||||||
|
* geteilt durch den angezeigten Anteil.
|
||||||
|
*
|
||||||
|
* npm run calibrate -- --week 14
|
||||||
|
* npm run calibrate -- --block 37
|
||||||
|
* npm run calibrate -- --week 14 --block 37 --write
|
||||||
|
*
|
||||||
|
* Ohne --write wird nur gerechnet und nichts verändert.
|
||||||
|
*
|
||||||
|
* Der Abgleich lohnt sich nach jeder größeren Änderung des Nutzungsverhaltens.
|
||||||
|
* Weichen zwei Kalibrierungen mit unterschiedlichem Modellmix stark
|
||||||
|
* voneinander ab, stimmen die Modellgewichte in config.json nicht — dann dort
|
||||||
|
* nachziehen statt das Limit hin- und herzuschieben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
const { TranscriptReader } = require('./jsonl');
|
||||||
|
const { UsageAggregator, MINUTE_MS } = require('./blocks');
|
||||||
|
const { weekAnchor } = require('./limits');
|
||||||
|
const { loadConfig } = require('./store');
|
||||||
|
const { CONFIG_FILE, ensureUserConfig } = require('./paths');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { write: false };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--write') args.write = true;
|
||||||
|
else if (a === '--week' || a === '--block') {
|
||||||
|
const value = Number(String(argv[++i]).replace(',', '.').replace('%', ''));
|
||||||
|
if (!Number.isFinite(value) || value <= 0 || value > 100) {
|
||||||
|
throw new Error(`${a} erwartet einen Prozentwert zwischen 0 und 100`);
|
||||||
|
}
|
||||||
|
args[a.slice(2)] = value;
|
||||||
|
} else if (a === '--help' || a === '-h') {
|
||||||
|
args.help = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unbekannte Option: ${a}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (n) => Math.round(n).toLocaleString('de-DE');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (args.help || (args.week == null && args.block == null)) {
|
||||||
|
console.log(`Kalibrierung gegen die echte Nutzungsanzeige.
|
||||||
|
|
||||||
|
npm run calibrate -- --week <prozent> Wochenfenster kalibrieren
|
||||||
|
npm run calibrate -- --block <prozent> 5-Stunden-Fenster kalibrieren
|
||||||
|
npm run calibrate -- --week 14 --write Ergebnis in config.json schreiben
|
||||||
|
|
||||||
|
Den Prozentwert aus Einstellungen > Nutzung bzw. /usage ablesen und
|
||||||
|
moeglichst unmittelbar danach kalibrieren — der Verbrauch laeuft weiter.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureUserConfig();
|
||||||
|
const { config, error } = loadConfig(CONFIG_FILE);
|
||||||
|
if (error) {
|
||||||
|
console.error(`Achtung: ${error}`);
|
||||||
|
console.error('Es wird mit Voreinstellungen gerechnet.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entries } = await new TranscriptReader().poll();
|
||||||
|
const agg = new UsageAggregator({
|
||||||
|
tokenWeights: config.tokenWeights,
|
||||||
|
modelWeights: config.modelWeights,
|
||||||
|
sessionMs: config.sessionHours ? config.sessionHours * 60 * MINUTE_MS : undefined,
|
||||||
|
});
|
||||||
|
agg.add(entries);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
if (args.week != null) {
|
||||||
|
const anchor = weekAnchor(now, config);
|
||||||
|
const w = agg.windowTotals(anchor, now);
|
||||||
|
const limit = (w.weighted / args.week) * 100;
|
||||||
|
results.week = limit;
|
||||||
|
|
||||||
|
const rolling = config.weekResetWeekday == null;
|
||||||
|
console.log('── Wochenfenster ──────────────────────────────────────────');
|
||||||
|
console.log(` Fenster ab: ${new Date(anchor).toLocaleString('de-DE')}${rolling ? ' (rollierend)' : ''}`);
|
||||||
|
console.log(` Verbrauch roh: ${fmt(w.raw)}`);
|
||||||
|
console.log(` Verbrauch gew.: ${fmt(w.weighted)}`);
|
||||||
|
console.log(` Angezeigt: ${args.week} %`);
|
||||||
|
console.log(` ⇒ Limit gew.: ${fmt(limit)}`);
|
||||||
|
if (rolling) {
|
||||||
|
console.log(
|
||||||
|
'\n Hinweis: Ohne weekResetWeekday rechnet das Dashboard rollierend.\n' +
|
||||||
|
' Steht in der Nutzungsanzeige ein fester Reset-Zeitpunkt, trage ihn\n' +
|
||||||
|
' ein — sonst passt das Fenster nicht zum kalibrierten Wert.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.block != null) {
|
||||||
|
const blocks = agg.buildBlocks(now);
|
||||||
|
const active = blocks.find((b) => b.isActive);
|
||||||
|
if (!active) {
|
||||||
|
console.error('Kein aktives 5-Stunden-Fenster — es liegt gerade kein Verbrauch an.');
|
||||||
|
console.error('Kalibriere waehrend oder direkt nach einer Sitzung.\n');
|
||||||
|
} else {
|
||||||
|
const limit = (active.weighted / args.block) * 100;
|
||||||
|
results.block = limit;
|
||||||
|
console.log('── 5-Stunden-Fenster ──────────────────────────────────────');
|
||||||
|
console.log(` Fenster: ${new Date(active.start).toLocaleString('de-DE')} – ${new Date(active.end).toLocaleTimeString('de-DE')}`);
|
||||||
|
console.log(` Verbrauch roh: ${fmt(active.raw)}`);
|
||||||
|
console.log(` Verbrauch gew.: ${fmt(active.weighted)}`);
|
||||||
|
console.log(` Angezeigt: ${args.block} %`);
|
||||||
|
console.log(` ⇒ Limit gew.: ${fmt(limit)}`);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.write) {
|
||||||
|
console.log('Nichts geschrieben. Mit --write in config.json uebernehmen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestehende Datei zeichengenau einlesen, damit Kommentarfelder und
|
||||||
|
// Formatierung des Nutzers erhalten bleiben.
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`config.json ist nicht lesbar (${err.message}) — nichts geschrieben.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
raw.limits = { ...(raw.limits || {}) };
|
||||||
|
for (const [key, value] of Object.entries(results)) {
|
||||||
|
raw.limits[key] = Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
|
||||||
|
console.log(`In ${CONFIG_FILE} geschrieben:`);
|
||||||
|
for (const [key, value] of Object.entries(results)) {
|
||||||
|
console.log(` limits.${key} = ${fmt(value)}`);
|
||||||
|
}
|
||||||
|
console.log('\nDashboard neu starten, damit die Werte greifen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrierung: überwacht die Transkripte, hält den Aggregator aktuell und
|
||||||
|
* stellt den fertig gerechneten Zustand für die Oberfläche bereit.
|
||||||
|
*
|
||||||
|
* Der Renderer bekommt ausschließlich fertige Zahlen — alle Dateizugriffe und
|
||||||
|
* jede Rechnung passieren hier im Main-Prozess.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const { EventEmitter } = require('node:events');
|
||||||
|
|
||||||
|
const { TranscriptReader, readSubscription, PROJECTS_DIR } = require('./jsonl');
|
||||||
|
const { UsageAggregator, MINUTE_MS } = require('./blocks');
|
||||||
|
const { LimitModel, weekAnchor, severity } = require('./limits');
|
||||||
|
const { OAuthUsage } = require('./oauth-usage');
|
||||||
|
|
||||||
|
/** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
/** Sicherheitsnetz, falls der Watcher ein Ereignis verschluckt. */
|
||||||
|
const SAFETY_POLL_MS = 15000;
|
||||||
|
/** Taktung der Neuberechnung, damit Countdown und Burn-Rate nicht einfrieren. */
|
||||||
|
const RECOMPUTE_MS = 5000;
|
||||||
|
|
||||||
|
/** Übersetzt den Schweregrad des Servers in die eigenen Stufen. */
|
||||||
|
function serverSeverity(value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'normal':
|
||||||
|
return 'ok';
|
||||||
|
case 'warning':
|
||||||
|
case 'warn':
|
||||||
|
return 'warn';
|
||||||
|
case 'critical':
|
||||||
|
case 'serious':
|
||||||
|
return 'critical';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Collector extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* @param {{config: Object, store: import('./store').Store, configError?: string|null}} deps
|
||||||
|
*/
|
||||||
|
constructor({ config, store, configError = null }) {
|
||||||
|
super();
|
||||||
|
this.config = config || {};
|
||||||
|
this.store = store;
|
||||||
|
this.configError = configError;
|
||||||
|
|
||||||
|
// Bewusst ohne persistierte Offsets: Die Offsets beschleunigen das Lesen
|
||||||
|
// *innerhalb* einer Sitzung. Über einen Neustart hinweg wären sie schädlich
|
||||||
|
// — der Aggregator startet leer, und mit fortgeschrittenen Offsets bliebe
|
||||||
|
// die gesamte Historie ungelesen. Ein Vollscan kostet rund 70 ms.
|
||||||
|
this.reader = new TranscriptReader();
|
||||||
|
this.aggregator = new UsageAggregator({
|
||||||
|
tokenWeights: this.config.tokenWeights,
|
||||||
|
modelWeights: this.config.modelWeights,
|
||||||
|
sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined,
|
||||||
|
});
|
||||||
|
this.oauth = new OAuthUsage(this.config.oauth || {}, store);
|
||||||
|
|
||||||
|
this.subscription = { subscriptionType: null, rateLimitTier: null };
|
||||||
|
this.limits = null;
|
||||||
|
this.state = null;
|
||||||
|
this.errors = [];
|
||||||
|
if (configError) this.errors.push(configError);
|
||||||
|
|
||||||
|
this._watcher = null;
|
||||||
|
this._debounce = null;
|
||||||
|
this._timers = [];
|
||||||
|
this._polling = false;
|
||||||
|
this._pollAgain = false;
|
||||||
|
this._lastPollMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.subscription = await readSubscription();
|
||||||
|
this.limits = new LimitModel(this.config, this.store.data.calibration, this.subscription);
|
||||||
|
|
||||||
|
await this._poll();
|
||||||
|
this._startWatcher();
|
||||||
|
|
||||||
|
this._timers.push(setInterval(() => this._poll(), SAFETY_POLL_MS));
|
||||||
|
this._timers.push(setInterval(() => this._recompute(), RECOMPUTE_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt eine geänderte Konfiguration im laufenden Betrieb.
|
||||||
|
*
|
||||||
|
* Die gesammelten Messwerte bleiben erhalten — geändert werden nur die
|
||||||
|
* Rechenparameter. Die Gewichte greifen sofort, weil sie bei jeder
|
||||||
|
* Auswertung neu angewandt und nicht in den Buckets vorberechnet werden.
|
||||||
|
*/
|
||||||
|
applyConfig(config) {
|
||||||
|
this.config = config || {};
|
||||||
|
|
||||||
|
this.aggregator.tokenWeights = {
|
||||||
|
...this.aggregator.tokenWeights,
|
||||||
|
...(this.config.tokenWeights || {}),
|
||||||
|
};
|
||||||
|
this.aggregator.modelWeights = {
|
||||||
|
...this.aggregator.modelWeights,
|
||||||
|
...(this.config.modelWeights || {}),
|
||||||
|
};
|
||||||
|
if (this.config.sessionHours > 0) {
|
||||||
|
this.aggregator.sessionMs = this.config.sessionHours * 60 * MINUTE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serverabfrage neu aufsetzen: An- und Abschalten sowie ein geänderter
|
||||||
|
// Endpunkt müssen sofort wirken, und ein Fehlerzähler von vorher darf eine
|
||||||
|
// frisch aktivierte Abfrage nicht blockieren.
|
||||||
|
this.oauth = new OAuthUsage(this.config.oauth || {}, this.store);
|
||||||
|
|
||||||
|
this.limits = new LimitModel(this.config, this.store.data.calibration, this.subscription);
|
||||||
|
this.errors = [];
|
||||||
|
|
||||||
|
this._recompute().catch((err) => this._note(`Neuberechnung fehlgeschlagen: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
for (const t of this._timers) clearInterval(t);
|
||||||
|
this._timers = [];
|
||||||
|
if (this._debounce) clearTimeout(this._debounce);
|
||||||
|
if (this._watcher) {
|
||||||
|
this._watcher.close();
|
||||||
|
this._watcher = null;
|
||||||
|
}
|
||||||
|
this.store.saveSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_startWatcher() {
|
||||||
|
try {
|
||||||
|
// Node unterstützt rekursives Beobachten auf Windows nativ — damit
|
||||||
|
// braucht es keine zusätzliche Watcher-Bibliothek.
|
||||||
|
this._watcher = fs.watch(PROJECTS_DIR, { recursive: true }, (_event, filename) => {
|
||||||
|
if (filename && !String(filename).endsWith('.jsonl')) return;
|
||||||
|
if (this._debounce) clearTimeout(this._debounce);
|
||||||
|
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.`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Ohne Watcher greift das Sicherheitsnetz alle 15 s.
|
||||||
|
this._note(`Dateiüberwachung nicht verfügbar: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_note(message) {
|
||||||
|
if (!this.errors.includes(message)) {
|
||||||
|
this.errors.push(message);
|
||||||
|
if (this.errors.length > 5) this.errors.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _poll() {
|
||||||
|
if (this._polling) {
|
||||||
|
this._pollAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._polling = true;
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const { entries } = await this.reader.poll();
|
||||||
|
if (entries.length) this.aggregator.add(entries);
|
||||||
|
this.aggregator.prune();
|
||||||
|
this._lastPollMs = Date.now() - t0;
|
||||||
|
await this._recompute();
|
||||||
|
} catch (err) {
|
||||||
|
this._note(`Lesen fehlgeschlagen: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
this._polling = false;
|
||||||
|
if (this._pollAgain) {
|
||||||
|
this._pollAgain = false;
|
||||||
|
setImmediate(() => this._poll());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merkt sich eine aus der Serverantwort abgeleitete Bezugsgröße, damit die
|
||||||
|
* Anzeige bei einem Ausfall der Abfrage nicht auf Schätzwerte zurückfällt.
|
||||||
|
*
|
||||||
|
* Nur ab 5 % Auslastung: Der Server rundet auf ganze Prozent, und bei einem
|
||||||
|
* kleinen Nenner wird der abgeleitete Wert dadurch beliebig ungenau.
|
||||||
|
*/
|
||||||
|
_rememberServerLimit(kind, limit, percent) {
|
||||||
|
if (!(percent >= 5) || !(limit > 0)) return;
|
||||||
|
const store = this.store.data.calibration;
|
||||||
|
if (!store.serverLimits) store.serverLimits = {};
|
||||||
|
const previous = store.serverLimits[kind];
|
||||||
|
// Leichte Glättung gegen das Rundungsrauschen aufeinanderfolgender Werte.
|
||||||
|
const next = previous > 0 ? previous * 0.7 + limit * 0.3 : limit;
|
||||||
|
if (!previous || Math.abs(next - previous) / previous > 0.005) {
|
||||||
|
store.serverLimits[kind] = Math.round(next);
|
||||||
|
this.store.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut den Zustand für die Oberfläche und meldet ihn. */
|
||||||
|
async _recompute() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Zuerst der Server: Sein Reset-Zeitpunkt legt das Wochenfenster fest.
|
||||||
|
// 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);
|
||||||
|
const anchor = server?.weekResetAt
|
||||||
|
? server.weekResetAt - this.aggregator.weekMs
|
||||||
|
: weekAnchor(now, this.config);
|
||||||
|
|
||||||
|
const snapshot = this.aggregator.snapshot(now, { weekAnchor: anchor });
|
||||||
|
const blocks = this.aggregator.buildBlocks(now);
|
||||||
|
|
||||||
|
if (this.limits.observe(snapshot, blocks)) {
|
||||||
|
this.store.data.calibration = this.limits.calibration;
|
||||||
|
this.store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = this.limits.evaluate('block', snapshot.block.weighted, server?.blockLimit);
|
||||||
|
const week = this.limits.evaluate('week', snapshot.week.weighted, server?.weekLimit);
|
||||||
|
|
||||||
|
// Ein exakter Prozentwert vom Server sticht die lokale Rechnung.
|
||||||
|
if (server?.blockPercent != null) {
|
||||||
|
block.percent = server.blockPercent <= 1 ? server.blockPercent * 100 : server.blockPercent;
|
||||||
|
block.confidence = 'exact';
|
||||||
|
}
|
||||||
|
if (server?.weekPercent != null) {
|
||||||
|
week.percent = server.weekPercent <= 1 ? server.weekPercent * 100 : server.weekPercent;
|
||||||
|
week.confidence = 'exact';
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockResetAt = server?.blockResetAt ?? snapshot.block.end;
|
||||||
|
|
||||||
|
// Ein rollierendes 7-Tage-Fenster hat keinen Reset-Zeitpunkt — es wandert
|
||||||
|
// kontinuierlich mit. Einen Countdown darauf zu zeigen wäre irreführend.
|
||||||
|
const rollingWeek = this.config.weekResetWeekday == null;
|
||||||
|
const weekResetAt = server?.weekResetAt ?? (rollingWeek ? null : snapshot.week.resetAt);
|
||||||
|
|
||||||
|
// Der Balken erscheint, sobald eine belastbare Bezugsgröße vorliegt —
|
||||||
|
// vom Server, aus der Konfiguration oder aus einer früheren Serverantwort.
|
||||||
|
// Bloß geschätzte Werte rechtfertigen keinen Auslastungsbalken.
|
||||||
|
const blockHasLimit =
|
||||||
|
server?.blockPercent != null || ['configured', 'calibrated', 'exact'].includes(block.confidence);
|
||||||
|
|
||||||
|
// Live-Kalibrierung: Der Server nennt den exakten Anteil, lokal ist der
|
||||||
|
// gewichtete Verbrauch im selben Fenster bekannt — daraus folgt die
|
||||||
|
// Bezugsgröße direkt, ohne Zutun des Nutzers und ohne Schätzwerte.
|
||||||
|
let effectiveWeekLimit = week.limit;
|
||||||
|
if (server?.weekPercent > 0 && snapshot.week.weighted > 0) {
|
||||||
|
effectiveWeekLimit = snapshot.week.weighted / (server.weekPercent / 100);
|
||||||
|
this._rememberServerLimit('week', effectiveWeekLimit, server.weekPercent);
|
||||||
|
}
|
||||||
|
let effectiveBlockLimit = block.limit;
|
||||||
|
if (server?.blockPercent > 0 && snapshot.block.weighted > 0) {
|
||||||
|
effectiveBlockLimit = snapshot.block.weighted / (server.blockPercent / 100);
|
||||||
|
this._rememberServerLimit('block', effectiveBlockLimit, server.blockPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbleibende *aktive* Arbeitszeit bis zum Wochenlimit. Bewusst nicht als
|
||||||
|
// Kalenderzeit hochgerechnet: Niemand arbeitet fünf Tage durch, eine
|
||||||
|
// Hochrechnung der Burn-Rate auf die Kalenderwoche wäre Unsinn.
|
||||||
|
const remainingWeek = Math.max(0, effectiveWeekLimit - snapshot.week.weighted);
|
||||||
|
const activeTimeLeftMs =
|
||||||
|
snapshot.burnRatePerMinute > 0
|
||||||
|
? (remainingWeek / snapshot.burnRatePerMinute) * MINUTE_MS
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Kurzfristprojektion: Wo steht das Wochenkontingent, wenn eine weitere
|
||||||
|
// Stunde in diesem Tempo gearbeitet wird?
|
||||||
|
const weekInOneHour = snapshot.week.weighted + snapshot.burnRatePerMinute * 60;
|
||||||
|
const weekProjectedPercent =
|
||||||
|
effectiveWeekLimit > 0 ? (weekInOneHour / effectiveWeekLimit) * 100 : 0;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
updatedAt: now,
|
||||||
|
subscription: this.subscription,
|
||||||
|
block: {
|
||||||
|
...block,
|
||||||
|
hasLimit: blockHasLimit,
|
||||||
|
severity: serverSeverity(server?.blockSeverity) || severity(block.percent),
|
||||||
|
resetAt: blockResetAt,
|
||||||
|
remainingMs: blockResetAt ? Math.max(0, blockResetAt - now) : 0,
|
||||||
|
active: snapshot.block.isActive,
|
||||||
|
raw: snapshot.block.raw,
|
||||||
|
messages: snapshot.block.totals.count,
|
||||||
|
models: snapshot.block.models.map((m) => ({
|
||||||
|
model: m.model,
|
||||||
|
family: m.family,
|
||||||
|
raw: m.raw,
|
||||||
|
weighted: m.weighted,
|
||||||
|
input: m.input,
|
||||||
|
output: m.output,
|
||||||
|
cacheRead: m.cacheRead,
|
||||||
|
cacheCreate: m.cacheCreate,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
week: {
|
||||||
|
...week,
|
||||||
|
severity: serverSeverity(server?.weekSeverity) || severity(week.percent),
|
||||||
|
resetAt: weekResetAt,
|
||||||
|
rolling: rollingWeek && server?.weekResetAt == null,
|
||||||
|
remainingMs: weekResetAt ? Math.max(0, weekResetAt - now) : 0,
|
||||||
|
raw: snapshot.week.raw,
|
||||||
|
messages: snapshot.week.totals.count,
|
||||||
|
models: snapshot.week.models.slice(0, 6).map((m) => ({
|
||||||
|
model: m.model,
|
||||||
|
family: m.family,
|
||||||
|
raw: m.raw,
|
||||||
|
weighted: m.weighted,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
burn: {
|
||||||
|
perMinute: snapshot.burnRatePerMinute,
|
||||||
|
projectedWeighted: snapshot.block.projectedWeighted,
|
||||||
|
projectedPercent:
|
||||||
|
effectiveBlockLimit > 0
|
||||||
|
? (snapshot.block.projectedWeighted / effectiveBlockLimit) * 100
|
||||||
|
: 0,
|
||||||
|
weekProjectedPercent,
|
||||||
|
activeTimeLeftMs,
|
||||||
|
},
|
||||||
|
// Nutzungsguthaben — nur vorhanden, wenn der Server es liefert.
|
||||||
|
spend: server?.spend || null,
|
||||||
|
extras: server?.extras || [],
|
||||||
|
history: snapshot.blockHistory,
|
||||||
|
oauth: this.oauth.status(),
|
||||||
|
errors: this.errors.slice(),
|
||||||
|
stats: { lastPollMs: this._lastPollMs },
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('update', this.state);
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Collector };
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI-Gegenprobe für die Datenschicht: liest alle Transkripte einmal
|
||||||
|
* vollständig und gibt Modell-Summen sowie die 5h-Blöcke aus.
|
||||||
|
*
|
||||||
|
* npm run dump
|
||||||
|
*
|
||||||
|
* Die Modell-Summen lassen sich gegen ~/.claude/stats-cache.json (modelUsage)
|
||||||
|
* abgleichen, die Blöcke gegen `npx ccusage blocks`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { TranscriptReader, readSubscription } = require('./jsonl');
|
||||||
|
const { UsageAggregator } = require('./blocks');
|
||||||
|
|
||||||
|
const fmt = (n) => Math.round(n).toLocaleString('de-DE');
|
||||||
|
const fmtTime = (ts) =>
|
||||||
|
ts == null ? '—' : new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const reader = new TranscriptReader();
|
||||||
|
const { entries, bytesRead, files } = await reader.poll();
|
||||||
|
const readMs = Date.now() - t0;
|
||||||
|
|
||||||
|
const agg = new UsageAggregator();
|
||||||
|
agg.add(entries);
|
||||||
|
|
||||||
|
const sub = await readSubscription();
|
||||||
|
|
||||||
|
console.log('=== Einlesen ===');
|
||||||
|
console.log(`Dateien: ${files}`);
|
||||||
|
console.log(`Gelesen: ${(bytesRead / 1024 / 1024).toFixed(2)} MB in ${readMs} ms`);
|
||||||
|
console.log(`Einträge: ${fmt(entries.length)}`);
|
||||||
|
console.log(`Abo: ${sub.subscriptionType || '?'} (${sub.rateLimitTier || '?'})`);
|
||||||
|
|
||||||
|
console.log('\n=== Summen je Modell (gesamter Zeitraum) ===');
|
||||||
|
console.log(
|
||||||
|
['Modell'.padEnd(30), 'Input'.padStart(12), 'Output'.padStart(12), 'CacheRead'.padStart(14), 'CacheCreate'.padStart(13)].join(''),
|
||||||
|
);
|
||||||
|
const byModel = new Map();
|
||||||
|
for (const e of entries) {
|
||||||
|
let m = byModel.get(e.model);
|
||||||
|
if (!m) byModel.set(e.model, (m = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, n: 0 }));
|
||||||
|
m.input += e.input;
|
||||||
|
m.output += e.output;
|
||||||
|
m.cacheRead += e.cacheRead;
|
||||||
|
m.cacheCreate += e.cacheCreate;
|
||||||
|
m.n += 1;
|
||||||
|
}
|
||||||
|
for (const [model, m] of [...byModel].sort((a, b) => b[1].output - a[1].output)) {
|
||||||
|
console.log(
|
||||||
|
[model.padEnd(30), fmt(m.input).padStart(12), fmt(m.output).padStart(12), fmt(m.cacheRead).padStart(14), fmt(m.cacheCreate).padStart(13)].join(''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== 5h-Blöcke ===');
|
||||||
|
const blocks = agg.buildBlocks();
|
||||||
|
for (const b of blocks) {
|
||||||
|
const flag = b.isActive ? ' ← aktiv' : '';
|
||||||
|
console.log(
|
||||||
|
`${fmtTime(b.start)} → ${fmtTime(b.end)} roh ${fmt(b.raw).padStart(12)} gewichtet ${fmt(b.weighted).padStart(12)} (${b.totals.count} Nachrichten)${flag}`,
|
||||||
|
);
|
||||||
|
for (const m of b.models) {
|
||||||
|
console.log(` ${m.model.padEnd(30)} roh ${fmt(m.raw).padStart(12)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snap = agg.snapshot();
|
||||||
|
console.log('\n=== Snapshot ===');
|
||||||
|
console.log(`Aktiver Block: ${snap.block.isActive ? `${fmt(snap.block.weighted)} gewichtet, Reset ${fmtTime(snap.block.end)}` : 'keiner'}`);
|
||||||
|
console.log(`Woche (7 Tage): ${fmt(snap.week.weighted)} gewichtet, ${fmt(snap.week.raw)} roh`);
|
||||||
|
console.log(`Burn-Rate: ${fmt(snap.burnRatePerMinute)} gewichtet/min`);
|
||||||
|
|
||||||
|
// Zweiter Poll: muss ohne neue Daten praktisch nichts lesen.
|
||||||
|
const t1 = Date.now();
|
||||||
|
const second = await reader.poll();
|
||||||
|
console.log(
|
||||||
|
`\nZweiter Poll: ${second.entries.length} neue Einträge, ${second.bytesRead} Bytes, ${Date.now() - t1} ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstiegspunkt: rahmenloses Always-on-Top-Fenster, Tray-Symbol und die
|
||||||
|
* Verdrahtung zum Collector.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeImage, shell } = require('electron');
|
||||||
|
|
||||||
|
const { Store, loadConfig } = require('./store');
|
||||||
|
const { Collector } = require('./collector');
|
||||||
|
const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths');
|
||||||
|
|
||||||
|
const COMPACT_HEIGHT = 352;
|
||||||
|
const EXPANDED_HEIGHT = 640;
|
||||||
|
const WIDTH = 320;
|
||||||
|
const MARGIN = 16;
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
|
||||||
|
const SETTINGS_WIDTH = 520;
|
||||||
|
const SETTINGS_HEIGHT = 660;
|
||||||
|
|
||||||
|
let win = null;
|
||||||
|
let settingsWin = null;
|
||||||
|
let tray = null;
|
||||||
|
let collector = null;
|
||||||
|
let store = null;
|
||||||
|
let config = {};
|
||||||
|
|
||||||
|
/** Verhindert, dass mehrere Instanzen dieselben Zustandsdateien beschreiben. */
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => showWindow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die gespeicherte Position wieder her — aber nur, wenn sie auf einem
|
||||||
|
* aktuell vorhandenen Bildschirm liegt. Nach einem Monitorwechsel läge das
|
||||||
|
* Fenster sonst unsichtbar außerhalb.
|
||||||
|
*/
|
||||||
|
function resolvePosition(width, height) {
|
||||||
|
const saved = store.data.window;
|
||||||
|
if (saved.x != null && saved.y != null) {
|
||||||
|
const displays = screen.getAllDisplays();
|
||||||
|
const visible = displays.some((d) => {
|
||||||
|
const b = d.workArea;
|
||||||
|
return (
|
||||||
|
saved.x + width > b.x + 40 &&
|
||||||
|
saved.x < b.x + b.width - 40 &&
|
||||||
|
saved.y + 40 > b.y &&
|
||||||
|
saved.y < b.y + b.height - 40
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (visible) return { x: saved.x, y: saved.y };
|
||||||
|
}
|
||||||
|
// Voreinstellung: rechts oben auf dem Hauptbildschirm
|
||||||
|
const area = screen.getPrimaryDisplay().workArea;
|
||||||
|
return { x: area.x + area.width - width - MARGIN, y: area.y + MARGIN };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const expanded = Boolean(store.data.window.expanded);
|
||||||
|
const height = expanded ? EXPANDED_HEIGHT : COMPACT_HEIGHT;
|
||||||
|
const { x, y } = resolvePosition(WIDTH, height);
|
||||||
|
|
||||||
|
win = new BrowserWindow({
|
||||||
|
width: WIDTH,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
minimizable: false,
|
||||||
|
fullscreenable: false,
|
||||||
|
skipTaskbar: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
show: false,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
icon: path.join(ROOT, 'assets', 'icon.png'),
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '..', 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'screen-saver' legt das Fenster auch über Vollbildanwendungen.
|
||||||
|
win.setAlwaysOnTop(true, 'screen-saver');
|
||||||
|
|
||||||
|
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
||||||
|
win.once('ready-to-show', () => win.show());
|
||||||
|
|
||||||
|
// Position sichern, sobald der Nutzer das Fenster losgelassen hat.
|
||||||
|
win.on('moved', () => {
|
||||||
|
const [nx, ny] = win.getPosition();
|
||||||
|
store.data.window.x = nx;
|
||||||
|
store.data.window.y = ny;
|
||||||
|
store.save();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Externe Links nie im Widget selbst öffnen.
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
win = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWindow() {
|
||||||
|
if (!win) return createWindow();
|
||||||
|
win.show();
|
||||||
|
win.setAlwaysOnTop(true, 'screen-saver');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWindow() {
|
||||||
|
if (!win) return createWindow();
|
||||||
|
if (win.isVisible()) win.hide();
|
||||||
|
else showWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpacity(value) {
|
||||||
|
config.opacity = value;
|
||||||
|
if (win) win.setOpacity(value);
|
||||||
|
buildTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setClickThrough(enabled) {
|
||||||
|
config.clickThrough = enabled;
|
||||||
|
// forward: true lässt Hover-Effekte weiterlaufen, ohne Klicks abzufangen.
|
||||||
|
if (win) win.setIgnoreMouseEvents(enabled, { forward: true });
|
||||||
|
buildTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutostart(enabled) {
|
||||||
|
app.setLoginItemSettings({ openAtLogin: enabled, args: [] });
|
||||||
|
buildTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrayMenu() {
|
||||||
|
if (!tray) return;
|
||||||
|
const autostart = app.getLoginItemSettings().openAtLogin;
|
||||||
|
|
||||||
|
tray.setContextMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{ label: 'Dashboard anzeigen', click: showWindow },
|
||||||
|
{ label: 'Ausblenden', click: () => win && win.hide() },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Deckkraft',
|
||||||
|
submenu: [1, 0.9, 0.75, 0.6].map((v) => ({
|
||||||
|
label: `${Math.round(v * 100)} %`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: (config.opacity ?? 1) === v,
|
||||||
|
click: () => setOpacity(v),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Klicks durchlassen',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: Boolean(config.clickThrough),
|
||||||
|
click: (item) => setClickThrough(item.checked),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Mit Windows starten',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: autostart,
|
||||||
|
click: (item) => setAutostart(item.checked),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Einstellungen …', click: openSettings },
|
||||||
|
{ label: 'Datenordner öffnen', click: () => shell.openPath(USER_DATA_DIR) },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Beenden', click: () => app.quit() },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTray() {
|
||||||
|
const icon = nativeImage.createFromPath(path.join(ROOT, 'assets', 'tray.png'));
|
||||||
|
tray = new Tray(icon);
|
||||||
|
tray.setToolTip('Claude Live Dashboard');
|
||||||
|
tray.on('click', toggleWindow);
|
||||||
|
buildTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Höhe an den Aufklappzustand anpassen, Position beibehalten. */
|
||||||
|
function applyExpanded(expanded) {
|
||||||
|
store.data.window.expanded = expanded;
|
||||||
|
store.save();
|
||||||
|
if (!win) return;
|
||||||
|
const [x, y] = win.getPosition();
|
||||||
|
win.setBounds({ x, y, width: WIDTH, height: expanded ? EXPANDED_HEIGHT : COMPACT_HEIGHT }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einstellungsdialog — ein Fenster, mehrfaches Öffnen holt es nur nach vorn. */
|
||||||
|
function openSettings() {
|
||||||
|
if (settingsWin && !settingsWin.isDestroyed()) {
|
||||||
|
settingsWin.show();
|
||||||
|
settingsWin.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auf dem Bildschirm zentrieren, auf dem das Widget steht — bei mehreren
|
||||||
|
// Monitoren landet der Dialog sonst irgendwo abseits.
|
||||||
|
const target = win && !win.isDestroyed()
|
||||||
|
? screen.getDisplayMatching(win.getBounds())
|
||||||
|
: screen.getPrimaryDisplay();
|
||||||
|
const area = target.workArea;
|
||||||
|
|
||||||
|
settingsWin = new BrowserWindow({
|
||||||
|
width: SETTINGS_WIDTH,
|
||||||
|
height: SETTINGS_HEIGHT,
|
||||||
|
x: Math.round(area.x + (area.width - SETTINGS_WIDTH) / 2),
|
||||||
|
y: Math.round(area.y + Math.max(0, (area.height - SETTINGS_HEIGHT) / 2)),
|
||||||
|
minWidth: 440,
|
||||||
|
minHeight: 420,
|
||||||
|
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, '..', 'settings-preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsWin.loadFile(path.join(__dirname, '..', 'settings', 'index.html'));
|
||||||
|
settingsWin.once('ready-to-show', () => settingsWin.show());
|
||||||
|
settingsWin.on('closed', () => {
|
||||||
|
settingsWin = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt geänderte Einstellungen ohne Neustart und schreibt sie in die
|
||||||
|
* Datei. Unbekannte Felder und die `_`-Kommentare bleiben erhalten, weil die
|
||||||
|
* vorhandene Datei gelesen und nur ergänzt wird.
|
||||||
|
*/
|
||||||
|
function applySettings(patch) {
|
||||||
|
let raw = {};
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') throw new Error(`config.json nicht lesbar: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) delete raw[key];
|
||||||
|
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
const merged = { ...(raw[key] || {}), ...value };
|
||||||
|
// Leere Werte entfernen, damit "automatisch" auch wirklich automatisch ist.
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
if (v === undefined || v === '' || v === null) delete merged[k];
|
||||||
|
}
|
||||||
|
raw[key] = merged;
|
||||||
|
} else raw[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
config = raw;
|
||||||
|
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.setOpacity(config.opacity ?? 1);
|
||||||
|
win.setIgnoreMouseEvents(Boolean(config.clickThrough), { forward: true });
|
||||||
|
}
|
||||||
|
if (collector) collector.applyConfig(config);
|
||||||
|
buildTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpc() {
|
||||||
|
ipcMain.handle('get-state', () => (collector ? collector.state : null));
|
||||||
|
ipcMain.handle('get-ui-state', () => ({ expanded: Boolean(store.data.window.expanded) }));
|
||||||
|
ipcMain.on('set-expanded', (_event, expanded) => applyExpanded(Boolean(expanded)));
|
||||||
|
ipcMain.on('hide-window', () => win && win.hide());
|
||||||
|
ipcMain.on('open-settings', openSettings);
|
||||||
|
|
||||||
|
ipcMain.handle('settings:load', () => ({
|
||||||
|
config,
|
||||||
|
meta: {
|
||||||
|
configPath: CONFIG_FILE,
|
||||||
|
autostart: app.getLoginItemSettings().openAtLogin,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('settings:save', (_event, patch) => {
|
||||||
|
try {
|
||||||
|
applySettings(patch || {});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('settings:autostart', (_event, enabled) => {
|
||||||
|
setAutostart(Boolean(enabled));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('settings:open-file', () => shell.openPath(CONFIG_FILE));
|
||||||
|
ipcMain.on('settings:close', () => settingsWin && settingsWin.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await app.whenReady();
|
||||||
|
|
||||||
|
// Beim ersten Start die mitgelieferte Vorlage ins Benutzerprofil kopieren —
|
||||||
|
// im Anwendungsverzeichnis wäre sie nach der Installation nicht beschreibbar.
|
||||||
|
ensureUserConfig();
|
||||||
|
|
||||||
|
const { config: loaded, error } = loadConfig(CONFIG_FILE);
|
||||||
|
config = loaded;
|
||||||
|
|
||||||
|
store = new Store(STATE_FILE);
|
||||||
|
store.load();
|
||||||
|
|
||||||
|
registerIpc();
|
||||||
|
createWindow();
|
||||||
|
createTray();
|
||||||
|
|
||||||
|
if (config.opacity != null) win.setOpacity(config.opacity);
|
||||||
|
if (config.clickThrough) win.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
|
||||||
|
collector = new Collector({ config, store, configError: error });
|
||||||
|
collector.on('update', (state) => {
|
||||||
|
if (win && !win.isDestroyed()) win.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)} %`);
|
||||||
|
if (state.burn.activeTimeLeftMs != null) {
|
||||||
|
const h = Math.floor(state.burn.activeTimeLeftMs / 3600000);
|
||||||
|
parts.push(h > 0 ? `noch ~${h} Std. Arbeit` : 'unter 1 Std. Arbeit');
|
||||||
|
}
|
||||||
|
tray.setToolTip(parts.join(' · '));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await collector.start();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[collector] Start fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Widget lebt im Tray weiter, auch wenn kein Fenster offen ist.
|
||||||
|
app.on('window-all-closed', () => {});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
if (collector) collector.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inkrementeller Reader für die Claude-Code-Transkripte.
|
||||||
|
*
|
||||||
|
* Die Transkripte unter ~/.claude/projects/**\/*.jsonl umfassen zusammen
|
||||||
|
* zweistellige Megabyte und wachsen im Sekundentakt. Sie bei jedem Poll
|
||||||
|
* vollständig zu parsen wäre nicht tragbar, deshalb merkt sich der Reader je
|
||||||
|
* Datei einen Byte-Offset und liest nur das Delta.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fsp = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
|
||||||
|
const CLAUDE_HOME =
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||||
|
const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
|
||||||
|
const CREDENTIALS_FILE = path.join(CLAUDE_HOME, '.credentials.json');
|
||||||
|
|
||||||
|
/** Obergrenze je Datei und Poll, damit ein Erstlauf den Speicher nicht sprengt. */
|
||||||
|
const MAX_CHUNK_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Modelle ohne echten Verbrauch, die Claude Code trotzdem protokolliert. */
|
||||||
|
const SYNTHETIC_MODELS = new Set(['<synthetic>', 'synthetic', '<none>']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnet eine Modell-ID einer Familie zu — die Plan-Limits gewichten nach
|
||||||
|
* Familie, nicht nach exakter Version.
|
||||||
|
* @param {string} model z.B. "claude-haiku-4-5-20251001"
|
||||||
|
* @returns {string} "opus" | "sonnet" | "haiku" | "unknown"
|
||||||
|
*/
|
||||||
|
function modelFamily(model) {
|
||||||
|
if (!model) return 'unknown';
|
||||||
|
const m = model.toLowerCase();
|
||||||
|
if (m.includes('opus')) return 'opus';
|
||||||
|
if (m.includes('sonnet')) return 'sonnet';
|
||||||
|
if (m.includes('haiku')) return 'haiku';
|
||||||
|
if (m.includes('fable')) return 'fable';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findet alle Transkript-Dateien.
|
||||||
|
* @returns {Promise<string[]>} absolute Pfade
|
||||||
|
*/
|
||||||
|
async function findTranscripts(dir = PROJECTS_DIR) {
|
||||||
|
let dirents;
|
||||||
|
try {
|
||||||
|
dirents = await fsp.readdir(dir, { withFileTypes: true, recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') return [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return dirents
|
||||||
|
.filter((d) => d.isFile() && d.name.endsWith('.jsonl'))
|
||||||
|
.map((d) => path.join(d.parentPath || d.path, d.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die seit `fromOffset` hinzugekommenen *vollständigen* Zeilen.
|
||||||
|
*
|
||||||
|
* Da die Datei währenddessen weitergeschrieben wird, endet der gelesene Block
|
||||||
|
* fast immer mitten in einer Zeile. Deshalb wird nur bis zum letzten Newline
|
||||||
|
* ausgewertet und der Offset genau dorthin gesetzt — der angefangene Rest wird
|
||||||
|
* beim nächsten Poll erneut gelesen.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{lines: string[], newOffset: number, size: number}>}
|
||||||
|
*/
|
||||||
|
async function readNewLines(filePath, fromOffset) {
|
||||||
|
const fh = await fsp.open(filePath, 'r');
|
||||||
|
try {
|
||||||
|
const { size } = await fh.stat();
|
||||||
|
|
||||||
|
// Datei geschrumpft (Rewrite/Kompaktierung) → von vorn lesen.
|
||||||
|
let start = fromOffset;
|
||||||
|
if (size < start) start = 0;
|
||||||
|
if (size <= start) return { lines: [], newOffset: start, size };
|
||||||
|
|
||||||
|
const len = Math.min(size - start, MAX_CHUNK_BYTES);
|
||||||
|
const buf = Buffer.allocUnsafe(len);
|
||||||
|
await fh.read(buf, 0, len, start);
|
||||||
|
|
||||||
|
const lastNl = buf.lastIndexOf(0x0a);
|
||||||
|
if (lastNl === -1) {
|
||||||
|
// Keine vollständige Zeile im Block — beim nächsten Poll erneut versuchen.
|
||||||
|
return { lines: [], newOffset: start, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = buf.toString('utf8', 0, lastNl + 1);
|
||||||
|
const lines = text.split('\n');
|
||||||
|
if (lines[lines.length - 1] === '') lines.pop();
|
||||||
|
return { lines, newOffset: start + lastNl + 1, size };
|
||||||
|
} finally {
|
||||||
|
await fh.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert einen Verbrauchseintrag aus einer Transkript-Zeile.
|
||||||
|
* @returns {null | {id: string, ts: number, model: string, family: string,
|
||||||
|
* input: number, output: number, cacheRead: number, cacheCreate: number}}
|
||||||
|
*/
|
||||||
|
function parseLine(line) {
|
||||||
|
if (!line || line.charCodeAt(0) !== 0x7b /* '{' */) return null;
|
||||||
|
// Billiger Vorfilter: über 90 % der Zeilen sind User-Nachrichten und
|
||||||
|
// Tool-Ergebnisse ohne Usage-Block.
|
||||||
|
if (!line.includes('"usage"')) return null;
|
||||||
|
|
||||||
|
let rec;
|
||||||
|
try {
|
||||||
|
rec = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rec.type !== 'assistant') return null;
|
||||||
|
const msg = rec.message;
|
||||||
|
if (!msg || !msg.usage) return null;
|
||||||
|
|
||||||
|
const model = msg.model;
|
||||||
|
if (!model || SYNTHETIC_MODELS.has(model)) return null;
|
||||||
|
|
||||||
|
const ts = Date.parse(rec.timestamp);
|
||||||
|
if (!Number.isFinite(ts)) return null;
|
||||||
|
|
||||||
|
const u = msg.usage;
|
||||||
|
return {
|
||||||
|
// Dedup-Schlüssel aus Nachrichten- UND Anfrage-ID. Die Nachrichten-ID
|
||||||
|
// allein greift zu scharf: Setzt ein Retry dieselbe Nachricht unter neuer
|
||||||
|
// Anfrage-ID fort, ist das echter zusätzlicher Verbrauch. Entspricht dem
|
||||||
|
// Verfahren von ccusage.
|
||||||
|
id: `${msg.id || rec.uuid || ''}:${rec.requestId || ''}:${msg.id ? '' : ts}`,
|
||||||
|
ts,
|
||||||
|
model,
|
||||||
|
family: modelFamily(model),
|
||||||
|
input: u.input_tokens || 0,
|
||||||
|
output: u.output_tokens || 0,
|
||||||
|
cacheRead: u.cache_read_input_tokens || 0,
|
||||||
|
cacheCreate: u.cache_creation_input_tokens || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Abo-Informationen aus den Claude-Code-Credentials.
|
||||||
|
*
|
||||||
|
* Es wird ausschließlich der Plan-Typ gelesen; die Tokens selbst rührt diese
|
||||||
|
* Funktion nicht an (siehe oauth-usage.js für die optionale Stufe 2).
|
||||||
|
*
|
||||||
|
* @returns {Promise<{subscriptionType: string|null, rateLimitTier: string|null}>}
|
||||||
|
*/
|
||||||
|
async function readSubscription() {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
|
||||||
|
const oauth = JSON.parse(raw).claudeAiOauth || {};
|
||||||
|
return {
|
||||||
|
subscriptionType: oauth.subscriptionType || null,
|
||||||
|
rateLimitTier: oauth.rateLimitTier || null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { subscriptionType: null, rateLimitTier: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält die Offsets und die Dedup-Menge über mehrere Polls hinweg.
|
||||||
|
*
|
||||||
|
* Dedup ist nicht optional: Claude Code schreibt dieselbe Assistant-Nachricht
|
||||||
|
* bei Retries und beim Kompaktieren einer Session mehrfach ins Transkript.
|
||||||
|
*/
|
||||||
|
class TranscriptReader {
|
||||||
|
/** @param {{offsets?: Object}} persisted zuvor gespeicherter Zustand */
|
||||||
|
constructor(persisted = {}) {
|
||||||
|
/** @type {Record<string, {offset: number, size: number}>} */
|
||||||
|
this.offsets = persisted.offsets || {};
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
this.seen = new Set();
|
||||||
|
this.maxSeen = 60000;
|
||||||
|
this.trimTo = 50000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begrenzt die Dedup-Menge; Sets behalten die Einfügereihenfolge. */
|
||||||
|
_trimSeen() {
|
||||||
|
if (this.seen.size <= this.maxSeen) return;
|
||||||
|
const drop = this.seen.size - this.trimTo;
|
||||||
|
let i = 0;
|
||||||
|
for (const key of this.seen) {
|
||||||
|
if (i++ >= drop) break;
|
||||||
|
this.seen.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest alle seit dem letzten Aufruf hinzugekommenen Einträge.
|
||||||
|
* @returns {Promise<{entries: Array, bytesRead: number, files: number}>}
|
||||||
|
*/
|
||||||
|
async poll() {
|
||||||
|
const files = await findTranscripts();
|
||||||
|
const entries = [];
|
||||||
|
let bytesRead = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const prev = this.offsets[file] || { offset: 0, size: 0 };
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await readNewLines(file, prev.offset);
|
||||||
|
} catch (err) {
|
||||||
|
// Eine gesperrte oder gelöschte Datei darf den Poll nicht abbrechen.
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'EBUSY') continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesRead += result.newOffset - prev.offset;
|
||||||
|
this.offsets[file] = { offset: result.newOffset, size: result.size };
|
||||||
|
|
||||||
|
for (const line of result.lines) {
|
||||||
|
const entry = parseLine(line);
|
||||||
|
if (!entry || this.seen.has(entry.id)) continue;
|
||||||
|
this.seen.add(entry.id);
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._trimSeen();
|
||||||
|
entries.sort((a, b) => a.ts - b.ts);
|
||||||
|
return { entries, bytesRead, files: files.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialisierbarer Zustand für store.js. */
|
||||||
|
serialize() {
|
||||||
|
return { offsets: this.offsets };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CLAUDE_HOME,
|
||||||
|
PROJECTS_DIR,
|
||||||
|
CREDENTIALS_FILE,
|
||||||
|
TranscriptReader,
|
||||||
|
findTranscripts,
|
||||||
|
readNewLines,
|
||||||
|
parseLine,
|
||||||
|
modelFamily,
|
||||||
|
readSubscription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan-Erkennung, Limit-Referenzwerte und Auto-Kalibrierung.
|
||||||
|
*
|
||||||
|
* Anthropic veröffentlicht die Plan-Limits nicht als Token-Zahlen, und es gibt
|
||||||
|
* keine dokumentierte lokale Schnittstelle dafür. Der Referenzwert für "100 %"
|
||||||
|
* wird deshalb kalibriert: Das höchste je beobachtete Blockvolumen gilt als
|
||||||
|
* untere Schranke des Limits und wird ausschließlich nach oben korrigiert.
|
||||||
|
*
|
||||||
|
* Damit die Anzeige ehrlich bleibt, liefert jede Auswertung eine
|
||||||
|
* Vertrauensstufe mit:
|
||||||
|
* exact — vom Server gemeldet (OAuth-Stufe aktiv)
|
||||||
|
* configured — vom Nutzer in config.json gesetzt
|
||||||
|
* calibrated — aus genügend eigenen Messungen abgeleitet
|
||||||
|
* estimate — zu wenig Daten, Wert ist ein grober Anhaltspunkt
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { DAY_MS } = require('./blocks');
|
||||||
|
|
||||||
|
/** Ab so vielen beobachteten Blöcken gilt die Kalibrierung als belastbar. */
|
||||||
|
const CALIBRATION_MIN_SAMPLES = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anhaltswerte je Abo für den allerersten Start, solange keine eigenen
|
||||||
|
* Messungen vorliegen. Bewusst grob — sie werden von der Kalibrierung
|
||||||
|
* überschrieben, sobald reale Blöcke vorliegen.
|
||||||
|
*/
|
||||||
|
const PLAN_SEED = {
|
||||||
|
// pro.week ist gemessen: 34,3 Mio. gewichtete Tokens entsprachen laut
|
||||||
|
// Einstellungen ▸ Nutzung 14 % (Juli 2026). pro.block ist dagegen ungeprüft
|
||||||
|
// — für das 5-Stunden-Fenster lag kein Referenzwert vor.
|
||||||
|
pro: { block: 12_000_000, week: 250_000_000 },
|
||||||
|
max: { block: 60_000_000, week: 300_000_000 },
|
||||||
|
max_5x: { block: 60_000_000, week: 300_000_000 },
|
||||||
|
max_20x: { block: 240_000_000, week: 1_200_000_000 },
|
||||||
|
default: { block: 12_000_000, week: 60_000_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet den Beginn des Wochenfensters ab.
|
||||||
|
*
|
||||||
|
* Ohne Serverauskunft ist die ehrlichste Annahme ein rollierendes 7-Tage-
|
||||||
|
* Fenster. Wer den echten Reset-Wochentag kennt, trägt ihn als
|
||||||
|
* `weekResetWeekday` (0 = Sonntag) und `weekResetHour` in config.json ein.
|
||||||
|
*/
|
||||||
|
function weekAnchor(now, config = {}) {
|
||||||
|
const weekday = config.weekResetWeekday;
|
||||||
|
if (weekday == null) return now - 7 * DAY_MS; // rollierend
|
||||||
|
|
||||||
|
const hour = config.weekResetHour ?? 0;
|
||||||
|
const minute = config.weekResetMinute ?? 0;
|
||||||
|
const anchor = new Date(now);
|
||||||
|
anchor.setHours(hour, minute, 0, 0);
|
||||||
|
const diff = (anchor.getDay() - weekday + 7) % 7;
|
||||||
|
anchor.setDate(anchor.getDate() - diff);
|
||||||
|
if (anchor.getTime() > now) anchor.setDate(anchor.getDate() - 7);
|
||||||
|
return anchor.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
class LimitModel {
|
||||||
|
/**
|
||||||
|
* @param {Object} config Nutzer-Konfiguration
|
||||||
|
* @param {Object} calibration persistierter Kalibrierungszustand aus store.js
|
||||||
|
* @param {{subscriptionType: string|null}} subscription
|
||||||
|
*/
|
||||||
|
constructor(config, calibration, subscription) {
|
||||||
|
this.config = config || {};
|
||||||
|
this.calibration = calibration;
|
||||||
|
this.subscription = subscription || {};
|
||||||
|
this.seed = PLAN_SEED[this.subscription.subscriptionType] || PLAN_SEED.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert die Kalibrierung anhand abgeschlossener Blöcke.
|
||||||
|
* Nur abgeschlossene Blöcke zählen — ein noch laufender Block sagt nichts
|
||||||
|
* über das Maximum aus.
|
||||||
|
* @returns {boolean} true, wenn sich etwas geändert hat
|
||||||
|
*/
|
||||||
|
observe(snapshot, blocks) {
|
||||||
|
let changed = false;
|
||||||
|
const finished = blocks.filter((b) => !b.isActive);
|
||||||
|
|
||||||
|
// `samples` misst gesammelte Erfahrung, nicht den aktuellen Fensterinhalt.
|
||||||
|
// Der Aggregator hält nur 8 Tage vor — würde hier direkt zugewiesen, fiele
|
||||||
|
// die Kalibrierungsgüte nach einer Pause wieder auf null zurück, obwohl
|
||||||
|
// blockMax erhalten bleibt. Das Maximum wächst monoton und zählt bei
|
||||||
|
// Neustarts dieselben Blöcke nicht doppelt.
|
||||||
|
if (finished.length > this.calibration.samples) {
|
||||||
|
this.calibration.samples = finished.length;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
for (const b of finished) {
|
||||||
|
if (b.weighted > this.calibration.blockMax) {
|
||||||
|
this.calibration.blockMax = b.weighted;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auch der laufende Block darf die untere Schranke anheben, sobald er das
|
||||||
|
// bisherige Maximum überschreitet — das Limit ist dann nachweislich höher.
|
||||||
|
if (snapshot.block.weighted > this.calibration.blockMax) {
|
||||||
|
this.calibration.blockMax = snapshot.block.weighted;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (snapshot.week.weighted > this.calibration.weekMax) {
|
||||||
|
this.calibration.weekMax = snapshot.week.weighted;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenzwert und Vertrauensstufe für ein Fenster.
|
||||||
|
* @param {'block'|'week'} kind
|
||||||
|
* @param {number|null} serverLimit exakter Wert aus der OAuth-Stufe
|
||||||
|
*/
|
||||||
|
limitFor(kind, serverLimit = null) {
|
||||||
|
if (serverLimit != null && serverLimit > 0) {
|
||||||
|
return { limit: serverLimit, confidence: 'exact' };
|
||||||
|
}
|
||||||
|
const configured = (this.config.limits || {})[kind];
|
||||||
|
if (configured != null && configured > 0) {
|
||||||
|
return { limit: configured, confidence: 'configured' };
|
||||||
|
}
|
||||||
|
// Aus einer früheren Serverantwort abgeleitet — deutlich belastbarer als
|
||||||
|
// das bloß beobachtete Maximum und überbrückt Ausfälle der Abfrage.
|
||||||
|
const fromServer = (this.calibration.serverLimits || {})[kind];
|
||||||
|
if (fromServer > 0) {
|
||||||
|
return { limit: fromServer, confidence: 'calibrated' };
|
||||||
|
}
|
||||||
|
const observed = kind === 'block' ? this.calibration.blockMax : this.calibration.weekMax;
|
||||||
|
const seed = this.seed[kind];
|
||||||
|
if (this.calibration.samples >= CALIBRATION_MIN_SAMPLES && observed > 0) {
|
||||||
|
// Das beobachtete Maximum ist eine untere Schranke des echten Limits;
|
||||||
|
// der Puffer verhindert, dass die Anzeige dauerhaft bei 100 % klebt.
|
||||||
|
return { limit: Math.max(observed * 1.15, seed * 0.5), confidence: 'calibrated' };
|
||||||
|
}
|
||||||
|
return { limit: Math.max(seed, observed * 1.15), confidence: 'estimate' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet einen gewichteten Verbrauch in einen Anzeigewert um.
|
||||||
|
* @returns {{used: number, limit: number, percent: number, confidence: string}}
|
||||||
|
*/
|
||||||
|
evaluate(kind, weighted, serverLimit = null) {
|
||||||
|
const { limit, confidence } = this.limitFor(kind, serverLimit);
|
||||||
|
const percent = limit > 0 ? Math.min(999, (weighted / limit) * 100) : 0;
|
||||||
|
return { used: weighted, limit, percent, confidence };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schwellen für die Farbcodierung der UI. */
|
||||||
|
function severity(percent) {
|
||||||
|
if (percent >= 85) return 'critical';
|
||||||
|
if (percent >= 60) return 'warn';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { LimitModel, weekAnchor, severity, PLAN_SEED, CALIBRATION_MIN_SAMPLES };
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stufe 2: exakter Limit-Status vom Server.
|
||||||
|
*
|
||||||
|
* Claude Code zeigt unter `/usage` Werte an, die es von einem Endpunkt bezieht,
|
||||||
|
* der weder dokumentiert noch stabil ist. Dieses Modul kann ihn abfragen, ist
|
||||||
|
* aber vollständig gekapselt: Fällt es aus, läuft das Dashboard unverändert auf
|
||||||
|
* der lokalen Berechnung weiter.
|
||||||
|
*
|
||||||
|
* ── Endpunkt ermitteln ──────────────────────────────────────────────────────
|
||||||
|
* Der Endpunkt wird NICHT geraten. Vorgehen:
|
||||||
|
* 1. `claude --debug api` starten
|
||||||
|
* 2. in der Sitzung `/usage` aufrufen
|
||||||
|
* 3. die Request-URL aus der Debug-Ausgabe ablesen
|
||||||
|
* 4. sie in config.json unter `oauth.endpoint` eintragen und
|
||||||
|
* `oauth.enabled: true` setzen
|
||||||
|
*
|
||||||
|
* Ohne diesen Eintrag bleibt das Modul inaktiv.
|
||||||
|
*
|
||||||
|
* Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie
|
||||||
|
* gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host
|
||||||
|
* gesendet. Ein eigener Refresh-Flow existiert bewusst nicht — Claude Code
|
||||||
|
* erneuert das Token selbst; bei Ablauf pausiert diese Stufe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fsp = require('node:fs/promises');
|
||||||
|
const { CREDENTIALS_FILE } = require('./jsonl');
|
||||||
|
|
||||||
|
/** Standard-Endpunkt, ermittelt aus der Claude-Code-Installation. */
|
||||||
|
const DEFAULT_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstand zwischen zwei Abfragen. Die Werte ändern sich nur langsam, und der
|
||||||
|
* Endpunkt ist ratenbegrenzt — häufiger zu fragen bringt nichts und riskiert
|
||||||
|
* ein 429.
|
||||||
|
*/
|
||||||
|
const MIN_INTERVAL_MS = 3 * 60 * 1000;
|
||||||
|
/** Nach so vielen Fehlversuchen wird pausiert — nicht dauerhaft abgeschaltet. */
|
||||||
|
const MAX_FAILURES = 3;
|
||||||
|
/** Länge dieser Pause; danach wird wieder versucht. */
|
||||||
|
const COOLDOWN_MS = 15 * 60 * 1000;
|
||||||
|
/** Pause nach einem 429, falls der Server kein Retry-After mitschickt. */
|
||||||
|
const RATE_LIMIT_COOLDOWN_MS = 10 * 60 * 1000;
|
||||||
|
const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
/** Nur diese Hosts dürfen angesprochen werden. */
|
||||||
|
const ALLOWED_HOSTS = new Set(['api.anthropic.com', 'console.anthropic.com', 'claude.ai']);
|
||||||
|
|
||||||
|
class OAuthUsage {
|
||||||
|
/**
|
||||||
|
* @param {{enabled?: boolean, endpoint?: string, betaHeader?: string}} config
|
||||||
|
* @param {import('./store').Store} store
|
||||||
|
*/
|
||||||
|
constructor(config = {}, store = null) {
|
||||||
|
this.config = config;
|
||||||
|
this.store = store;
|
||||||
|
// Standardmäßig aktiv: Die Serverwerte sind exakt, alles andere ist
|
||||||
|
// Näherung. Abschaltbar über "oauth": { "enabled": false }.
|
||||||
|
this.wanted = config.enabled !== false;
|
||||||
|
this.endpoint = config.endpoint || DEFAULT_ENDPOINT;
|
||||||
|
this.enabled = this.wanted;
|
||||||
|
this.failures = 0;
|
||||||
|
// Abfragezeitpunkt und Pause überdauern Neustarts. Ohne das führt jeder
|
||||||
|
// Start sofort eine Abfrage aus — bei mehreren Starts kurz nacheinander
|
||||||
|
// reicht das, um in die Ratenbegrenzung zu laufen.
|
||||||
|
const persisted = (store && store.data && store.data.oauth) || {};
|
||||||
|
this.lastFetch = persisted.lastFetch || 0;
|
||||||
|
this.pausedUntil = persisted.pausedUntil || 0;
|
||||||
|
this.lastResult = null;
|
||||||
|
this.lastError = null;
|
||||||
|
|
||||||
|
if (this.enabled && !this._hostAllowed(this.endpoint)) {
|
||||||
|
this.enabled = false;
|
||||||
|
this.lastError = 'oauth.endpoint zeigt nicht auf einen Anthropic-Host';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sichert Abfragezeitpunkt und Pause über Neustarts hinweg. */
|
||||||
|
_persist() {
|
||||||
|
if (!this.store) return;
|
||||||
|
this.store.data.oauth = {
|
||||||
|
...(this.store.data.oauth || {}),
|
||||||
|
lastFetch: this.lastFetch,
|
||||||
|
pausedUntil: this.pausedUntil,
|
||||||
|
};
|
||||||
|
this.store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_hostAllowed(endpoint) {
|
||||||
|
try {
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
return url.protocol === 'https:' && ALLOWED_HOSTS.has(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest das aktuelle Zugriffstoken, sofern es noch gültig ist. */
|
||||||
|
async _token() {
|
||||||
|
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
|
||||||
|
const oauth = JSON.parse(raw).claudeAiOauth;
|
||||||
|
if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden');
|
||||||
|
if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
|
||||||
|
throw new Error('Zugriffstoken abgelaufen — Claude Code erneuert es beim nächsten Start');
|
||||||
|
}
|
||||||
|
return oauth.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt den Endpunkt ab, höchstens einmal pro Minute.
|
||||||
|
* @returns {Promise<{blockLimit: number|null, weekLimit: number|null,
|
||||||
|
* blockPercent: number|null, weekPercent: number|null,
|
||||||
|
* blockResetAt: number|null, weekResetAt: number|null} | null>}
|
||||||
|
*/
|
||||||
|
async fetch(now = Date.now()) {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
// Pausiert nach Fehlern oder Ratenbegrenzung — der zuletzt bekannte Stand
|
||||||
|
// bleibt gültig, bis die Pause abgelaufen ist.
|
||||||
|
if (now < this.pausedUntil) return this.lastResult;
|
||||||
|
if (now - this.lastFetch < MIN_INTERVAL_MS) return this.lastResult;
|
||||||
|
|
||||||
|
this.lastFetch = now;
|
||||||
|
this._persist();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await this._token();
|
||||||
|
const res = await fetch(this.endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'anthropic-beta': this.config.betaHeader || 'oauth-2025-04-20',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
// Ratenbegrenzung ist vorübergehend und kein Grund, die Abfrage
|
||||||
|
// aufzugeben — es wird nur länger gewartet.
|
||||||
|
const retryAfter = Number(res.headers.get('retry-after'));
|
||||||
|
const wait = Number.isFinite(retryAfter) && retryAfter > 0
|
||||||
|
? retryAfter * 1000
|
||||||
|
: RATE_LIMIT_COOLDOWN_MS;
|
||||||
|
this.pausedUntil = now + wait;
|
||||||
|
this._persist();
|
||||||
|
this.lastError = `zu viele Anfragen — Pause bis ${new Date(this.pausedUntil).toLocaleTimeString('de-DE')}`;
|
||||||
|
return this.lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
this.failures = 0;
|
||||||
|
this.pausedUntil = 0;
|
||||||
|
this.lastError = null;
|
||||||
|
this.lastResult = this._normalize(json);
|
||||||
|
this._persist();
|
||||||
|
return this.lastResult;
|
||||||
|
} catch (err) {
|
||||||
|
this.failures += 1;
|
||||||
|
// Nur die Fehlerart, nie Header oder Token protokollieren.
|
||||||
|
this.lastError = err.name === 'AbortError' ? 'Zeitüberschreitung' : err.message;
|
||||||
|
if (this.failures >= MAX_FAILURES) {
|
||||||
|
this.failures = 0;
|
||||||
|
this.pausedUntil = now + COOLDOWN_MS;
|
||||||
|
this._persist();
|
||||||
|
this.lastError += ` — Pause bis ${new Date(this.pausedUntil).toLocaleTimeString('de-DE')}`;
|
||||||
|
}
|
||||||
|
return this.lastResult;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt die Antwort in die interne Form.
|
||||||
|
*
|
||||||
|
* Die Antwort führt dieselben Werte doppelt: einmal als benannte Felder
|
||||||
|
* (`five_hour`, `seven_day`) und einmal im Array `limits` mit `kind`,
|
||||||
|
* `percent`, `severity` und `resets_at`. Das Array ist die reichere Quelle
|
||||||
|
* und hat Vorrang; die benannten Felder dienen als Rückfallebene.
|
||||||
|
*
|
||||||
|
* Bewusst tolerant: Das Schema ist nicht zugesichert, und die Antwort
|
||||||
|
* enthält etliche Felder, die je nach Abo `null` sind.
|
||||||
|
*/
|
||||||
|
_normalize(json) {
|
||||||
|
const toMs = (v) => {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return v > 1e12 ? v : v * 1000;
|
||||||
|
const parsed = Date.parse(v);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
const pct = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||||
|
|
||||||
|
const byKind = new Map();
|
||||||
|
for (const entry of Array.isArray(json.limits) ? json.limits : []) {
|
||||||
|
if (entry && entry.kind) byKind.set(entry.kind, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = byKind.get('session') || json.five_hour || {};
|
||||||
|
const week = byKind.get('weekly_all') || json.seven_day || {};
|
||||||
|
|
||||||
|
// Zusätzliche Wochenkontingente, die nur manche Abos führen.
|
||||||
|
const extras = [];
|
||||||
|
for (const [kind, label] of [
|
||||||
|
['weekly_opus', 'Opus'],
|
||||||
|
['weekly_sonnet', 'Sonnet'],
|
||||||
|
]) {
|
||||||
|
const e = byKind.get(kind) || json[`seven_day_${label.toLowerCase()}`];
|
||||||
|
if (e && pct(e.percent ?? e.utilization) != null) {
|
||||||
|
extras.push({
|
||||||
|
label,
|
||||||
|
percent: pct(e.percent ?? e.utilization),
|
||||||
|
resetAt: toMs(e.resets_at),
|
||||||
|
severity: e.severity || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nutzungsguthaben: Beträge kommen als kleinste Währungseinheit.
|
||||||
|
let spend = null;
|
||||||
|
const s = json.spend;
|
||||||
|
if (s && s.used && s.limit) {
|
||||||
|
const toMajor = (m) => m.amount_minor / 10 ** (m.exponent ?? 2);
|
||||||
|
spend = {
|
||||||
|
used: toMajor(s.used),
|
||||||
|
limit: toMajor(s.limit),
|
||||||
|
currency: s.limit.currency || s.used.currency || 'EUR',
|
||||||
|
percent: pct(s.percent),
|
||||||
|
severity: s.severity || null,
|
||||||
|
enabled: s.enabled !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: json,
|
||||||
|
blockPercent: pct(session.percent ?? session.utilization),
|
||||||
|
weekPercent: pct(week.percent ?? week.utilization),
|
||||||
|
blockResetAt: toMs(session.resets_at),
|
||||||
|
weekResetAt: toMs(week.resets_at),
|
||||||
|
blockSeverity: session.severity || null,
|
||||||
|
weekSeverity: week.severity || null,
|
||||||
|
extras,
|
||||||
|
spend,
|
||||||
|
// Der Endpunkt nennt keine Token-Obergrenzen, nur Prozentwerte.
|
||||||
|
blockLimit: null,
|
||||||
|
weekLimit: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statuszeile für die Detailansicht. */
|
||||||
|
status() {
|
||||||
|
if (!this.wanted) return { active: false, text: 'abgeschaltet' };
|
||||||
|
if (!this.enabled) return { active: false, text: this.lastError || 'nicht konfiguriert' };
|
||||||
|
if (this.lastError) return { active: false, text: this.lastError };
|
||||||
|
return { active: Boolean(this.lastResult), text: this.lastResult ? 'aktiv' : 'wartet' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { OAuthUsage };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Pfadauflösung — die einzige Stelle, die weiß, wo Konfiguration und
|
||||||
|
* Zustand liegen.
|
||||||
|
*
|
||||||
|
* Wichtig für die installierte Fassung: Das Anwendungsverzeichnis steckt dann
|
||||||
|
* in einem asar-Archiv und ist schreibgeschützt. Konfiguration und Zustand
|
||||||
|
* gehören deshalb ins Benutzerprofil. `calibrate.js` läuft außerhalb von
|
||||||
|
* Electron und kann `app.getPath()` nicht nutzen — darum wird der Pfad hier
|
||||||
|
* ohne Electron-Abhängigkeit hergeleitet und von beiden Seiten verwendet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
/** Muss mit `productName` in package.json übereinstimmen — Electron leitet
|
||||||
|
* seinen userData-Pfad daraus ab. */
|
||||||
|
const APP_NAME = 'Claude Live Dashboard';
|
||||||
|
|
||||||
|
function userDataDir() {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||||
|
return path.join(base, APP_NAME);
|
||||||
|
}
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.join(os.homedir(), 'Library', 'Application Support', APP_NAME);
|
||||||
|
}
|
||||||
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), APP_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_DATA_DIR = userDataDir();
|
||||||
|
const CONFIG_FILE = path.join(USER_DATA_DIR, 'config.json');
|
||||||
|
const STATE_FILE = path.join(USER_DATA_DIR, 'state.json');
|
||||||
|
|
||||||
|
/** Mitgelieferte Vorlage im Anwendungsverzeichnis (nur lesend). */
|
||||||
|
const CONFIG_TEMPLATE = path.join(__dirname, '..', '..', 'config.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt beim ersten Start eine Konfiguration im Benutzerprofil an.
|
||||||
|
* @returns {boolean} true, wenn eine Datei neu angelegt wurde
|
||||||
|
*/
|
||||||
|
function ensureUserConfig() {
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) return false;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(USER_DATA_DIR, { recursive: true });
|
||||||
|
if (fs.existsSync(CONFIG_TEMPLATE)) {
|
||||||
|
fs.copyFileSync(CONFIG_TEMPLATE, CONFIG_FILE);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(CONFIG_FILE, '{}\n', 'utf8');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
// Ohne eigene Konfiguration laufen die Voreinstellungen — kein Grund,
|
||||||
|
// den Start abzubrechen.
|
||||||
|
console.warn(`[paths] Konfiguration konnte nicht angelegt werden: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { APP_NAME, USER_DATA_DIR, CONFIG_FILE, STATE_FILE, CONFIG_TEMPLATE, ensureUserConfig };
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistenz für Datei-Offsets, Kalibrierungswerte und Fensterposition.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Electron-Abhängigkeit, damit die Datenschicht auch per CLI
|
||||||
|
* testbar bleibt — der Speicherpfad wird hereingereicht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const fsp = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datei-Offsets stehen bewusst NICHT hier: Sie gelten nur innerhalb einer
|
||||||
|
* Sitzung. Persistiert würden sie beim nächsten Start die gesamte Historie
|
||||||
|
* überspringen, weil der Aggregator leer beginnt.
|
||||||
|
*/
|
||||||
|
const DEFAULT_STATE = {
|
||||||
|
version: 1,
|
||||||
|
// serverLimits: aus früheren Serverantworten abgeleitete Bezugsgrößen. Sie
|
||||||
|
// überbrücken Ausfälle der Abfrage, ohne dass die Anzeige einbricht.
|
||||||
|
calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} },
|
||||||
|
window: { x: null, y: null, expanded: false },
|
||||||
|
oauth: { lastFetch: 0, pausedUntil: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
class Store {
|
||||||
|
/** @param {string} filePath Pfad zu state.json */
|
||||||
|
constructor(filePath) {
|
||||||
|
this.filePath = filePath;
|
||||||
|
this.data = structuredClone(DEFAULT_STATE);
|
||||||
|
this._writeTimer = null;
|
||||||
|
this._writing = false;
|
||||||
|
this._dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(this.filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Flach mergen, damit neue Felder späterer Versionen Defaults bekommen.
|
||||||
|
this.data = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
...parsed,
|
||||||
|
calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) },
|
||||||
|
window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) },
|
||||||
|
oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) },
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
// Beschädigter Zustand darf den Start nicht verhindern.
|
||||||
|
console.warn(`[store] ${this.filePath} nicht lesbar (${err.message}) — Defaults werden verwendet.`);
|
||||||
|
}
|
||||||
|
this.data = structuredClone(DEFAULT_STATE);
|
||||||
|
}
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markiert den Zustand als änderungsbedürftig; geschrieben wird gebündelt. */
|
||||||
|
save({ immediate = false } = {}) {
|
||||||
|
this._dirty = true;
|
||||||
|
if (immediate) return this._flush();
|
||||||
|
if (this._writeTimer) return Promise.resolve();
|
||||||
|
this._writeTimer = setTimeout(() => {
|
||||||
|
this._writeTimer = null;
|
||||||
|
this._flush().catch((err) => console.warn(`[store] Schreiben fehlgeschlagen: ${err.message}`));
|
||||||
|
}, 2000);
|
||||||
|
if (this._writeTimer.unref) this._writeTimer.unref();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _flush() {
|
||||||
|
if (this._writing || !this._dirty) return;
|
||||||
|
this._writing = true;
|
||||||
|
this._dirty = false;
|
||||||
|
const tmp = `${this.filePath}.tmp`;
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
|
await fsp.writeFile(tmp, JSON.stringify(this.data, null, 2), 'utf8');
|
||||||
|
await fsp.rename(tmp, this.filePath); // atomar, damit kein halber Zustand entsteht
|
||||||
|
} finally {
|
||||||
|
this._writing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt synchron — für den Shutdown-Pfad. */
|
||||||
|
saveSync() {
|
||||||
|
if (!this._dirty) return;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||||
|
const tmp = `${this.filePath}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8');
|
||||||
|
fs.renameSync(tmp, this.filePath);
|
||||||
|
this._dirty = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[store] Schreiben beim Beenden fehlgeschlagen: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Nutzer-Konfiguration. Fehlt oder bricht sie, laufen die Defaults —
|
||||||
|
* ein Tippfehler in config.json darf das Dashboard nicht lahmlegen.
|
||||||
|
*
|
||||||
|
* @returns {{config: Object, error: string|null}}
|
||||||
|
*/
|
||||||
|
function loadConfig(filePath) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf8');
|
||||||
|
return { config: JSON.parse(raw), error: null };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') return { config: {}, error: null };
|
||||||
|
return { config: {}, error: `config.json: ${err.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Store, loadConfig, DEFAULT_STATE };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schmale, explizite Brücke zwischen Main-Prozess und Oberfläche.
|
||||||
|
* Der Renderer bekommt keinen Node-Zugriff — nur diese Funktionen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('dashboard', {
|
||||||
|
/** 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'),
|
||||||
|
/** Liefert den gespeicherten Ansichtszustand, damit die Oberfläche zur Fenstergröße passt. */
|
||||||
|
getUiState: () => ipcRenderer.invoke('get-ui-state'),
|
||||||
|
/** Klappt die Detailansicht auf oder zu. */
|
||||||
|
setExpanded: (expanded) => ipcRenderer.send('set-expanded', expanded),
|
||||||
|
/** Blendet das Fenster aus (bleibt über das Tray-Symbol erreichbar). */
|
||||||
|
hide: () => ipcRenderer.send('hide-window'),
|
||||||
|
/** Öffnet den Einstellungsdialog. */
|
||||||
|
openSettings: () => ipcRenderer.send('open-settings'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darstellung. Rechnet nichts nach — die Zahlen kommen fertig aus dem
|
||||||
|
* Main-Prozess. Nur der Countdown läuft hier lokal weiter, damit die Anzeige
|
||||||
|
* zwischen zwei Aktualisierungen nicht stehenbleibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feste Farbzuordnung je Modellfamilie. Die Farbe folgt der Entität, nie dem
|
||||||
|
* Rang — verschiebt sich die Reihenfolge im Balken, bleibt Opus orange.
|
||||||
|
*/
|
||||||
|
const FAMILY_COLOR = {
|
||||||
|
opus: 'var(--series-2)',
|
||||||
|
sonnet: 'var(--series-1)',
|
||||||
|
haiku: 'var(--series-3)',
|
||||||
|
fable: 'var(--series-4)',
|
||||||
|
unknown: 'var(--series-other)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEVERITY_TEXT = {
|
||||||
|
ok: 'im Rahmen',
|
||||||
|
warn: 'erhöht',
|
||||||
|
critical: 'kritisch',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFIDENCE_TEXT = {
|
||||||
|
exact: 'exakt (Server)',
|
||||||
|
configured: 'eigener Wert',
|
||||||
|
calibrated: 'kalibriert',
|
||||||
|
estimate: 'geschätzt',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFIDENCE_HINT = {
|
||||||
|
exact: 'Die Werte stammen direkt vom Server und entsprechen der Anzeige von /usage.',
|
||||||
|
configured: 'Als Bezugsgröße dient der in config.json hinterlegte Wert.',
|
||||||
|
calibrated:
|
||||||
|
'Bezugsgröße ist das höchste bisher beobachtete Volumen zuzüglich Puffer. Der Wert nähert sich dem echten Limit an, je öfter du nah herankommst.',
|
||||||
|
estimate:
|
||||||
|
'Noch zu wenige eigene Messungen für eine belastbare Bezugsgröße. Der Prozentwert ist ein grober Anhaltspunkt — trage bei Bedarf ein Limit in config.json ein.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const el = {
|
||||||
|
widget: $('widget'),
|
||||||
|
planLabel: $('plan-label'),
|
||||||
|
staleBadge: $('stale-badge'),
|
||||||
|
errorBadge: $('error-badge'),
|
||||||
|
btnHide: $('btn-hide'),
|
||||||
|
btnSettings: $('btn-settings'),
|
||||||
|
btnExpand: $('btn-expand'),
|
||||||
|
expanderLabel: $('expander-label'),
|
||||||
|
details: $('details'),
|
||||||
|
|
||||||
|
meterBlock: $('meter-block'),
|
||||||
|
blockPercent: $('block-percent'),
|
||||||
|
blockFill: $('block-fill'),
|
||||||
|
blockProjection: $('block-projection'),
|
||||||
|
blockStatus: $('block-status'),
|
||||||
|
blockReset: $('block-reset'),
|
||||||
|
blockA11y: $('block-a11y'),
|
||||||
|
|
||||||
|
sessionTokens: $('session-tokens'),
|
||||||
|
sessionRate: $('session-rate'),
|
||||||
|
sessionRunway: $('session-runway'),
|
||||||
|
|
||||||
|
meterSpend: $('meter-spend'),
|
||||||
|
spendValue: $('spend-value'),
|
||||||
|
spendFill: $('spend-fill'),
|
||||||
|
spendStatus: $('spend-status'),
|
||||||
|
spendLimit: $('spend-limit'),
|
||||||
|
spendA11y: $('spend-a11y'),
|
||||||
|
|
||||||
|
weekPercent: $('week-percent'),
|
||||||
|
weekFill: $('week-fill'),
|
||||||
|
weekProjection: $('week-projection'),
|
||||||
|
weekStatus: $('week-status'),
|
||||||
|
weekReset: $('week-reset'),
|
||||||
|
weekA11y: $('week-a11y'),
|
||||||
|
|
||||||
|
modelBar: $('model-bar'),
|
||||||
|
modelLegend: $('model-legend'),
|
||||||
|
modelA11y: $('model-a11y'),
|
||||||
|
modelTableBody: document.querySelector('#model-table tbody'),
|
||||||
|
|
||||||
|
factBurn: $('fact-burn'),
|
||||||
|
factProjection: $('fact-projection'),
|
||||||
|
factTtl: $('fact-ttl'),
|
||||||
|
factMessages: $('fact-messages'),
|
||||||
|
factConfidence: $('fact-confidence'),
|
||||||
|
factConfidenceLabel: $('fact-confidence-label'),
|
||||||
|
factConfidenceWeek: $('fact-confidence-week'),
|
||||||
|
factOauth: $('fact-oauth'),
|
||||||
|
factUpdated: $('fact-updated'),
|
||||||
|
confidenceHint: $('confidence-hint'),
|
||||||
|
errors: $('errors'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = null;
|
||||||
|
let expanded = false;
|
||||||
|
|
||||||
|
// ── Formatierung ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const nf = new Intl.NumberFormat('de-DE');
|
||||||
|
|
||||||
|
/** Kompakte Tokenzahl: 1.2 Mio., 43,5 Tsd. */
|
||||||
|
function fmtTokens(n) {
|
||||||
|
if (!Number.isFinite(n)) return '—';
|
||||||
|
if (n >= 1e6) return `${(n / 1e6).toLocaleString('de-DE', { maximumFractionDigits: 1 })} Mio.`;
|
||||||
|
if (n >= 1e3) return `${(n / 1e3).toLocaleString('de-DE', { maximumFractionDigits: 1 })} Tsd.`;
|
||||||
|
return nf.format(Math.round(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restdauer als h:mm:ss bzw. mm:ss. */
|
||||||
|
function fmtDuration(ms) {
|
||||||
|
if (ms == null || !Number.isFinite(ms) || ms <= 0) return '—';
|
||||||
|
const total = Math.floor(ms / 1000);
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
const pad = (v) => String(v).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restdauer minutengenau: "2 T. 3 Std. 40 Min." */
|
||||||
|
function fmtCoarse(ms) {
|
||||||
|
if (ms == null || !Number.isFinite(ms) || ms <= 0) return '—';
|
||||||
|
const min = Math.floor(ms / 60000);
|
||||||
|
if (min < 60) return `${min} Min.`;
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const restMin = min % 60;
|
||||||
|
if (h < 24) return restMin ? `${h} Std. ${restMin} Min.` : `${h} Std.`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
const restH = h % 24;
|
||||||
|
const parts = [`${d} T.`];
|
||||||
|
if (restH) parts.push(`${restH} Std.`);
|
||||||
|
if (restMin) parts.push(`${restMin} Min.`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständiger Zeitpunkt inkl. Datum, sobald er nicht mehr heute liegt.
|
||||||
|
* Sekunden nur, wo sie eine Aussage haben — bei einem Termin in Tagen wären
|
||||||
|
* sie Scheingenauigkeit.
|
||||||
|
*/
|
||||||
|
function fmtExact(ts, withSeconds = false) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
const d = new Date(ts);
|
||||||
|
const time = d.toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
...(withSeconds ? { second: '2-digit' } : {}),
|
||||||
|
});
|
||||||
|
if (d.toDateString() === new Date().toDateString()) return time;
|
||||||
|
return `${d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })} ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absoluter Zeitpunkt — heute nur Uhrzeit, sonst mit Wochentag. */
|
||||||
|
function fmtWhen(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
|
const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (sameDay) return time;
|
||||||
|
return `${d.toLocaleDateString('de-DE', { weekday: 'short' })} ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPercent(p) {
|
||||||
|
if (!Number.isFinite(p)) return '—';
|
||||||
|
return `${p < 10 ? p.toLocaleString('de-DE', { maximumFractionDigits: 1 }) : Math.round(p)} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityColor(sev) {
|
||||||
|
return sev === 'critical' ? 'var(--status-critical)' : sev === 'warn' ? 'var(--status-warning)' : 'var(--status-good)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schweregrad: Serverangabe hat Vorrang vor der eigenen Schwelle. */
|
||||||
|
function severityOf(percent, serverSeverity) {
|
||||||
|
if (serverSeverity === 'warning' || serverSeverity === 'warn') return 'warn';
|
||||||
|
if (serverSeverity === 'critical' || serverSeverity === 'serious') return 'critical';
|
||||||
|
if (serverSeverity === 'normal') return 'ok';
|
||||||
|
return percent >= 85 ? 'critical' : percent >= 60 ? 'warn' : 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderMeter(prefix, data) {
|
||||||
|
const percent = Math.min(100, Math.max(0, data.percent));
|
||||||
|
el[`${prefix}Percent`].textContent = fmtPercent(data.percent);
|
||||||
|
el[`${prefix}Fill`].style.width = `${percent}%`;
|
||||||
|
el[`${prefix}Fill`].style.backgroundColor = severityColor(data.severity);
|
||||||
|
|
||||||
|
const status = el[`${prefix}Status`];
|
||||||
|
status.dataset.severity = data.severity;
|
||||||
|
const label = SEVERITY_TEXT[data.severity] || '—';
|
||||||
|
const confidence = CONFIDENCE_TEXT[data.confidence] || '';
|
||||||
|
status.querySelector('.status-text').textContent =
|
||||||
|
data.confidence === 'exact' ? label : `${label} · ${confidence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnet die schraffierte Hochrechnung hinter den Ist-Wert. */
|
||||||
|
function renderProjection(node, currentPercent, projectedPercent, sev) {
|
||||||
|
const current = Math.min(100, Math.max(0, currentPercent));
|
||||||
|
const projected = Math.min(100, Math.max(0, projectedPercent));
|
||||||
|
if (!(projected > current + 1)) {
|
||||||
|
node.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.hidden = false;
|
||||||
|
node.style.left = `${current}%`;
|
||||||
|
node.style.width = `${projected - current}%`;
|
||||||
|
// backgroundColor, nicht background: die Kurzschreibweise würde die
|
||||||
|
// Schraffur aus dem Stylesheet mitlöschen.
|
||||||
|
node.style.backgroundColor = severityColor(sev);
|
||||||
|
node.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sitzungsfenster. Der Prozentbalken erscheint nur, wenn eine echte
|
||||||
|
* Bezugsgröße vorliegt — vom Server oder selbst kalibriert. Verbrauch und
|
||||||
|
* Tempo stehen unabhängig davon immer darunter.
|
||||||
|
*/
|
||||||
|
function renderBlock(block, burn) {
|
||||||
|
// Ohne Bezugsgröße entfallen nur Prozentwert, Balken und Schweregrad.
|
||||||
|
// Überschrift und Fensterende bleiben — sie gelten unabhängig davon, und
|
||||||
|
// ohne sie stünden die Zahlen darunter ohne jeden Zusammenhang da.
|
||||||
|
el.blockPercent.hidden = !block.hasLimit;
|
||||||
|
el.meterBlock.querySelector('.track').hidden = !block.hasLimit;
|
||||||
|
el.blockStatus.hidden = !block.hasLimit;
|
||||||
|
|
||||||
|
if (block.hasLimit) {
|
||||||
|
renderMeter('block', block);
|
||||||
|
renderProjection(el.blockProjection, block.percent, burn.projectedPercent, block.severity);
|
||||||
|
el.blockA11y.textContent = `Laufende Sitzung: ${fmtPercent(block.percent)} genutzt, ${CONFIDENCE_TEXT[block.confidence]}.`;
|
||||||
|
} else {
|
||||||
|
el.blockA11y.textContent = `Laufende Sitzung: ${fmtTokens(block.raw)} Tokens verbraucht.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.sessionTokens.textContent = block.active ? fmtTokens(block.raw) : '—';
|
||||||
|
el.sessionRate.textContent = burn.perMinute > 0 ? fmtTokens(burn.perMinute) : '—';
|
||||||
|
|
||||||
|
if (!block.active) {
|
||||||
|
el.sessionRunway.textContent = 'Keine laufende Sitzung.';
|
||||||
|
} else if (burn.activeTimeLeftMs != null) {
|
||||||
|
el.sessionRunway.textContent = `Wochenkontingent reicht bei diesem Tempo noch ${fmtCoarse(burn.activeTimeLeftMs)} aktive Arbeit.`;
|
||||||
|
} else {
|
||||||
|
el.sessionRunway.textContent = 'Tempo zu gering für eine Hochrechnung.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nutzungsguthaben — der Budget-Zustand jenseits des Plan-Kontingents. */
|
||||||
|
function renderSpend(spend) {
|
||||||
|
el.meterSpend.hidden = !spend;
|
||||||
|
if (!spend) return;
|
||||||
|
|
||||||
|
const money = new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: spend.currency || 'EUR',
|
||||||
|
});
|
||||||
|
const percent = spend.percent ?? (spend.limit > 0 ? (spend.used / spend.limit) * 100 : 0);
|
||||||
|
|
||||||
|
el.spendValue.textContent = money.format(spend.used);
|
||||||
|
el.spendFill.style.width = `${Math.min(100, Math.max(0, percent))}%`;
|
||||||
|
el.spendFill.style.backgroundColor = severityColor(severityOf(percent, spend.severity));
|
||||||
|
el.spendLimit.textContent = `von ${money.format(spend.limit)}`;
|
||||||
|
|
||||||
|
const sev = severityOf(percent, spend.severity);
|
||||||
|
el.spendStatus.dataset.severity = sev;
|
||||||
|
el.spendStatus.querySelector('.status-text').textContent = spend.enabled
|
||||||
|
? `${SEVERITY_TEXT[sev]} · ${fmtPercent(percent)}`
|
||||||
|
: 'abgeschaltet';
|
||||||
|
|
||||||
|
el.spendA11y.textContent = `Nutzungsguthaben: ${money.format(spend.used)} von ${money.format(spend.limit)} verbraucht.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeek(week, burn) {
|
||||||
|
renderMeter('week', week);
|
||||||
|
renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity);
|
||||||
|
el.weekA11y.textContent = `Wochenkontingent: ${fmtPercent(week.percent)} genutzt, ${CONFIDENCE_TEXT[week.confidence]}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModels(block) {
|
||||||
|
const models = block.models || [];
|
||||||
|
const total = models.reduce((sum, m) => sum + m.raw, 0);
|
||||||
|
|
||||||
|
el.modelBar.replaceChildren();
|
||||||
|
el.modelLegend.replaceChildren();
|
||||||
|
el.modelTableBody.replaceChildren();
|
||||||
|
|
||||||
|
if (!models.length || total <= 0) {
|
||||||
|
el.modelA11y.textContent = 'Im laufenden Fenster wurden noch keine Tokens verbraucht.';
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
cell.colSpan = 4;
|
||||||
|
cell.textContent = 'Noch kein Verbrauch im laufenden Fenster.';
|
||||||
|
row.append(cell);
|
||||||
|
el.modelTableBody.append(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of models) {
|
||||||
|
const color = FAMILY_COLOR[m.family] || FAMILY_COLOR.unknown;
|
||||||
|
const share = (m.raw / total) * 100;
|
||||||
|
|
||||||
|
const seg = document.createElement('div');
|
||||||
|
seg.className = 'segment';
|
||||||
|
seg.style.width = `${share}%`;
|
||||||
|
seg.style.background = color;
|
||||||
|
seg.title = `${m.model}: ${fmtTokens(m.raw)} Tokens (${fmtPercent(share)})`;
|
||||||
|
el.modelBar.append(seg);
|
||||||
|
|
||||||
|
// Legende: ab zwei Serien immer vorhanden, damit Identität nie allein
|
||||||
|
// an der Farbe hängt.
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const swatch = document.createElement('span');
|
||||||
|
swatch.className = 'swatch';
|
||||||
|
swatch.style.background = color;
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = `${m.model.replace(/^claude-/, '')} ${fmtPercent(share)}`;
|
||||||
|
li.append(swatch, text);
|
||||||
|
el.modelLegend.append(li);
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
for (const [value, isNum] of [
|
||||||
|
[m.model.replace(/^claude-/, ''), false],
|
||||||
|
[fmtTokens(m.output), true],
|
||||||
|
[fmtTokens(m.cacheRead + m.cacheCreate), true],
|
||||||
|
[fmtTokens(m.raw), true],
|
||||||
|
]) {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
if (isNum) td.className = 'num';
|
||||||
|
td.textContent = value;
|
||||||
|
row.append(td);
|
||||||
|
}
|
||||||
|
el.modelTableBody.append(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.modelA11y.textContent = `Verbrauch je Modell: ${models
|
||||||
|
.map((m) => `${m.model}: ${fmtTokens(m.raw)} Tokens`)
|
||||||
|
.join(', ')}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(s) {
|
||||||
|
el.factBurn.textContent =
|
||||||
|
s.burn.perMinute > 0 ? `${fmtTokens(s.burn.perMinute)} gew./Min.` : 'inaktiv';
|
||||||
|
el.factProjection.textContent =
|
||||||
|
s.burn.perMinute > 0 ? fmtPercent(s.burn.weekProjectedPercent) : '—';
|
||||||
|
el.factTtl.textContent =
|
||||||
|
s.burn.activeTimeLeftMs != null ? `${fmtCoarse(s.burn.activeTimeLeftMs)} Arbeit` : '—';
|
||||||
|
el.factMessages.textContent = nf.format(s.block.messages || 0);
|
||||||
|
|
||||||
|
// Die Genauigkeit des Sitzungsfensters ist nur dann eine Aussage, wenn dafür
|
||||||
|
// überhaupt ein Prozentwert angezeigt wird.
|
||||||
|
el.factConfidence.hidden = !s.block.hasLimit;
|
||||||
|
el.factConfidenceLabel.hidden = !s.block.hasLimit;
|
||||||
|
el.factConfidence.textContent = CONFIDENCE_TEXT[s.block.confidence] || '—';
|
||||||
|
el.factConfidenceWeek.textContent = CONFIDENCE_TEXT[s.week.confidence] || '—';
|
||||||
|
el.factOauth.textContent = s.oauth.text || '—';
|
||||||
|
|
||||||
|
// Der Hinweis gilt dem schwächsten der gezeigten Werte.
|
||||||
|
const rank = { exact: 3, configured: 2, calibrated: 1, estimate: 0 };
|
||||||
|
const shown = s.block.hasLimit ? [s.week.confidence, s.block.confidence] : [s.week.confidence];
|
||||||
|
const weakest = shown.reduce((a, b) => ((rank[b] ?? 0) < (rank[a] ?? 0) ? b : a));
|
||||||
|
el.confidenceHint.textContent = CONFIDENCE_HINT[weakest] || '';
|
||||||
|
|
||||||
|
if (s.errors && s.errors.length) {
|
||||||
|
el.errors.hidden = false;
|
||||||
|
el.errors.replaceChildren(
|
||||||
|
...s.errors.map((msg) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = msg;
|
||||||
|
return li;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
el.errors.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(s) {
|
||||||
|
state = s;
|
||||||
|
const plan = s.subscription.subscriptionType;
|
||||||
|
el.planLabel.textContent = plan ? `Claude · ${plan.replace(/_/g, ' ').toUpperCase()}` : 'Claude';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
renderModels(s.block);
|
||||||
|
renderDetails(s);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Läuft im Sekundentakt und hält nur die zeitabhängigen Anzeigen aktuell. */
|
||||||
|
function tick() {
|
||||||
|
if (!state) return;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// „endet in“, nicht „Reset in“: Ohne eigenes Limit wird beim Fensterwechsel
|
||||||
|
// nichts zurückgesetzt — das Fenster gruppiert nur den Verbrauch.
|
||||||
|
const blockRemaining = state.block.resetAt ? state.block.resetAt - now : null;
|
||||||
|
el.blockReset.textContent = state.block.resetAt
|
||||||
|
? `${fmtExact(state.block.resetAt, true)} · noch ${fmtDuration(blockRemaining)}`
|
||||||
|
: 'inaktiv';
|
||||||
|
|
||||||
|
if (state.week.resetAt) {
|
||||||
|
const weekRemaining = state.week.resetAt - now;
|
||||||
|
// Untertägig auf die Sekunde genau, darüber minutengenau — mehr wäre
|
||||||
|
// Scheingenauigkeit bei einem Countdown über Tage.
|
||||||
|
const rest = weekRemaining < 24 * 3600e3 ? fmtDuration(weekRemaining) : fmtCoarse(weekRemaining);
|
||||||
|
el.weekReset.textContent = `${fmtExact(state.week.resetAt)} · noch ${rest}`;
|
||||||
|
} else if (state.week.rolling) {
|
||||||
|
// Ohne bekannten Reset-Wochentag wandert das Fenster mit — ein Countdown
|
||||||
|
// wäre hier eine erfundene Zahl.
|
||||||
|
el.weekReset.textContent = 'rollierende 7 Tage';
|
||||||
|
} else {
|
||||||
|
el.weekReset.textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bleiben Aktualisierungen aus, wird das kenntlich gemacht statt still
|
||||||
|
// veraltete Zahlen zu zeigen.
|
||||||
|
const age = now - state.updatedAt;
|
||||||
|
el.staleBadge.hidden = age < 60000;
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
el.factUpdated.textContent = age < 45000 ? 'gerade eben' : `vor ${fmtCoarse(age)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interaktion ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Setzt die Ansicht; `persist` false beim Wiederherstellen nach dem Start. */
|
||||||
|
function applyExpanded(next, persist = true) {
|
||||||
|
expanded = next;
|
||||||
|
el.details.hidden = !expanded;
|
||||||
|
el.btnExpand.setAttribute('aria-expanded', String(expanded));
|
||||||
|
el.expanderLabel.textContent = expanded ? 'Weniger' : 'Details';
|
||||||
|
if (persist) window.dashboard.setExpanded(expanded);
|
||||||
|
if (expanded && state) {
|
||||||
|
renderModels(state.block);
|
||||||
|
renderDetails(state);
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.btnExpand.addEventListener('click', () => applyExpanded(!expanded));
|
||||||
|
|
||||||
|
el.btnHide.addEventListener('click', () => window.dashboard.hide());
|
||||||
|
el.btnSettings.addEventListener('click', () => window.dashboard.openSettings());
|
||||||
|
|
||||||
|
window.dashboard.onState(render);
|
||||||
|
|
||||||
|
// Reihenfolge: erst die Ansicht auf die wiederhergestellte Fenstergröße
|
||||||
|
// bringen, dann die Daten einfüllen.
|
||||||
|
window.dashboard.getUiState().then((ui) => {
|
||||||
|
if (ui && ui.expanded) applyExpanded(true, false);
|
||||||
|
return window.dashboard.getState();
|
||||||
|
}).then((s) => {
|
||||||
|
if (s) render(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(tick, 1000);
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<!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>Claude Live Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="widget" id="widget">
|
||||||
|
<!-- Kopfzeile: einziger Ziehbereich des rahmenlosen Fensters -->
|
||||||
|
<header class="titlebar">
|
||||||
|
<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" id="btn-settings" title="Einstellungen" aria-label="Einstellungen">
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" focusable="false">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M8 5.2a2.8 2.8 0 1 0 0 5.6 2.8 2.8 0 0 0 0-5.6Zm0 1.4a1.4 1.4 0 1 1 0 2.8 1.4 1.4 0 0 1 0-2.8Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M6.9.9a.7.7 0 0 0-.69.58l-.2 1.16a5.6 5.6 0 0 0-1.02.59l-1.1-.42a.7.7 0 0 0-.85.31l-1.1 1.9a.7.7 0 0 0 .16.89l.9.74a5.65 5.65 0 0 0 0 1.18l-.9.74a.7.7 0 0 0-.16.89l1.1 1.9a.7.7 0 0 0 .85.31l1.1-.42c.32.24.66.44 1.02.59l.2 1.16a.7.7 0 0 0 .69.58h2.2a.7.7 0 0 0 .69-.58l.2-1.16c.36-.15.7-.35 1.02-.59l1.1.42a.7.7 0 0 0 .85-.31l1.1-1.9a.7.7 0 0 0-.16-.89l-.9-.74a5.65 5.65 0 0 0 0-1.18l.9-.74a.7.7 0 0 0 .16-.89l-1.1-1.9a.7.7 0 0 0-.85-.31l-1.1.42a5.6 5.6 0 0 0-1.02-.59l-.2-1.16A.7.7 0 0 0 9.1.9H6.9Zm.59 1.4h1.02l.17.99a.7.7 0 0 0 .5.55c.46.14.89.39 1.24.72a.7.7 0 0 0 .73.14l.94-.36.51.88-.77.63a.7.7 0 0 0-.25.7 4.25 4.25 0 0 1 0 1.44.7.7 0 0 0 .25.7l.77.63-.51.88-.94-.36a.7.7 0 0 0-.73.14c-.35.33-.78.58-1.24.72a.7.7 0 0 0-.5.55l-.17.99H7.49l-.17-.99a.7.7 0 0 0-.5-.55 4.2 4.2 0 0 1-1.24-.72.7.7 0 0 0-.73-.14l-.94.36-.51-.88.77-.63a.7.7 0 0 0 .25-.7 4.25 4.25 0 0 1 0-1.44.7.7 0 0 0-.25-.7l-.77-.63.51-.88.94.36a.7.7 0 0 0 .73-.14c.35-.33.78-.58 1.24-.72a.7.7 0 0 0 .5-.55l.17-.99Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" id="btn-hide" title="Ausblenden (über das Tray-Symbol zurückholen)">–</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="meters">
|
||||||
|
<!-- Laufende Sitzung: das kurzfristig bindende Limit -->
|
||||||
|
<section class="meter" id="meter-block">
|
||||||
|
<div class="meter-head">
|
||||||
|
<span class="meter-label">Laufende Sitzung</span>
|
||||||
|
<span class="meter-value" id="block-percent">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="track" role="img" aria-labelledby="block-a11y">
|
||||||
|
<div class="fill" id="block-fill"></div>
|
||||||
|
<div class="projection" id="block-projection" hidden></div>
|
||||||
|
</div>
|
||||||
|
<div class="meter-foot">
|
||||||
|
<span class="status" id="block-status"><span class="status-icon" aria-hidden="true"></span><span class="status-text">—</span></span>
|
||||||
|
<span class="reset" id="block-reset">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-figures">
|
||||||
|
<div class="figure">
|
||||||
|
<span class="figure-value" id="session-tokens">—</span>
|
||||||
|
<span class="figure-label">Tokens</span>
|
||||||
|
</div>
|
||||||
|
<div class="figure">
|
||||||
|
<span class="figure-value" id="session-rate">—</span>
|
||||||
|
<span class="figure-label">pro Minute</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="sr-only" id="block-a11y"></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Wochenkontingent -->
|
||||||
|
<section class="meter" id="meter-week">
|
||||||
|
<div class="meter-head">
|
||||||
|
<span class="meter-label">Wochenkontingent</span>
|
||||||
|
<span class="meter-value" id="week-percent">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="track" role="img" aria-labelledby="week-a11y">
|
||||||
|
<div class="fill" id="week-fill"></div>
|
||||||
|
<div class="projection" id="week-projection" hidden></div>
|
||||||
|
</div>
|
||||||
|
<div class="meter-foot">
|
||||||
|
<span class="status" id="week-status"><span class="status-icon" aria-hidden="true"></span><span class="status-text">—</span></span>
|
||||||
|
<span class="reset" id="week-reset">—</span>
|
||||||
|
</div>
|
||||||
|
<span class="sr-only" id="week-a11y"></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Nutzungsguthaben — nur wenn der Server es meldet -->
|
||||||
|
<section class="meter" id="meter-spend" hidden>
|
||||||
|
<div class="meter-head">
|
||||||
|
<span class="meter-label">Nutzungsguthaben</span>
|
||||||
|
<span class="meter-value" id="spend-value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="track" role="img" aria-labelledby="spend-a11y">
|
||||||
|
<div class="fill" id="spend-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="meter-foot">
|
||||||
|
<span class="status" id="spend-status"><span class="status-icon" aria-hidden="true"></span><span class="status-text">—</span></span>
|
||||||
|
<span class="reset" id="spend-limit">—</span>
|
||||||
|
</div>
|
||||||
|
<span class="sr-only" id="spend-a11y"></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="runway" id="session-runway">—</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<button class="expander" id="btn-expand" aria-expanded="false" aria-controls="details">
|
||||||
|
<span id="expander-label">Details</span>
|
||||||
|
<span class="chevron" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Detailansicht -->
|
||||||
|
<div class="details" id="details" hidden>
|
||||||
|
<section class="detail-block">
|
||||||
|
<h2>Tokenverbrauch im laufenden Fenster</h2>
|
||||||
|
<div class="stacked" id="model-bar" role="img" aria-labelledby="model-a11y"></div>
|
||||||
|
<ul class="legend" id="model-legend"></ul>
|
||||||
|
<span class="sr-only" id="model-a11y"></span>
|
||||||
|
<table class="model-table" id="model-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Modell</th>
|
||||||
|
<th scope="col" class="num">Ausgabe</th>
|
||||||
|
<th scope="col" class="num">Cache</th>
|
||||||
|
<th scope="col" class="num">Gesamt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-block">
|
||||||
|
<h2>Tempo</h2>
|
||||||
|
<dl class="facts">
|
||||||
|
<dt>Burn-Rate</dt>
|
||||||
|
<dd id="fact-burn">—</dd>
|
||||||
|
<dt>Woche in 1 Std.</dt>
|
||||||
|
<dd id="fact-projection">—</dd>
|
||||||
|
<dt>Kontingent reicht noch</dt>
|
||||||
|
<dd id="fact-ttl">—</dd>
|
||||||
|
<dt>Nachrichten in der Sitzung</dt>
|
||||||
|
<dd id="fact-messages">—</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-block">
|
||||||
|
<h2>Datengrundlage</h2>
|
||||||
|
<dl class="facts">
|
||||||
|
<dt id="fact-confidence-label" hidden>Genauigkeit 5 Std.</dt>
|
||||||
|
<dd id="fact-confidence" hidden>—</dd>
|
||||||
|
<dt>Genauigkeit Woche</dt>
|
||||||
|
<dd id="fact-confidence-week">—</dd>
|
||||||
|
<dt>Serverabfrage</dt>
|
||||||
|
<dd id="fact-oauth">—</dd>
|
||||||
|
<dt>Aktualisiert</dt>
|
||||||
|
<dd id="fact-updated">—</dd>
|
||||||
|
</dl>
|
||||||
|
<p class="hint" id="confidence-hint"></p>
|
||||||
|
<ul class="errors" id="errors" hidden></ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
/*
|
||||||
|
* Das Widget ist ein transluzentes Overlay und committet sich bewusst auf
|
||||||
|
* einen dunklen Look — ein heller Modus würde über beliebigen Fenstern
|
||||||
|
* schlechter lesbar sein. Die Farbwerte stammen aus der validierten Palette
|
||||||
|
* und wurden gegen die Oberfläche #1e1e22 geprüft.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nahezu deckend: Ein transparentes Electron-Fenster hat keinen Backdrop,
|
||||||
|
* den CSS blurren könnte — durchscheinender Text darunter macht das Widget
|
||||||
|
* unlesbar. Die Transparenz steuert stattdessen bewusst das Tray-Menü über
|
||||||
|
* die Fenster-Deckkraft.
|
||||||
|
*/
|
||||||
|
--surface: rgba(24, 24, 27, 0.985);
|
||||||
|
--surface-solid: #1e1e22;
|
||||||
|
--surface-raised: rgba(255, 255, 255, 0.05);
|
||||||
|
--track: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--text-muted: #898781;
|
||||||
|
--hairline: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
/* Statusfarben — fest, nie durch Serienfarben ersetzt */
|
||||||
|
--status-good: #0ca30c;
|
||||||
|
--status-warning: #fab219;
|
||||||
|
--status-critical: #d03b3b;
|
||||||
|
|
||||||
|
/* Kategoriale Slots (dunkle Stufen), fest je Modellfamilie vergeben */
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--series-2: #d95926;
|
||||||
|
--series-3: #199e70;
|
||||||
|
--series-4: #c98500;
|
||||||
|
--series-other: #898781;
|
||||||
|
|
||||||
|
--radius: 10px;
|
||||||
|
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Das hidden-Attribut muss jede display-Regel schlagen. Ohne !important
|
||||||
|
* gewinnt z. B. `.meter-head { display: flex }` gegen das UA-Stylesheet, und
|
||||||
|
* ein ausgeblendeter Bereich bleibt sichtbar — mit leeren Platzhaltern.
|
||||||
|
*/
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--text-primary);
|
||||||
|
user-select: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kopfzeile ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.titlebar {
|
||||||
|
-webkit-app-region: drag; /* einziger Ziehbereich des rahmenlosen Fensters */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 8px 7px 10px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-brand {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--series-2);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meter ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.meters {
|
||||||
|
padding: 10px 12px 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-value {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Balken: 4px runde Datenenden, an der Grundlinie verankert */
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--track);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--status-good);
|
||||||
|
transition: width 0.4s ease, background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hochrechnung als schraffierte Verlängerung — deutlich vom Ist getrennt */
|
||||||
|
.projection {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 0.28) 0 2px,
|
||||||
|
transparent 2px 5px
|
||||||
|
);
|
||||||
|
border-left: 2px solid var(--surface-solid); /* 2px Abstand zum Ist-Wert */
|
||||||
|
transition: left 0.4s ease, width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status trägt immer Symbol + Text, nie Farbe allein */
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-good);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status[data-severity="warn"] .status-icon {
|
||||||
|
background: var(--status-warning);
|
||||||
|
border-radius: 1px;
|
||||||
|
transform: rotate(45deg); /* zweite Unterscheidung neben der Farbe */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status[data-severity="critical"] .status-icon {
|
||||||
|
background: var(--status-critical);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status[data-severity="warn"] .status-text {
|
||||||
|
color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status[data-severity="critical"] .status-text {
|
||||||
|
color: var(--status-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eigene Schriftgröße: .reset steht auch außerhalb von .meter-foot. */
|
||||||
|
.reset {
|
||||||
|
/* Bleibt rechtsbündig, auch wenn der Status daneben entfällt. */
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Laufende Sitzung ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.session-figures {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure-label {
|
||||||
|
font-size: 9.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runway {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
padding-top: 9px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Aufklapper ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.expander {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expander:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expander[aria-expanded="true"] .chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detailansicht ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.details {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 4px 12px 12px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--track);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-block {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-block + .detail-block {
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-block h2 {
|
||||||
|
margin: 0 0 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gestapelter Balken mit 2px Oberflächenlücke zwischen den Segmenten */
|
||||||
|
.stacked {
|
||||||
|
display: flex;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--track);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked .segment {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked .segment:first-child {
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked .segment:last-child {
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked .segment:only-child {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
list-style: none;
|
||||||
|
margin: 7px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend .swatch {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabellenansicht — zweiter Zugang zu denselben Zahlen */
|
||||||
|
.model-table {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 9px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-table th,
|
||||||
|
.model-table td {
|
||||||
|
padding: 3px 0;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-table th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 9.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-table .num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-table td:first-child {
|
||||||
|
max-width: 118px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 4px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dd {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors {
|
||||||
|
list-style: none;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** Brücke für den Einstellungsdialog. Bewusst getrennt vom Widget-Preload. */
|
||||||
|
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('settings', {
|
||||||
|
/** @returns {Promise<{config: Object, meta: {configPath: string, autostart: boolean}}>} */
|
||||||
|
load: () => ipcRenderer.invoke('settings:load'),
|
||||||
|
/** @returns {Promise<{ok: boolean, error?: string}>} */
|
||||||
|
save: (patch) => ipcRenderer.invoke('settings:save', patch),
|
||||||
|
setAutostart: (enabled) => ipcRenderer.invoke('settings:autostart', enabled),
|
||||||
|
openFile: () => ipcRenderer.send('settings:open-file'),
|
||||||
|
close: () => ipcRenderer.send('settings:close'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungsdialog. Liest und schreibt dieselbe config.json, die auch von
|
||||||
|
* Hand bearbeitet werden kann — unbekannte Felder und die `_`-Kommentare
|
||||||
|
* bleiben dabei erhalten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const el = {
|
||||||
|
path: $('config-path'),
|
||||||
|
close: $('btn-close'),
|
||||||
|
save: $('btn-save'),
|
||||||
|
revert: $('btn-revert'),
|
||||||
|
openFile: $('btn-open-file'),
|
||||||
|
feedback: $('feedback'),
|
||||||
|
|
||||||
|
opacity: $('opacity'),
|
||||||
|
opacityOut: $('opacity-out'),
|
||||||
|
clickThrough: $('clickThrough'),
|
||||||
|
autostart: $('autostart'),
|
||||||
|
|
||||||
|
oauthEnabled: $('oauthEnabled'),
|
||||||
|
oauthEndpoint: $('oauthEndpoint'),
|
||||||
|
|
||||||
|
limitWeek: $('limitWeek'),
|
||||||
|
limitBlock: $('limitBlock'),
|
||||||
|
|
||||||
|
wInput: $('wInput'),
|
||||||
|
wOutput: $('wOutput'),
|
||||||
|
wCacheCreate: $('wCacheCreate'),
|
||||||
|
wCacheRead: $('wCacheRead'),
|
||||||
|
mOpus: $('mOpus'),
|
||||||
|
mSonnet: $('mSonnet'),
|
||||||
|
mHaiku: $('mHaiku'),
|
||||||
|
|
||||||
|
weekday: $('weekday'),
|
||||||
|
resetHour: $('resetHour'),
|
||||||
|
resetMinute: $('resetMinute'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Voreinstellungen, die auch der Main-Prozess verwendet. */
|
||||||
|
const DEFAULTS = {
|
||||||
|
opacity: 1,
|
||||||
|
tokenWeights: { input: 1, output: 5, cacheCreate: 1.25, cacheRead: 0.1 },
|
||||||
|
modelWeights: { opus: 5, sonnet: 1, haiku: 0.3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let feedbackTimer = null;
|
||||||
|
|
||||||
|
function say(text, kind = 'ok') {
|
||||||
|
el.feedback.hidden = false;
|
||||||
|
el.feedback.textContent = text;
|
||||||
|
el.feedback.dataset.kind = kind;
|
||||||
|
clearTimeout(feedbackTimer);
|
||||||
|
feedbackTimer = setTimeout(() => {
|
||||||
|
el.feedback.hidden = true;
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zahl aus einem Feld; leer ergibt null. */
|
||||||
|
function num(input) {
|
||||||
|
const raw = input.value.trim();
|
||||||
|
if (raw === '') return null;
|
||||||
|
const value = Number(raw.replace(',', '.'));
|
||||||
|
return Number.isFinite(value) ? value : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNum(input, value, fallback = '') {
|
||||||
|
input.value = value == null ? fallback : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Füllt die Oberfläche aus der Konfiguration. */
|
||||||
|
function fill(config, meta) {
|
||||||
|
el.path.textContent = meta.configPath;
|
||||||
|
|
||||||
|
const opacity = Math.round((config.opacity ?? DEFAULTS.opacity) * 100);
|
||||||
|
el.opacity.value = String(opacity);
|
||||||
|
el.opacityOut.textContent = `${opacity} %`;
|
||||||
|
el.clickThrough.checked = Boolean(config.clickThrough);
|
||||||
|
el.autostart.checked = Boolean(meta.autostart);
|
||||||
|
|
||||||
|
const oauth = config.oauth || {};
|
||||||
|
el.oauthEnabled.checked = oauth.enabled !== false;
|
||||||
|
el.oauthEndpoint.value = oauth.endpoint || '';
|
||||||
|
|
||||||
|
const limits = config.limits || {};
|
||||||
|
setNum(el.limitWeek, limits.week);
|
||||||
|
setNum(el.limitBlock, limits.block);
|
||||||
|
|
||||||
|
const tw = { ...DEFAULTS.tokenWeights, ...(config.tokenWeights || {}) };
|
||||||
|
setNum(el.wInput, tw.input);
|
||||||
|
setNum(el.wOutput, tw.output);
|
||||||
|
setNum(el.wCacheCreate, tw.cacheCreate);
|
||||||
|
setNum(el.wCacheRead, tw.cacheRead);
|
||||||
|
|
||||||
|
const mw = { ...DEFAULTS.modelWeights, ...(config.modelWeights || {}) };
|
||||||
|
setNum(el.mOpus, mw.opus);
|
||||||
|
setNum(el.mSonnet, mw.sonnet);
|
||||||
|
setNum(el.mHaiku, mw.haiku);
|
||||||
|
|
||||||
|
el.weekday.value = config.weekResetWeekday == null ? '' : String(config.weekResetWeekday);
|
||||||
|
setNum(el.resetHour, config.weekResetHour, '0');
|
||||||
|
setNum(el.resetMinute, config.weekResetMinute, '0');
|
||||||
|
|
||||||
|
updateWeekFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset-Zeit ist ohne gewählten Wochentag bedeutungslos. */
|
||||||
|
function updateWeekFields() {
|
||||||
|
const off = el.weekday.value === '';
|
||||||
|
el.resetHour.disabled = off;
|
||||||
|
el.resetMinute.disabled = off;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut das Änderungsobjekt. Nur gesetzte Werte werden übernommen — so bleiben
|
||||||
|
* Felder, die der Dialog nicht kennt, in der Datei unangetastet.
|
||||||
|
* @returns {{patch: Object, errors: string[]}}
|
||||||
|
*/
|
||||||
|
function collect() {
|
||||||
|
const errors = [];
|
||||||
|
const patch = {};
|
||||||
|
|
||||||
|
const check = (input, label, { min = 0, max = Infinity, required = false } = {}) => {
|
||||||
|
const value = num(input);
|
||||||
|
const invalid =
|
||||||
|
Number.isNaN(value) || (value != null && (value < min || value > max)) || (required && value == null);
|
||||||
|
input.setAttribute('aria-invalid', invalid ? 'true' : 'false');
|
||||||
|
if (invalid) errors.push(label);
|
||||||
|
return invalid ? undefined : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
patch.opacity = Number(el.opacity.value) / 100;
|
||||||
|
patch.clickThrough = el.clickThrough.checked;
|
||||||
|
|
||||||
|
patch.oauth = { enabled: el.oauthEnabled.checked };
|
||||||
|
const endpoint = el.oauthEndpoint.value.trim();
|
||||||
|
if (endpoint) {
|
||||||
|
if (!/^https:\/\/[^\s]+$/.test(endpoint)) errors.push('Endpunkt');
|
||||||
|
else patch.oauth.endpoint = endpoint;
|
||||||
|
} else {
|
||||||
|
patch.oauth.endpoint = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const week = check(el.limitWeek, 'Bezugsgröße Woche');
|
||||||
|
const block = check(el.limitBlock, 'Bezugsgröße 5-Stunden-Fenster');
|
||||||
|
patch.limits = {};
|
||||||
|
if (week) patch.limits.week = week;
|
||||||
|
if (block) patch.limits.block = block;
|
||||||
|
|
||||||
|
patch.tokenWeights = {
|
||||||
|
input: check(el.wInput, 'Gewicht Eingabe', { required: true }),
|
||||||
|
output: check(el.wOutput, 'Gewicht Ausgabe', { required: true }),
|
||||||
|
cacheCreate: check(el.wCacheCreate, 'Gewicht Cache anlegen', { required: true }),
|
||||||
|
cacheRead: check(el.wCacheRead, 'Gewicht Cache lesen', { required: true }),
|
||||||
|
};
|
||||||
|
patch.modelWeights = {
|
||||||
|
opus: check(el.mOpus, 'Gewicht Opus', { required: true }),
|
||||||
|
sonnet: check(el.mSonnet, 'Gewicht Sonnet', { required: true }),
|
||||||
|
haiku: check(el.mHaiku, 'Gewicht Haiku', { required: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (el.weekday.value === '') {
|
||||||
|
patch.weekResetWeekday = null;
|
||||||
|
patch.weekResetHour = null;
|
||||||
|
patch.weekResetMinute = null;
|
||||||
|
} else {
|
||||||
|
patch.weekResetWeekday = Number(el.weekday.value);
|
||||||
|
patch.weekResetHour = check(el.resetHour, 'Reset-Stunde', { max: 23, required: true });
|
||||||
|
patch.weekResetMinute = check(el.resetMinute, 'Reset-Minute', { max: 59, required: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { patch, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const { config, meta } = await window.settings.load();
|
||||||
|
fill(config, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.opacity.addEventListener('input', () => {
|
||||||
|
el.opacityOut.textContent = `${el.opacity.value} %`;
|
||||||
|
});
|
||||||
|
|
||||||
|
el.weekday.addEventListener('change', updateWeekFields);
|
||||||
|
|
||||||
|
// Autostart ist keine Einstellung der Datei, sondern ein Systemzustand —
|
||||||
|
// deshalb sofort wirksam statt beim Speichern.
|
||||||
|
el.autostart.addEventListener('change', async () => {
|
||||||
|
await window.settings.setAutostart(el.autostart.checked);
|
||||||
|
say(el.autostart.checked ? 'Autostart aktiviert.' : 'Autostart deaktiviert.');
|
||||||
|
});
|
||||||
|
|
||||||
|
el.save.addEventListener('click', async () => {
|
||||||
|
const { patch, errors } = collect();
|
||||||
|
if (errors.length) {
|
||||||
|
say(`Bitte prüfen: ${errors.join(', ')}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.save.disabled = true;
|
||||||
|
const result = await window.settings.save(patch);
|
||||||
|
el.save.disabled = false;
|
||||||
|
say(result.ok ? 'Gespeichert und übernommen.' : `Fehler: ${result.error}`, result.ok ? 'ok' : 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
el.revert.addEventListener('click', async () => {
|
||||||
|
await load();
|
||||||
|
say('Änderungen verworfen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
el.openFile.addEventListener('click', () => window.settings.openFile());
|
||||||
|
el.close.addEventListener('click', () => window.settings.close());
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') window.settings.close();
|
||||||
|
if (event.key === 's' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
el.save.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<!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>Einstellungen</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="titlebar">
|
||||||
|
<span class="title">Einstellungen</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="icon-btn" id="btn-close" title="Schließen" aria-label="Schließen">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<p class="path" id="config-path">—</p>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>Anzeige</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="opacity">Deckkraft</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="range" id="opacity" min="30" max="100" step="5" />
|
||||||
|
<output id="opacity-out">100 %</output>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="clickThrough">Klicks durchlassen</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="checkbox" id="clickThrough" />
|
||||||
|
<span class="hint">Das Widget fängt keine Mausklicks mehr ab.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="autostart">Mit Windows starten</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="checkbox" id="autostart" />
|
||||||
|
<span class="hint">Wird sofort übernommen, unabhängig vom Speichern.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>Datenquelle</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="oauthEnabled">Serverabfrage</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="checkbox" id="oauthEnabled" />
|
||||||
|
<span class="hint">
|
||||||
|
Liefert exakte Prozentwerte und Reset-Zeiten. Abgeschaltet rechnet
|
||||||
|
das Dashboard nur lokal — dann sind alle Werte Näherungen.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="oauthEndpoint">Endpunkt</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" id="oauthEndpoint" placeholder="Standard verwenden" spellcheck="false" />
|
||||||
|
<span class="hint">Nur ausfüllen, um den Standard zu überschreiben.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>Bezugsgrößen <span class="tag">Rückfallebene</span></h2>
|
||||||
|
<p class="note">
|
||||||
|
Werden nur benutzt, wenn die Serverabfrage ausfällt. Ermitteln mit
|
||||||
|
<code>npm run calibrate</code>. Leer lassen für Auto-Kalibrierung.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="limitWeek">Woche</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="number" id="limitWeek" min="0" step="1000000" placeholder="automatisch" />
|
||||||
|
<span class="hint">gewichtete Tokens für 100 %</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="limitBlock">5-Stunden-Fenster</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="number" id="limitBlock" min="0" step="1000000" placeholder="automatisch" />
|
||||||
|
<span class="hint">gewichtete Tokens für 100 %</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>Gewichtung</h2>
|
||||||
|
<p class="note">
|
||||||
|
Wie stark Token-Arten und Modelle ins Kontingent zählen. Beeinflusst
|
||||||
|
die lokale Rechnung und die Hochrechnung „reicht noch …“, nicht die
|
||||||
|
Serverwerte.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid" id="weights">
|
||||||
|
<label for="wInput">Eingabe</label><input type="number" id="wInput" min="0" step="0.05" />
|
||||||
|
<label for="wOutput">Ausgabe</label><input type="number" id="wOutput" min="0" step="0.05" />
|
||||||
|
<label for="wCacheCreate">Cache anlegen</label><input type="number" id="wCacheCreate" min="0" step="0.05" />
|
||||||
|
<label for="wCacheRead">Cache lesen</label><input type="number" id="wCacheRead" min="0" step="0.05" />
|
||||||
|
<label for="mOpus">Opus</label><input type="number" id="mOpus" min="0" step="0.1" />
|
||||||
|
<label for="mSonnet">Sonnet</label><input type="number" id="mSonnet" min="0" step="0.1" />
|
||||||
|
<label for="mHaiku">Haiku</label><input type="number" id="mHaiku" min="0" step="0.1" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>Wochenfenster <span class="tag">Rückfallebene</span></h2>
|
||||||
|
<p class="note">
|
||||||
|
Bei aktiver Serverabfrage wird der echte Reset-Zeitpunkt verwendet und
|
||||||
|
diese Angaben bleiben ungenutzt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="weekday">Reset-Tag</label>
|
||||||
|
<div class="control">
|
||||||
|
<select id="weekday">
|
||||||
|
<option value="">rollierende 7 Tage</option>
|
||||||
|
<option value="0">Sonntag</option>
|
||||||
|
<option value="1">Montag</option>
|
||||||
|
<option value="2">Dienstag</option>
|
||||||
|
<option value="3">Mittwoch</option>
|
||||||
|
<option value="4">Donnerstag</option>
|
||||||
|
<option value="5">Freitag</option>
|
||||||
|
<option value="6">Samstag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="resetHour">Uhrzeit</label>
|
||||||
|
<div class="control inline">
|
||||||
|
<input type="number" id="resetHour" min="0" max="23" step="1" />
|
||||||
|
<span class="colon">:</span>
|
||||||
|
<input type="number" id="resetMinute" min="0" max="59" step="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="actions">
|
||||||
|
<button class="btn ghost" id="btn-open-file">Datei öffnen</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="feedback" id="feedback" hidden></span>
|
||||||
|
<button class="btn ghost" id="btn-revert">Verwerfen</button>
|
||||||
|
<button class="btn primary" id="btn-save">Speichern</button>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
/* Einstellungsfenster — 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);
|
||||||
|
--surface-input: rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--text-muted: #898781;
|
||||||
|
--hairline: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
--accent: #3987e5;
|
||||||
|
--good: #0ca30c;
|
||||||
|
--warning: #fab219;
|
||||||
|
|
||||||
|
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group + .group {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: none;
|
||||||
|
color: var(--warning);
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note code {
|
||||||
|
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
background: var(--surface-input);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Zeilen ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 148px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 7px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > label {
|
||||||
|
padding-top: 5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control.inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control .hint {
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 148px 1fr;
|
||||||
|
gap: 8px 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Eingabefelder ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--surface-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
max-width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control.inline input[type="number"] {
|
||||||
|
max-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 190px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control:has(> input[type="range"]) {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ungültige Eingabe deutlich, aber nicht allein über die Farbe */
|
||||||
|
input[aria-invalid="true"] {
|
||||||
|
border-color: var(--warning);
|
||||||
|
background: rgba(250, 178, 25, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--good);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback[data-kind="error"] {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
REM Startet das Dashboard, ohne dass Node im PATH liegen muss.
|
||||||
|
REM Auf diesem System zeigt der nvm-Symlink "C:\Program Files\nodejs"
|
||||||
|
REM ins Leere, deshalb wird die Node-Version hier direkt angesteuert.
|
||||||
|
|
||||||
|
setlocal
|
||||||
|
set "NODE_DIR=%APPDATA%\nvm\v22.22.1"
|
||||||
|
|
||||||
|
if not exist "%NODE_DIR%\node.exe" (
|
||||||
|
echo Node wurde unter "%NODE_DIR%" nicht gefunden.
|
||||||
|
echo Passe NODE_DIR in dieser Datei an oder fuehre "nvm use 22.22.1" aus.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "PATH=%NODE_DIR%;%PATH%"
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
if not exist "node_modules\electron" (
|
||||||
|
echo Abhaengigkeiten werden installiert...
|
||||||
|
call npm install || exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
start "" "node_modules\.bin\electron.cmd" .
|
||||||
|
endlocal
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt die Tray-Symbole als PNG — ohne Bildbibliothek, nur mit zlib aus der
|
||||||
|
* Standardbibliothek. Einmalig aufzurufen:
|
||||||
|
*
|
||||||
|
* node tools/make-icon.js
|
||||||
|
*
|
||||||
|
* Motiv: ein Ring mit einem Sektor, der den Füllstand andeutet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const zlib = require('node:zlib');
|
||||||
|
|
||||||
|
const OUT_DIR = path.join(__dirname, '..', 'assets');
|
||||||
|
|
||||||
|
/** CRC32 nach PNG-Spezifikation. */
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const table = new Int32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = -1;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const out = Buffer.alloc(data.length + 12);
|
||||||
|
out.writeUInt32BE(data.length, 0);
|
||||||
|
out.write(type, 4, 'ascii');
|
||||||
|
data.copy(out, 8);
|
||||||
|
const crcInput = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
out.writeUInt32BE(crc32(crcInput), data.length + 8);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} size Kantenlänge
|
||||||
|
* @param {(x: number, y: number) => [number, number, number, number]} shade RGBA je Pixel
|
||||||
|
*/
|
||||||
|
function makePng(size, shade) {
|
||||||
|
// Scanlines mit Filterbyte 0 je Zeile
|
||||||
|
const raw = Buffer.alloc(size * (size * 4 + 1));
|
||||||
|
let p = 0;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
raw[p++] = 0;
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const [r, g, b, a] = shade(x, y);
|
||||||
|
raw[p++] = r;
|
||||||
|
raw[p++] = g;
|
||||||
|
raw[p++] = b;
|
||||||
|
raw[p++] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8; // Bittiefe
|
||||||
|
ihdr[9] = 6; // Farbtyp RGBA
|
||||||
|
ihdr[10] = 0;
|
||||||
|
ihdr[11] = 0;
|
||||||
|
ihdr[12] = 0;
|
||||||
|
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ring mit Füllsektor; kantengeglättet über 4×4-Supersampling. */
|
||||||
|
function ringShader(size, color, fillFraction = 0.68) {
|
||||||
|
const c = (size - 1) / 2;
|
||||||
|
const rOuter = size * 0.46;
|
||||||
|
const rInner = size * 0.28;
|
||||||
|
const SS = 4;
|
||||||
|
|
||||||
|
return (x, y) => {
|
||||||
|
let hits = 0;
|
||||||
|
for (let sy = 0; sy < SS; sy++) {
|
||||||
|
for (let sx = 0; sx < SS; sx++) {
|
||||||
|
const px = x + (sx + 0.5) / SS - 0.5;
|
||||||
|
const py = y + (sy + 0.5) / SS - 0.5;
|
||||||
|
const dx = px - c;
|
||||||
|
const dy = py - c;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist > rOuter || dist < rInner) continue;
|
||||||
|
// Winkel ab 12 Uhr im Uhrzeigersinn
|
||||||
|
let ang = Math.atan2(dx, -dy);
|
||||||
|
if (ang < 0) ang += Math.PI * 2;
|
||||||
|
if (ang <= fillFraction * Math.PI * 2) hits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const a = Math.round((hits / (SS * SS)) * 255);
|
||||||
|
return [color[0], color[1], color[2], a];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
const orange = [217, 89, 38]; // Slot 2 der Palette
|
||||||
|
|
||||||
|
for (const size of [16, 32]) {
|
||||||
|
const file = path.join(OUT_DIR, `tray${size === 16 ? '' : `@${size}`}.png`);
|
||||||
|
fs.writeFileSync(file, makePng(size, ringShader(size, orange)));
|
||||||
|
console.log(`geschrieben: ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fenster- und Anwendungssymbol
|
||||||
|
const file = path.join(OUT_DIR, 'icon.png');
|
||||||
|
fs.writeFileSync(file, makePng(256, ringShader(256, orange)));
|
||||||
|
console.log(`geschrieben: ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user