Endpunkt-Status-Icon mit Ereignis-Log und Update-Erkennung ergaenzen

Ein neues Status-Icon im Titelbalken zeigt den Zustand der lokalen
Transkripte und der Serverabfrage; ein Klick oeffnet ein eigenes Fenster
mit persistiertem, zeitgestempeltem Ereignis-/Fehlerprotokoll (loeschbar).
Ausserdem erkennt die App beim Start Erstinstallation vs. Update
(plattformunabhaengig fuer Windows und Linux) und meldet Updates per
nativer Benachrichtigung.

Dazu: Versionierungs-Policy dokumentiert (package.json vor jedem Release
erhoehen, sonst greift weder die Update-Erkennung noch bleiben alte
Release-Dateien in release/ erhalten), sowie zwei neue Dokumente fuer
Endanwender (BENUTZERHANDBUCH.md) und eine Download-Webseite
(PRODUKTSEITE.md).

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