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:
co-authored by
Claude Opus 4.8
parent
c1b899ec8b
commit
e149a9118f
@@ -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'),
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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)} %`);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user