7 Commits
Author SHA1 Message Date
Winkler, Stefan 011e5b6816 Version 1.2.1
Claude-Session: https://claude.ai/code/session_01VeqoaAZDVSCU4QBtiYDC5C
2026-09-01 09:59:42 +02:00
Winkler, Stefan a01f4266ea Fehlerhafte Prozent-Skalierung bei Server-Wochen-/Blockwert beheben
Eine veraltete "<=1 -> *100"-Heuristik verfälschte einen echten
Serverwert von 1% zu 100%, sobald der Prozentwert zufällig im Bereich
0-1 lag. Der Endpunkt liefert bereits einen echten 0-100-Prozentwert,
_rememberServerLimit() ging schon immer korrekt davon aus.

Claude-Session: https://claude.ai/code/session_01VeqoaAZDVSCU4QBtiYDC5C
2026-09-01 09:55:43 +02:00
Winkler, Stefan 39f19ab261 Sichtbare Rückmeldung für automatische Kalibrierung ergaenzen
Der erste automatische Kalibrierungsschritt einer Sitzung lief bisher
lautlos ab. Jetzt erscheint dafür einmalig pro Woche/Block ein Eintrag
im Ereignisprotokoll.

Claude-Session: https://claude.ai/code/session_01VeqoaAZDVSCU4QBtiYDC5C
2026-09-01 09:44:28 +02:00
Winkler, Stefan 489b15860f Version 1.2.0 2026-08-20 20:01:17 +02:00
Winkler, Stefan bb311e76c2 Update-Pruefung gegen Gitea-Releases ergaenzen
Die App prueft ca. 60 s nach dem Start einmalig, ob auf dem Gitea-Server
ein neueres Release vorliegt. Bei einem Treffer erscheint eine
Systembenachrichtigung, und ein Symbol im Titelbalken bleibt sichtbar,
bis aktualisiert wird -- Klick auf beides oeffnet die Release-Seite im
Browser. Ueber die neue Einstellung "Nach Updates suchen" abschaltbar
(Standard: aktiv).
2026-08-20 19:59:06 +02:00
Winkler, Stefan e413a36506 Manuellen OAuth-Token-Refresh im Endpunkt-Status-Fenster ergaenzen
Ein Button "Zugriffstoken erneuern" tauscht das Refresh-Token aus
.credentials.json aktiv gegen ein neues Zugriffstoken und schreibt es in
dieselbe Datei zurueck, die auch Claude Code verwendet -- statt nur auf
den naechsten Start von Claude Code zu warten. Nur auf expliziten Klick,
nie automatisch im Hintergrund; Token wird dabei nie geloggt.
2026-08-20 19:30:01 +02:00
Winkler, Stefan 628a269a14 MIT-Lizenztext ergaenzen 2026-08-06 12:31:02 +02:00
22 changed files with 565 additions and 16 deletions
+34 -4
View File
@@ -74,6 +74,7 @@ Position wird gemerkt.
| Element | Bedeutung | | Element | Bedeutung |
|---|---| |---|---|
| Statuspunkt | Zeigt den Zustand der Datenquellen, siehe unten | | Statuspunkt | Zeigt den Zustand der Datenquellen, siehe unten |
| Pfeil-Symbol (nur bei neuem Release) | Öffnet die Release-Seite im Browser |
| Zahnrad | Öffnet die Einstellungen | | Zahnrad | Öffnet die Einstellungen |
| `` | Blendet das Fenster aus (Rückkehr über das Tray-Symbol) | | `` | Blendet das Fenster aus (Rückkehr über das Tray-Symbol) |
@@ -121,12 +122,19 @@ Das Zahnrad öffnet den Einstellungsdialog:
der Anmeldung. der Anmeldung.
- **Serverabfrage** — kann abgeschaltet werden, falls die Anzeige rein lokal - **Serverabfrage** — kann abgeschaltet werden, falls die Anzeige rein lokal
arbeiten soll (dann sind alle Werte Näherungen statt exakter Zahlen). arbeiten soll (dann sind alle Werte Näherungen statt exakter Zahlen).
- **Nach Updates suchen** — prüft rund eine Minute nach dem Start einmalig,
ob auf dem Projekt-Server eine neuere Version vorliegt. Falls ja,
erscheint eine Systembenachrichtigung, und ein Pfeil-Symbol im
Titelbalken bleibt sichtbar, bis aktualisiert wird. Klick auf
Benachrichtigung oder Symbol öffnet die Release-Seite im Browser. Diese
Einstellung wirkt erst beim nächsten Start.
- **Bezugsgrößen, Gewichtung, Wochenfenster** — fortgeschrittene Feineinstellungen - **Bezugsgrößen, Gewichtung, Wochenfenster** — fortgeschrittene Feineinstellungen
für den Fall, dass die Serverabfrage dauerhaft nicht zur Verfügung steht; für den Fall, dass die Serverabfrage dauerhaft nicht zur Verfügung steht;
im Normalbetrieb müssen Sie hier nichts anpassen. im Normalbetrieb müssen Sie hier nichts anpassen.
Änderungen wirken sofort, ein Neustart ist nicht nötig. `Strg`+`S` speichert, Änderungen wirken sofort, ein Neustart ist nicht nötig (Ausnahme: „Nach
`Esc` schließt den Dialog ohne zu speichern. Updates suchen", siehe oben). `Strg`+`S` speichert, `Esc` schließt den
Dialog ohne zu speichern.
## Tray-Menü ## Tray-Menü
@@ -147,6 +155,17 @@ und Beenden.
- Diese Serverabfrage lässt sich in den Einstellungen jederzeit abschalten; - Diese Serverabfrage lässt sich in den Einstellungen jederzeit abschalten;
die Anwendung funktioniert dann weiter, nur mit geschätzten statt exakten die Anwendung funktioniert dann weiter, nur mit geschätzten statt exakten
Werten. Werten.
- Über den Knopf „Zugriffstoken erneuern" im Endpunkt-Status-Fenster lässt
sich das Zugriffstoken bei Bedarf manuell erneuern. Dabei wird das
Refresh-Token aus derselben Anmeldedatei gegen ein neues Zugriffstoken
eingetauscht und das Ergebnis dorthin zurückgeschrieben — Claude Code
selbst profitiert automatisch davon. Das geschieht ausschließlich auf
Klick, nie automatisch im Hintergrund, und auch dabei wird kein Token
protokolliert.
- Ist „Nach Updates suchen" aktiviert, fragt die Anwendung rund eine Minute
nach dem Start einmalig den selbst gehosteten Gitea-Server
(`git.conatum.net`) nach der neuesten Versionsnummer ab — ohne
Zugriffstoken und ohne weitere Daten. Abschaltbar in den Einstellungen.
- Es findet keine Übertragung an Dritte statt. - Es findet keine Übertragung an Dritte statt.
## Deinstallation ## Deinstallation
@@ -170,8 +189,10 @@ gelesen werden. Ein Klick auf den Punkt zeigt im Log den genauen Grund.
**Der Statuspunkt ist gelb.** Nur die optionale Serverabfrage ist gerade **Der Statuspunkt ist gelb.** Nur die optionale Serverabfrage ist gerade
gestört, meist vorübergehend (z. B. Netzwerkproblem oder ein abgelaufenes gestört, meist vorübergehend (z. B. Netzwerkproblem oder ein abgelaufenes
Zugriffstoken, das Claude Code beim nächsten eigenen Start erneuert). Die Zugriffstoken). Ein Klick auf „Zugriffstoken erneuern" im
Anzeige selbst funktioniert währenddessen weiter. Endpunkt-Status-Fenster erneuert es sofort; andernfalls übernimmt das Claude
Code beim nächsten eigenen Start. Die Anzeige selbst funktioniert
währenddessen weiter.
**Windows warnt beim ersten Start.** Normal bei unsignierter Software, siehe **Windows warnt beim ersten Start.** Normal bei unsignierter Software, siehe
Abschnitt „Installation“ oben. Abschnitt „Installation“ oben.
@@ -179,3 +200,12 @@ Abschnitt „Installation“ oben.
**Nach einem Update sehe ich eine Benachrichtigung.** Das ist beabsichtigt: **Nach einem Update sehe ich eine Benachrichtigung.** Das ist beabsichtigt:
Die Anwendung erkennt neue Versionen automatisch und weist einmalig darauf Die Anwendung erkennt neue Versionen automatisch und weist einmalig darauf
hin. Ihre Einstellungen und Kalibrierung bleiben davon unberührt. hin. Ihre Einstellungen und Kalibrierung bleiben davon unberührt.
**Ich bekomme eine Benachrichtigung über eine neue Version / ein
Pfeil-Symbol erscheint im Titelbalken.** Das ist eine andere Meldung als
oben: Die Anwendung hat rund eine Minute nach dem Start auf dem
Projekt-Server ein neueres Release gefunden. Ein Klick auf Benachrichtigung
oder Symbol öffnet die Release-Seite im Browser — heruntergeladen oder
installiert wird nichts automatisch. Das Symbol bleibt sichtbar, bis eine
neuere Version installiert wurde. Abschaltbar über die Einstellung „Nach
Updates suchen".
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stefan Winkler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+26 -3
View File
@@ -140,9 +140,15 @@ Prozentwerte, Schweregrade und Reset-Zeitpunkte **exakt** — sekundengenau und
ohne Schätzung. ohne Schätzung.
Das Zugriffstoken wird aus `~/.claude/.credentials.json` gelesen, nie Das Zugriffstoken wird aus `~/.claude/.credentials.json` gelesen, nie
gespeichert, nie protokolliert und nur an diesen Host gesendet. Es gibt bewusst protokolliert und nur an den konfigurierten Anthropic-Host gesendet. Läuft es
keinen eigenen Refresh-Flow: Läuft das Token ab, pausiert die Abfrage, bis ab, pausiert diese Abfrage, bis ein neues Token vorliegt — entweder
Claude Code es erneuert hat. Nach drei Fehlversuchen schaltet sie sich für die automatisch durch Claude Code beim nächsten eigenen Start, oder sofort per
Klick auf „Zugriffstoken erneuern" im Endpunkt-Status-Fenster. Dieser
manuelle Refresh tauscht das Refresh-Token gegen ein neues Zugriffstoken bei
Anthropics OAuth-Endpunkt und schreibt das Ergebnis in dieselbe Datei zurück,
die auch Claude Code verwendet — die CLI profitiert automatisch mit. Auch
dabei wird kein Token protokolliert, nur Erfolg oder Fehlschlag als Text.
Nach drei Fehlversuchen der automatischen Abfrage schaltet sie sich für die
Sitzung ab. Sitzung ab.
Abschalten über `config.json`: Abschalten über `config.json`:
@@ -169,6 +175,10 @@ Fenster bekannt — daraus folgt die Bezugsgröße unmittelbar. Auch das
Wochenfenster wird aus dem gemeldeten Reset-Zeitpunkt abgeleitet, nicht Wochenfenster wird aus dem gemeldeten Reset-Zeitpunkt abgeleitet, nicht
geraten. geraten.
Der erste automatische Kalibrierungsschritt einer Sitzung erscheint als
Eintrag im Ereignisprotokoll ("Automatisch kalibriert …") — spätere
Aktualisierungen laufen bewusst lautlos im Hintergrund weiter.
Die folgenden Abschnitte betreffen nur den Fall, dass die Abfrage **nicht** Die folgenden Abschnitte betreffen nur den Fall, dass die Abfrage **nicht**
verfügbar ist. verfügbar ist.
@@ -270,6 +280,19 @@ npm run dist
`npm version` legt standardmäßig auch einen Git-Commit und -Tag an; ohne das `npm version` legt standardmäßig auch einen Git-Commit und -Tag an; ohne das
genügt es, `version` in `package.json` von Hand zu ändern. genügt es, `version` in `package.json` von Hand zu ändern.
### Update-Prüfung beim Gitea-Server
Zusätzlich zur lokalen Erkennung oben prüft die Anwendung ca. 60 s nach dem
Start einmalig aktiv, ob auf dem selbst gehosteten Gitea-Server
(`git.conatum.net`, Repo `swinks/claude-live-dashboard`) ein neueres Release
vorliegt (`GET .../releases/latest`, öffentlich ohne Authentifizierung). Bei
einer neueren Version erscheint eine native Systembenachrichtigung, und ein
Symbol im Titelbalken (zwischen Endpunkt-Status und Zahnrad) bleibt
sichtbar, bis aktualisiert wird. Ein Klick auf Benachrichtigung oder Symbol
öffnet die Release-Seite im Standardbrowser. Über die Einstellung „Nach
Updates suchen" lässt sich dieser Netzwerkaufruf beim Start abschalten
(`config.json:updateCheck.enabled`, Standard `true`).
## Release bauen ## Release bauen
Gebaut wird jeweils auf dem Zielsystem — electron-builder kann `.deb` nicht Gebaut wird jeweils auf dem Zielsystem — electron-builder kann `.deb` nicht
+4
View File
@@ -26,5 +26,9 @@
"_oauth": "Serverabfrage für exakte Werte. Standardmäßig aktiv; mit enabled:false abschalten. endpoint nur setzen, um den Standard zu überschreiben.", "_oauth": "Serverabfrage für exakte Werte. Standardmäßig aktiv; mit enabled:false abschalten. endpoint nur setzen, um den Standard zu überschreiben.",
"oauth": { "oauth": {
"enabled": true "enabled": true
},
"_updateCheck": "Prüft ca. 60 s nach dem Start einmalig beim Gitea-Server auf ein neueres Release. Standardmäßig aktiv; mit enabled:false abschalten.",
"updateCheck": {
"enabled": true
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "claude-live-dashboard", "name": "claude-live-dashboard",
"version": "1.0.0", "version": "1.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "claude-live-dashboard", "name": "claude-live-dashboard",
"version": "1.0.0", "version": "1.2.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"electron": "^43.2.0", "electron": "^43.2.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "claude-live-dashboard", "name": "claude-live-dashboard",
"productName": "Claude Live Dashboard", "productName": "Claude Live Dashboard",
"version": "1.1.0", "version": "1.2.1",
"private": true, "private": true,
"description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows und Linux", "description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows und Linux",
"main": "src/main/index.js", "main": "src/main/index.js",
+2
View File
@@ -15,4 +15,6 @@ contextBridge.exposeInMainWorld('errorsBridge', {
clear: () => ipcRenderer.send('errors:clear'), clear: () => ipcRenderer.send('errors:clear'),
/** Schließt das Fenster. */ /** Schließt das Fenster. */
close: () => ipcRenderer.send('errors:close'), close: () => ipcRenderer.send('errors:close'),
/** Stößt den manuellen OAuth-Token-Refresh an. @returns {Promise<{ok: boolean, error?: string}>} */
refreshToken: () => ipcRenderer.invoke('oauth:refresh'),
}); });
+26
View File
@@ -10,6 +10,8 @@ const $ = (id) => document.getElementById(id);
const el = { const el = {
close: $('btn-close'), close: $('btn-close'),
clear: $('btn-clear'), clear: $('btn-clear'),
refresh: $('btn-refresh-token'),
feedback: $('feedback'),
statusLocal: $('status-local'), statusLocal: $('status-local'),
statusOauth: $('status-oauth'), statusOauth: $('status-oauth'),
logList: $('log-list'), logList: $('log-list'),
@@ -21,8 +23,22 @@ const SOURCE_LABEL = {
local: 'Lokal', local: 'Lokal',
oauth: 'Server', oauth: 'Server',
app: 'App', app: 'App',
refresh: 'Token',
update: 'Update',
}; };
let feedbackTimer = null;
/** Portiert aus settings/app.js say() für konsistentes Feedback-Verhalten. */
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);
}
function fmtTime(ts) { function fmtTime(ts) {
const d = new Date(ts); const d = new Date(ts);
const now = new Date(); const now = new Date();
@@ -72,6 +88,16 @@ function render(s) {
el.close.addEventListener('click', () => window.errorsBridge.close()); el.close.addEventListener('click', () => window.errorsBridge.close());
el.clear.addEventListener('click', () => window.errorsBridge.clear()); el.clear.addEventListener('click', () => window.errorsBridge.clear());
el.refresh.addEventListener('click', async () => {
el.refresh.disabled = true;
say('Zugriffstoken wird erneuert …');
try {
const result = await window.errorsBridge.refreshToken();
say(result.ok ? 'Zugriffstoken erneuert.' : `Fehler: ${result.error}`, result.ok ? 'ok' : 'error');
} finally {
el.refresh.disabled = false;
}
});
document.addEventListener('keydown', (ev) => { document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') window.errorsBridge.close(); if (ev.key === 'Escape') window.errorsBridge.close();
}); });
+2
View File
@@ -33,7 +33,9 @@
<footer class="actions"> <footer class="actions">
<button class="btn ghost" id="btn-clear">Log löschen</button> <button class="btn ghost" id="btn-clear">Log löschen</button>
<button class="btn ghost" id="btn-refresh-token">Zugriffstoken erneuern</button>
<span class="spacer"></span> <span class="spacer"></span>
<span class="feedback" id="feedback" hidden></span>
<span class="count" id="log-count"></span> <span class="count" id="log-count"></span>
</footer> </footer>
+20
View File
@@ -16,6 +16,7 @@
--good: #0ca30c; --good: #0ca30c;
--warning: #fab219; --warning: #fab219;
--critical: #d03b3b; --critical: #d03b3b;
--update: #9b6bd6;
--font: system-ui, -apple-system, "Segoe UI", sans-serif; --font: system-ui, -apple-system, "Segoe UI", sans-serif;
} }
@@ -224,6 +225,14 @@ dd[data-severity="critical"] .status-label {
color: var(--accent); color: var(--accent);
} }
.log-source[data-source="refresh"] {
color: var(--good);
}
.log-source[data-source="update"] {
color: var(--update);
}
.log-message { .log-message {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.4; line-height: 1.4;
@@ -245,6 +254,17 @@ dd[data-severity="critical"] .status-label {
padding: 11px 16px; padding: 11px 16px;
border-top: 1px solid var(--hairline); border-top: 1px solid var(--hairline);
flex: 0 0 auto; flex: 0 0 auto;
flex-wrap: wrap;
row-gap: 6px;
}
.feedback {
font-size: 11.5px;
color: var(--good);
}
.feedback[data-kind="error"] {
color: var(--critical);
} }
.btn { .btn {
+76 -3
View File
@@ -15,6 +15,7 @@ const { TranscriptReader, readSubscription, PROJECTS_DIR } = require('./jsonl');
const { UsageAggregator, MINUTE_MS } = require('./blocks'); const { UsageAggregator, MINUTE_MS } = require('./blocks');
const { LimitModel, weekAnchor, severity } = require('./limits'); const { LimitModel, weekAnchor, severity } = require('./limits');
const { OAuthUsage } = require('./oauth-usage'); const { OAuthUsage } = require('./oauth-usage');
const { OAuthRefresh } = require('./oauth-refresh');
/** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */ /** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */
const DEBOUNCE_MS = 300; const DEBOUNCE_MS = 300;
@@ -63,6 +64,10 @@ class Collector extends EventEmitter {
sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined, sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined,
}); });
this.oauth = new OAuthUsage(this.config.oauth || {}, store); this.oauth = new OAuthUsage(this.config.oauth || {}, store);
// Bewusst unabhängig von der Konfiguration und nicht in applyConfig()
// neu erzeugt — Cooldown/lastAttempt sollen Einstellungsänderungen
// überstehen, genau wie this.errors.
this.oauthRefresh = new OAuthRefresh(store);
this.subscription = { subscriptionType: null, rateLimitTier: null }; this.subscription = { subscriptionType: null, rateLimitTier: null };
this.limits = null; this.limits = null;
@@ -72,6 +77,15 @@ class Collector extends EventEmitter {
this.errors = Array.isArray(store.data.errors) ? store.data.errors : []; this.errors = Array.isArray(store.data.errors) ? store.data.errors : [];
this._localOk = true; this._localOk = true;
this._lastOauthError = null; this._lastOauthError = null;
// Für das Titelleisten-Symbol: bleibt für die gesamte Sitzung gesetzt,
// sobald ein neueres Release gefunden wurde — bewusst nicht gedrosselt
// wie die Notification (store.data.updateCheck.notifiedTag), das Symbol
// soll sichtbar bleiben, bis aktualisiert wird.
this._updateAvailable = null;
// Merkt sich pro Woche/Block, ob in dieser Sitzung bereits eine
// Kalibrierung aus einer Serverantwort übernommen wurde — für eine
// einmalige Rückmeldung im Ereignisprotokoll, siehe _rememberServerLimit().
this._calibratedOnce = { week: false, block: false };
if (configError) this._note(configError, 'local'); if (configError) this._note(configError, 'local');
if (versionEvent) { if (versionEvent) {
const msg = const msg =
@@ -184,6 +198,49 @@ class Collector extends EventEmitter {
this._recompute(); this._recompute();
} }
/**
* Manueller Token-Refresh — ausgelöst über den Knopf im
* Endpunkt-Status-Fenster. Schreibt bei Erfolg direkt in
* ~/.claude/.credentials.json (siehe oauth-refresh.js) und setzt danach die
* Serverabfrage zurück, damit sie das neue Token sofort nutzt.
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async refreshOauthToken() {
try {
const { expiresAt } = await this.oauthRefresh.refresh();
const until = new Date(expiresAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
// Zeitstempel im Text macht jede Erfolgsmeldung eindeutig — _note()
// dedupliziert sonst nach exaktem Nachrichtentext, ein zweiter
// erfolgreicher Refresh würde sonst stumm verschluckt.
this._note(`Zugriffstoken manuell erneuert (gültig bis ${until})`, 'refresh');
this.oauth.resetAfterTokenRefresh();
await this._recompute();
return { ok: true };
} catch (err) {
this._note(`Token-Erneuerung fehlgeschlagen: ${err.message}`, 'refresh');
await this._recompute();
return { ok: false, error: err.message };
}
}
/**
* Hält ein gefundenes neueres Release fest — im Ereignisprotokoll (einmalig
* pro Tag, dedupliziert über _note()) und als Live-State fürs
* Titelleisten-Symbol (state.update, bleibt für die Sitzung gesetzt).
* Ausgelöst einmalig ca. 60 s nach dem Start (siehe index.js/update-check.js).
*/
noteUpdateAvailable(tagName, htmlUrl) {
this._updateAvailable = { tagName, htmlUrl };
this._note(`Neue Version verfügbar: ${tagName} (${htmlUrl})`, 'update');
this._recompute().catch(() => {});
}
/** Hält einen gescheiterten Update-Check fest — stört den Betrieb nie, nur Protokoll. */
noteUpdateCheckError(message) {
this._note(`Update-Prüfung fehlgeschlagen: ${message}`, 'update');
this._recompute().catch(() => {});
}
async _poll() { async _poll() {
if (this._polling) { if (this._polling) {
this._pollAgain = true; this._pollAgain = true;
@@ -230,6 +287,12 @@ class Collector extends EventEmitter {
if (!previous || Math.abs(next - previous) / previous > 0.005) { if (!previous || Math.abs(next - previous) / previous > 0.005) {
store.serverLimits[kind] = Math.round(next); store.serverLimits[kind] = Math.round(next);
this.store.save(); this.store.save();
if (!this._calibratedOnce[kind]) {
this._calibratedOnce[kind] = true;
const label = kind === 'week' ? 'Woche' : 'Zeitfenster';
this._note(`Automatisch kalibriert (${label}) aus Serverantwort`, 'oauth');
}
} }
} }
@@ -267,13 +330,17 @@ class Collector extends EventEmitter {
const block = this.limits.evaluate('block', snapshot.block.weighted, server?.blockLimit); const block = this.limits.evaluate('block', snapshot.block.weighted, server?.blockLimit);
const week = this.limits.evaluate('week', snapshot.week.weighted, server?.weekLimit); const week = this.limits.evaluate('week', snapshot.week.weighted, server?.weekLimit);
// Ein exakter Prozentwert vom Server sticht die lokale Rechnung. // Ein exakter Prozentwert vom Server sticht die lokale Rechnung. Der
// Endpunkt liefert bereits einen echten Prozentwert (0100), nicht einen
// Bruch — eine frühere "<=1 → *100"-Heuristik verfälschte z. B. 1 % zu
// 100 %, siehe _rememberServerLimit() unten, die weekPercent schon immer
// konsistent als 0100-Wert behandelt.
if (server?.blockPercent != null) { if (server?.blockPercent != null) {
block.percent = server.blockPercent <= 1 ? server.blockPercent * 100 : server.blockPercent; block.percent = server.blockPercent;
block.confidence = 'exact'; block.confidence = 'exact';
} }
if (server?.weekPercent != null) { if (server?.weekPercent != null) {
week.percent = server.weekPercent <= 1 ? server.weekPercent * 100 : server.weekPercent; week.percent = server.weekPercent;
week.confidence = 'exact'; week.confidence = 'exact';
} }
@@ -381,6 +448,12 @@ class Collector extends EventEmitter {
local: { ok: this._localOk }, local: { ok: this._localOk },
oauth: { ok: !oauthError, text: this.oauth.status().text }, oauth: { ok: !oauthError, text: this.oauth.status().text },
}, },
// Titelleisten-Symbol: bleibt sichtbar, bis die Sitzung endet oder
// aktualisiert wird — bewusst kein „gesehen"-Zustand wie bei der
// Notification (store.data.updateCheck.notifiedTag).
update: this._updateAvailable
? { available: true, tagName: this._updateAvailable.tagName, htmlUrl: this._updateAvailable.htmlUrl }
: { available: false, tagName: null, htmlUrl: null },
errors: this.errors.slice(), errors: this.errors.slice(),
stats: { lastPollMs: this._lastPollMs }, stats: { lastPollMs: this._lastPollMs },
}; };
+52
View File
@@ -13,6 +13,7 @@ const { Store, loadConfig } = require('./store');
const { Collector } = require('./collector'); const { Collector } = require('./collector');
const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths'); const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths');
const { getAutostart, setAutostart: applyAutostart } = require('./autostart'); const { getAutostart, setAutostart: applyAutostart } = require('./autostart');
const { checkForUpdate } = require('./update-check');
const IS_LINUX = process.platform === 'linux'; const IS_LINUX = process.platform === 'linux';
@@ -29,6 +30,10 @@ const SETTINGS_HEIGHT = 660;
const ERRORS_WIDTH = 420; const ERRORS_WIDTH = 420;
const ERRORS_HEIGHT = 480; const ERRORS_HEIGHT = 480;
/** Wartezeit vor der einmaligen Update-Prüfung — verzögert, damit sie den
* Start nicht verlangsamt und nicht mit dem ersten Poll konkurriert. */
const UPDATE_CHECK_DELAY_MS = 60 * 1000;
let win = null; let win = null;
let settingsWin = null; let settingsWin = null;
let errorsWin = null; let errorsWin = null;
@@ -406,6 +411,14 @@ function registerIpc() {
ipcMain.on('open-error-log', openErrorLog); ipcMain.on('open-error-log', openErrorLog);
ipcMain.on('errors:clear', () => collector && collector.clearErrors()); ipcMain.on('errors:clear', () => collector && collector.clearErrors());
ipcMain.on('errors:close', () => errorsWin && errorsWin.close()); ipcMain.on('errors:close', () => errorsWin && errorsWin.close());
ipcMain.handle('oauth:refresh', () => {
if (!collector) return { ok: false, error: 'Anwendung startet noch' };
return collector.refreshOauthToken();
});
ipcMain.on('open-release-page', () => {
const info = collector?.state?.update;
if (info?.available && info.htmlUrl) shell.openExternal(info.htmlUrl);
});
ipcMain.handle('settings:load', () => ({ ipcMain.handle('settings:load', () => ({
config, config,
@@ -505,6 +518,45 @@ async function main() {
} catch (err) { } catch (err) {
console.error('[collector] Start fehlgeschlagen:', err); console.error('[collector] Start fehlgeschlagen:', err);
} }
if (config.updateCheck?.enabled !== false) {
const updateTimer = setTimeout(() => {
checkForUpdate(app.getVersion())
.then((result) => {
console.log(result ? `[update-check] neue Version: ${result.tagName}` : '[update-check] keine neuere Version verfügbar');
if (!result || !collector) return;
// Titelleisten-Symbol: sofort sichtbar, unabhängig von der
// Notification-Drossel unten.
collector.noteUpdateAvailable(result.tagName, result.htmlUrl);
// Notification nur einmal pro neu erschienenem Tag — sonst würde sie
// bei jedem Start erneut aufpoppen, solange der Nutzer nicht
// aktualisiert (die App läuft typischerweise per Autostart täglich).
const alreadyNotified = store.data.updateCheck?.notifiedTag === result.tagName;
store.data.updateCheck = { notifiedTag: result.tagName };
store.save();
if (alreadyNotified || !Notification.isSupported()) return;
const notification = new Notification({
title: 'Neue Version verfügbar',
body: `${result.tagName} steht bereit. Klicken, um die Release-Seite zu öffnen.`,
icon: path.join(ROOT, 'assets', 'icon.png'),
});
notification.on('click', () => shell.openExternal(result.htmlUrl));
notification.show();
})
.catch((err) => {
console.warn('[update-check] fehlgeschlagen:', err.message);
if (collector) collector.noteUpdateCheckError(err.message);
});
}, UPDATE_CHECK_DELAY_MS);
// Darf den App-Exit nie blockieren — anders als collector._timers wird
// dieser einmalige Main-Prozess-Timer nicht von collector.stop() erfasst.
if (updateTimer.unref) updateTimer.unref();
} else {
console.log('[update-check] übersprungen (in den Einstellungen deaktiviert)');
}
} }
// Das Widget lebt im Tray weiter, auch wenn kein Fenster offen ist. // Das Widget lebt im Tray weiter, auch wenn kein Fenster offen ist.
+162
View File
@@ -0,0 +1,162 @@
'use strict';
/**
* Manueller OAuth-Token-Refresh, ausgelöst über den Knopf im
* Endpunkt-Status-Fenster (src/errors/).
*
* Anders als oauth-usage.js (reine Leseabfrage) schreibt dieses Modul in
* ~/.claude/.credentials.json zurück — dieselbe Datei, die auch die
* Claude-Code-CLI verwendet. Ein neues Token landet dort, sodass Claude Code
* automatisch mitprofitiert, ohne eigenen Neustart.
*
* Auslöser ist ausschließlich ein bewusster Klick — kein automatischer,
* stiller Refresh im Hintergrund.
*
* WICHTIG — nicht verifizierte Annahmen: Endpunkt, Client-ID und der exakte
* Feldname des Refresh-Tokens in der Credentials-Datei stammen aus
* allgemeinem, öffentlich bekanntem Wissen über den OAuth-Client der
* Claude-Code-CLI, nicht aus einer im Projekt verifizierten Quelle. Bricht
* Anthropic das Schema, meldet dieses Modul einen klaren Fehler statt
* stillschweigend falsche Daten zu schreiben.
*
* Umgang mit dem Token: nie geloggt (weder das alte noch das neue), nur
* Erfolg/Fehlschlag als Text im Ereignisprotokoll.
*/
const fsp = require('node:fs/promises');
const { CREDENTIALS_FILE } = require('./jsonl');
/** Vermuteter Endpunkt — nicht dokumentiert, siehe Kopfkommentar. */
const TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';
/** Vermutete öffentliche Client-ID der Claude-Code-CLI. */
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const REQUEST_TIMEOUT_MS = 10000;
/** Sperrt Mehrfachklicks — ein manueller Refresh braucht keine höhere Frequenz. */
const MIN_INTERVAL_MS = 30 * 1000;
class OAuthRefresh {
/** @param {import('./store').Store} store */
constructor(store = null) {
this.store = store;
const persisted = (store && store.data && store.data.oauthRefresh) || {};
this.lastAttempt = persisted.lastAttempt || 0;
this.inFlight = false;
}
_persist() {
if (!this.store) return;
this.store.data.oauthRefresh = { lastAttempt: this.lastAttempt };
this.store.save();
}
/**
* Tauscht das Refresh-Token gegen ein neues Zugriffstoken und schreibt das
* Ergebnis zurück in .credentials.json. Wirft bei jedem Fehler eine
* verständliche deutsche Fehlermeldung — nie Header, nie Token.
* @returns {Promise<{expiresAt: number}>}
*/
async refresh(now = Date.now()) {
if (this.inFlight) throw new Error('Erneuerung läuft bereits');
if (now - this.lastAttempt < MIN_INTERVAL_MS) {
const waitS = Math.ceil((MIN_INTERVAL_MS - (now - this.lastAttempt)) / 1000);
throw new Error(`Bitte kurz warten (${waitS}s) — der letzte Versuch war gerade erst`);
}
this.inFlight = true;
this.lastAttempt = now;
this._persist();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const { raw, oauth, stat } = await this._readCredentials();
// Feldname nicht verifiziert — beide plausiblen Schreibweisen probieren.
const refreshToken = oauth.refreshToken || oauth.refresh_token;
if (!refreshToken) {
throw new Error('Kein Refresh-Token gefunden — bitte „claude login" erneut ausführen');
}
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!res.ok) {
// Body nie ungefiltert loggen/anzeigen — könnte token-ähnliche Daten enthalten.
if (res.status === 400 || res.status === 401) {
throw new Error('Anmeldung abgelehnt (invalid_grant) — bitte „claude login" erneut ausführen');
}
throw new Error(`Token-Endpunkt: HTTP ${res.status}`);
}
const json = await res.json();
const accessToken = json.access_token || json.accessToken;
const newRefreshToken = json.refresh_token || json.refreshToken || refreshToken;
const expiresAt =
json.expires_at ||
json.expiresAt ||
(Number.isFinite(json.expires_in) ? now + json.expires_in * 1000 : null);
if (!accessToken || !expiresAt) {
throw new Error('Unerwartete Antwort vom Token-Endpunkt — Schema weicht ab');
}
await this._writeCredentials(raw, stat, { accessToken, refreshToken: newRefreshToken, expiresAt });
return { expiresAt };
} catch (err) {
if (err.name === 'AbortError') throw new Error('Zeitüberschreitung beim Token-Endpunkt');
throw err;
} finally {
clearTimeout(timer);
this.inFlight = false;
}
}
async _readCredentials() {
let raw;
try {
raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') throw new Error('Anmeldedatei nicht gefunden — bitte „claude login" ausführen');
throw new Error(`Anmeldedatei nicht lesbar: ${err.message}`);
}
const stat = await fsp.stat(CREDENTIALS_FILE);
let data;
try {
data = JSON.parse(raw);
} catch {
throw new Error('.credentials.json ist beschädigt (kein gültiges JSON)');
}
return { raw, oauth: data.claudeAiOauth || {}, stat };
}
/**
* Schreibt nur accessToken/refreshToken/expiresAt innerhalb von
* claudeAiOauth — alle anderen, auch unbekannte Felder bleiben unangetastet.
* Atomar über temp file + rename, damit Claude Code nie einen halb
* geschriebenen Zustand sieht.
*/
async _writeCredentials(raw, stat, patch) {
const data = JSON.parse(raw);
data.claudeAiOauth = { ...(data.claudeAiOauth || {}), ...patch };
const tmp = `${CREDENTIALS_FILE}.tmp`;
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
try {
// Rechte der Originaldatei übernehmen (unter Linux typischerweise 600) —
// sonst fällt die neue Datei auf die Umask-Vorgabe zurück.
await fsp.chmod(tmp, stat.mode);
} catch {
// Unter Windows meist wirkungslos, kein Abbruchgrund.
}
await fsp.rename(tmp, CREDENTIALS_FILE);
}
}
module.exports = { OAuthRefresh, TOKEN_ENDPOINT, CLIENT_ID, MIN_INTERVAL_MS };
+23 -3
View File
@@ -20,8 +20,13 @@
* *
* Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie * Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie
* gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host * gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host
* gesendet. Ein eigener Refresh-Flow existiert bewusst nicht — Claude Code * gesendet. Diese Stufe erneuert das Token nicht selbst — bei Ablauf
* erneuert das Token selbst; bei Ablauf pausiert diese Stufe. * pausiert sie, bis ein neues vorliegt. Ein manueller Refresh ist möglich:
* über den Knopf "Zugriffstoken erneuern" im Endpunkt-Status-Fenster
* (siehe oauth-refresh.js), ausschließlich auf Klick, nie automatisch im
* Hintergrund. Nach einem erfolgreichen manuellen Refresh ruft der Collector
* resetAfterTokenRefresh() auf, damit diese Stufe sofort neu abfragt statt
* einen laufenden Cooldown abzuwarten.
*/ */
const fsp = require('node:fs/promises'); const fsp = require('node:fs/promises');
@@ -96,13 +101,28 @@ class OAuthUsage {
} }
} }
/**
* Setzt Fehlerzähler und Pause zurück, nachdem oauth-refresh.js extern ein
* neues Zugriffstoken geschrieben hat — die nächste Abfrage soll das sofort
* nutzen, statt einen laufenden Cooldown/MIN_INTERVAL_MS abzuwarten.
*/
resetAfterTokenRefresh() {
this.failures = 0;
this.pausedUntil = 0;
this.lastFetch = 0;
this.lastError = null;
this._persist();
}
/** Liest das aktuelle Zugriffstoken, sofern es noch gültig ist. */ /** Liest das aktuelle Zugriffstoken, sofern es noch gültig ist. */
async _token() { async _token() {
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8'); const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
const oauth = JSON.parse(raw).claudeAiOauth; const oauth = JSON.parse(raw).claudeAiOauth;
if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden'); if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden');
if (oauth.expiresAt && oauth.expiresAt < Date.now()) { if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
throw new Error('Zugriffstoken abgelaufen — Claude Code erneuert es beim nächsten Start'); throw new Error(
'Zugriffstoken abgelaufen — im Endpunkt-Status-Fenster erneuern oder Claude Code neu starten',
);
} }
return oauth.accessToken; return oauth.accessToken;
} }
+7
View File
@@ -23,11 +23,16 @@ const DEFAULT_STATE = {
calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} }, calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} },
window: { x: null, y: null, expanded: false }, window: { x: null, y: null, expanded: false },
oauth: { lastFetch: 0, pausedUntil: 0 }, oauth: { lastFetch: 0, pausedUntil: 0 },
// Sperrt Mehrfachklicks auf "Zugriffstoken erneuern" über Neustarts hinweg.
oauthRefresh: { lastAttempt: 0 },
// Zeitgestempeltes Ereignis-/Fehlerprotokoll (lokale Transkripte, // Zeitgestempeltes Ereignis-/Fehlerprotokoll (lokale Transkripte,
// Serverabfrage, App-Lebenszyklus) — überdauert bewusst Neustarts. // Serverabfrage, App-Lebenszyklus) — überdauert bewusst Neustarts.
errors: [], errors: [],
// Zuletzt gestartete App-Version, für die Neuinstallations-/Update-Erkennung. // Zuletzt gestartete App-Version, für die Neuinstallations-/Update-Erkennung.
appVersion: null, appVersion: null,
// Zuletzt per Notification gemeldeter Release-Tag — verhindert, dass
// dieselbe neue Version bei jedem Start erneut als Popup erscheint.
updateCheck: { notifiedTag: null },
}; };
class Store { class Store {
@@ -51,6 +56,8 @@ class Store {
calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) }, calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) },
window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) }, window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) },
oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) }, oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) },
oauthRefresh: { ...DEFAULT_STATE.oauthRefresh, ...(parsed.oauthRefresh || {}) },
updateCheck: { ...DEFAULT_STATE.updateCheck, ...(parsed.updateCheck || {}) },
errors: Array.isArray(parsed.errors) ? parsed.errors : [], errors: Array.isArray(parsed.errors) ? parsed.errors : [],
}; };
} catch (err) { } catch (err) {
+71
View File
@@ -0,0 +1,71 @@
'use strict';
/**
* Einmalige Update-Prüfung, ca. 60 s nach dem Start ausgelöst (siehe
* index.js). Fragt das öffentliche Gitea-API des eigenen Repos nach dem
* neuesten Release ab — kein Zugriffstoken nötig, der Endpunkt ist ohne
* Authentifizierung erreichbar (verifiziert per curl).
*
* Bewusst zustandslos und ohne Wiederholungslogik: Ein Check pro Sitzung
* genügt der Anforderung „einmalig prüfen". Persistenz (welches Release
* schon einmal per Notification gemeldet wurde) lebt bewusst außerhalb
* dieses Moduls, in index.js/store.js.
*/
const RELEASES_URL =
'https://git.conatum.net/api/v1/repos/swinks/claude-live-dashboard/releases/latest';
const ALLOWED_HOST = 'git.conatum.net';
const REQUEST_TIMEOUT_MS = 10000;
/**
* Vergleicht zwei MAJOR.MINOR.PATCH-Versionen ohne Suffixe.
* @returns {number} negativ wenn a<b, 0 wenn gleich, positiv wenn a>b
*/
function compareVersions(a, b) {
const pa = String(a).split('.').map(Number);
const pb = String(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
/**
* Fragt das neueste Release ab und vergleicht dessen Tag gegen currentVersion.
* Drafts und Pre-Releases werden ignoriert.
* @param {string} currentVersion z. B. app.getVersion() (ohne führendes "v")
* @returns {Promise<{tagName: string, htmlUrl: string} | null>}
*/
async function checkForUpdate(currentVersion) {
const url = new URL(RELEASES_URL);
if (url.protocol !== 'https:' || url.hostname !== ALLOWED_HOST) {
throw new Error('Gitea-Endpunkt zeigt nicht auf den erwarteten Host');
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(RELEASES_URL, {
method: 'GET',
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.draft || json.prerelease) return null;
const remoteVersion = String(json.tag_name || '').replace(/^v/, '').trim();
if (!remoteVersion) return null;
if (compareVersions(remoteVersion, currentVersion) <= 0) return null;
return { tagName: String(json.tag_name), htmlUrl: json.html_url };
} catch (err) {
throw new Error(err.name === 'AbortError' ? 'Zeitüberschreitung' : err.message);
} finally {
clearTimeout(timer);
}
}
module.exports = { checkForUpdate, compareVersions, RELEASES_URL };
+2
View File
@@ -24,4 +24,6 @@ contextBridge.exposeInMainWorld('dashboard', {
openSettings: () => ipcRenderer.send('open-settings'), openSettings: () => ipcRenderer.send('open-settings'),
/** Öffnet das Fehler-/Ereignis-Log. */ /** Öffnet das Fehler-/Ereignis-Log. */
openErrorLog: () => ipcRenderer.send('open-error-log'), openErrorLog: () => ipcRenderer.send('open-error-log'),
/** Öffnet die Release-Seite eines gefundenen neuen Updates im Browser. */
openReleasePage: () => ipcRenderer.send('open-release-page'),
}); });
+12
View File
@@ -47,6 +47,7 @@ const el = {
planLabel: $('plan-label'), planLabel: $('plan-label'),
staleBadge: $('stale-badge'), staleBadge: $('stale-badge'),
btnEndpointStatus: $('btn-endpoint-status'), btnEndpointStatus: $('btn-endpoint-status'),
btnUpdateAvailable: $('btn-update-available'),
btnHide: $('btn-hide'), btnHide: $('btn-hide'),
btnSettings: $('btn-settings'), btnSettings: $('btn-settings'),
btnExpand: $('btn-expand'), btnExpand: $('btn-expand'),
@@ -284,6 +285,15 @@ function renderEndpointStatus(s) {
`Serverabfrage: ${ep.oauth.text || '—'}`; `Serverabfrage: ${ep.oauth.text || '—'}`;
} }
/** Titelleisten-Symbol für ein gefundenes neues Release. */
function renderUpdateAvailable(s) {
const update = s.update || { available: false };
el.btnUpdateAvailable.hidden = !update.available;
if (update.available) {
el.btnUpdateAvailable.title = `Version ${update.tagName} verfügbar — Release-Seite öffnen`;
}
}
function renderWeek(week, burn) { function renderWeek(week, burn) {
renderMeter('week', week); renderMeter('week', week);
renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity); renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity);
@@ -397,6 +407,7 @@ function render(s) {
renderWeek(s.week, s.burn); renderWeek(s.week, s.burn);
renderSpend(s.spend); renderSpend(s.spend);
renderEndpointStatus(s); renderEndpointStatus(s);
renderUpdateAvailable(s);
if (expanded) { if (expanded) {
renderModels(s.block); renderModels(s.block);
@@ -462,6 +473,7 @@ el.btnExpand.addEventListener('click', () => applyExpanded(!expanded));
el.btnHide.addEventListener('click', () => window.dashboard.hide()); el.btnHide.addEventListener('click', () => window.dashboard.hide());
el.btnSettings.addEventListener('click', () => window.dashboard.openSettings()); el.btnSettings.addEventListener('click', () => window.dashboard.openSettings());
el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog()); el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog());
el.btnUpdateAvailable.addEventListener('click', () => window.dashboard.openReleasePage());
window.dashboard.onState(render); window.dashboard.onState(render);
+1
View File
@@ -17,6 +17,7 @@
<button class="icon-btn status" id="btn-endpoint-status" data-severity="ok" title="Endpunkt-Status" aria-label="Endpunkt-Status"> <button class="icon-btn status" id="btn-endpoint-status" data-severity="ok" title="Endpunkt-Status" aria-label="Endpunkt-Status">
<span class="status-icon" aria-hidden="true"></span> <span class="status-icon" aria-hidden="true"></span>
</button> </button>
<button class="icon-btn" id="btn-update-available" hidden title="Neues Release verfügbar" aria-label="Neues Release verfügbar"></button>
<button class="icon-btn" id="btn-settings" title="Einstellungen" aria-label="Einstellungen"> <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"> <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" focusable="false">
<path <path
+5
View File
@@ -28,6 +28,7 @@
--status-good: #0ca30c; --status-good: #0ca30c;
--status-warning: #fab219; --status-warning: #fab219;
--status-critical: #d03b3b; --status-critical: #d03b3b;
--status-update: #9b6bd6;
/* Kategoriale Slots (dunkle Stufen), fest je Modellfamilie vergeben */ /* Kategoriale Slots (dunkle Stufen), fest je Modellfamilie vergeben */
--series-1: #3987e5; --series-1: #3987e5;
@@ -152,6 +153,10 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
#btn-update-available {
color: var(--status-update);
}
/* ── Meter ───────────────────────────────────────────────────────────── */ /* ── Meter ───────────────────────────────────────────────────────────── */
.meters { .meters {
+5
View File
@@ -21,6 +21,7 @@ const el = {
clickThrough: $('clickThrough'), clickThrough: $('clickThrough'),
autostart: $('autostart'), autostart: $('autostart'),
autostartLabel: $('autostart-label'), autostartLabel: $('autostart-label'),
updateCheckEnabled: $('updateCheckEnabled'),
oauthEnabled: $('oauthEnabled'), oauthEnabled: $('oauthEnabled'),
oauthEndpoint: $('oauthEndpoint'), oauthEndpoint: $('oauthEndpoint'),
@@ -84,6 +85,9 @@ function fill(config, meta) {
el.autostartLabel.textContent = el.autostartLabel.textContent =
meta.platform === 'win32' ? 'Mit Windows starten' : 'Mit dem System starten'; meta.platform === 'win32' ? 'Mit Windows starten' : 'Mit dem System starten';
const updateCheck = config.updateCheck || {};
el.updateCheckEnabled.checked = updateCheck.enabled !== false;
const oauth = config.oauth || {}; const oauth = config.oauth || {};
el.oauthEnabled.checked = oauth.enabled !== false; el.oauthEnabled.checked = oauth.enabled !== false;
el.oauthEndpoint.value = oauth.endpoint || ''; el.oauthEndpoint.value = oauth.endpoint || '';
@@ -137,6 +141,7 @@ function collect() {
patch.opacity = Number(el.opacity.value) / 100; patch.opacity = Number(el.opacity.value) / 100;
patch.clickThrough = el.clickThrough.checked; patch.clickThrough = el.clickThrough.checked;
patch.updateCheck = { enabled: el.updateCheckEnabled.checked };
patch.oauth = { enabled: el.oauthEnabled.checked }; patch.oauth = { enabled: el.oauthEnabled.checked };
const endpoint = el.oauthEndpoint.value.trim(); const endpoint = el.oauthEndpoint.value.trim();
+11
View File
@@ -42,6 +42,17 @@
<span class="hint">Wird sofort übernommen, unabhängig vom Speichern.</span> <span class="hint">Wird sofort übernommen, unabhängig vom Speichern.</span>
</div> </div>
</div> </div>
<div class="row">
<label for="updateCheckEnabled">Nach Updates suchen</label>
<div class="control">
<input type="checkbox" id="updateCheckEnabled" />
<span class="hint">
Prüft rund eine Minute nach dem Start einmalig auf dem
Projekt-Server, ob eine neuere Version vorliegt.
</span>
</div>
</div>
</section> </section>
<section class="group"> <section class="group">