feat(gui): Systemanzeige in der Kopfzeile (Speicherring + Turbine)

Zwei Symbole zwischen Bluetooth-Tab und Hell/Dunkel-Schalter: ein Ring mit
der Plattenbelegung in Prozent und eine Turbine, deren Drehzahl der CPU-Last
folgt. Beide klappen bei Mauskontakt eine Karte mit Zahlen auf (belegt/frei/
gesamt bzw. CPU, RAM, Temperatur).

Neues Modul sysstat.js liest alles aus /proc bzw. fs.statfsSync, damit die
GUI im Sekundentakt fragen kann, ohne df/top-Prozesse zu starten. CPU-% aus
zwei /proc/stat-Messpunkten mit eigenem 2-s-Ticker, RAM ueber MemAvailable,
Temperatur als waermste SoC-/Core-Zone.

Doku: Kapitel 07 + Changelog.
This commit is contained in:
Claude
2026-08-17 21:52:21 +02:00
parent 0f530b575a
commit 47288c0479
6 changed files with 334 additions and 1 deletions
+112
View File
@@ -55,6 +55,7 @@ function render() {
${['recordings', 'projects', 'search', 'voices', 'glossary', 'bluetooth'].map(t =>
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', projects: 'Projektansicht', search: 'Suche', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
</nav>
${sysmonMarkup()}
<button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button>
</header>
<main id="view"></main>`;
@@ -68,11 +69,122 @@ function render() {
app.querySelector('#themetoggle').onclick = () =>
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
app.querySelector('#recbtn').onclick = toggleRecord;
paintSysmon(sysmon.last); // Werte sofort zurückmalen, render() leert das DOM
startSysmon();
bindMarkerInput();
if (state.recording) startTimerUI();
({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab]();
}
// ---- Systemanzeige in der Kopfzeile -------------------------------------
// Zwei Symbole zwischen den Tabs und dem Hell/Dunkel-Schalter: ein Ring mit der
// Plattenbelegung in Prozent und eine Turbine, die sich um so schneller dreht,
// je höher die CPU-Last ist. Bei Mauskontakt klappt jeweils eine Karte mit den
// Zahlen auf (CPU, RAM, Temperatur bzw. belegt/frei/gesamt).
const sysmon = { timer: null, last: null };
const TURBINE_BLADES = [0, 72, 144, 216, 288].map(a =>
`<path transform="rotate(${a} 12 12)" d="M12 12 C10.1 8.7 9.9 5.2 12 2.3 C14.1 5.2 13.9 8.7 12 12 Z"/>`).join('');
function sysmonMarkup() {
return `
<div class="sysmon">
<div class="sys-chip" id="sysdisk" tabindex="0" role="group" aria-label="Speicherbelegung">
<svg class="ring" viewBox="0 0 36 36" aria-hidden="true">
<circle class="ring-bg" cx="18" cy="18" r="15.9155"/>
<circle class="ring-fg" cx="18" cy="18" r="15.9155" stroke-dasharray="0 100" data-disk-ring/>
</svg>
<span class="sys-val mono" data-disk-pct>–</span>
<div class="sys-pop">
<div class="sys-pop-h">Speicherplatz</div>
<div class="sys-row"><span>Belegt</span><b data-disk-used>–</b></div>
<div class="sys-row"><span>Frei</span><b data-disk-free>–</b></div>
<div class="sys-row"><span>Gesamt</span><b data-disk-total>–</b></div>
</div>
</div>
<div class="sys-chip" id="sysload" tabindex="0" role="group" aria-label="Systemlast">
<svg class="turbine" viewBox="0 0 24 24" aria-hidden="true" data-turbine>
<g class="tb-blades">${TURBINE_BLADES}</g>
<circle class="tb-hub" cx="12" cy="12" r="2.2"/>
</svg>
<div class="sys-pop">
<div class="sys-pop-h">Systemlast</div>
<div class="sys-row"><span>CPU</span><b data-cpu-pct>–</b></div>
<div class="sys-bar"><i data-cpu-bar style="width:0%"></i></div>
<div class="sys-row"><span>RAM</span><b data-ram-pct>–</b></div>
<div class="sys-bar"><i data-ram-bar style="width:0%"></i></div>
<div class="sys-row"><span>Temperatur</span><b data-temp>–</b></div>
</div>
</div>
</div>`;
}
function sysLevel(pct) { return pct >= 90 ? 'danger' : pct >= 75 ? 'warn' : 'ok'; }
function sysGb(bytes) { return (bytes / 1073741824).toFixed(1).replace('.', ',') + ' GB'; }
// Drehzahl in Stufen statt stufenlos: ein geänderter animation-duration setzt
// die laufende Drehung neu an, das ruckt. Grobe Stufen = seltener Sprung.
function spinSeconds(cpuPct) {
const steps = [[5, 7], [15, 4.5], [30, 3], [45, 2], [60, 1.3], [75, .85], [90, .55]];
for (const [limit, sec] of steps) if (cpuPct < limit) return sec;
return .35;
}
function paintSysmon(s) {
const disk = document.querySelector('#sysdisk');
const load = document.querySelector('#sysload');
if (!disk || !load) return;
const set = (sel, txt) => { const el = document.querySelector(sel); if (el) el.textContent = txt; };
if (!s) { // Server nicht erreichbar: Anzeige grau, Turbine steht
disk.className = 'sys-chip off'; load.className = 'sys-chip off';
return;
}
if (s.disk) {
const p = Math.round(s.disk.usedPct);
disk.className = 'sys-chip ' + sysLevel(p);
disk.title = `Speicher ${p} % belegt`;
set('[data-disk-pct]', p + '%');
const ring = document.querySelector('[data-disk-ring]');
if (ring) ring.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, s.disk.usedPct)).toFixed(1)} 100`);
set('[data-disk-used]', sysGb(s.disk.usedBytes));
set('[data-disk-free]', sysGb(s.disk.freeBytes));
set('[data-disk-total]', sysGb(s.disk.totalBytes));
}
const cpu = s.cpu ? s.cpu.pct : 0;
const ram = s.ram ? s.ram.usedPct : 0;
const tc = s.temp ? s.temp.c : null;
// Die Farbe der Turbine folgt dem schlimmsten der drei Werte; Temperatur ab
// 80 °C gelb, ab 90 °C rot (darüber drosselt der RK3588 sichtbar).
const worst = Math.max(cpu, ram, tc == null ? 0 : (tc - 45) / 45 * 100);
load.className = 'sys-chip ' + sysLevel(worst);
load.title = `CPU ${Math.round(cpu)} % · RAM ${Math.round(ram)} %${tc == null ? '' : ` · ${tc.toFixed(0)} °C`}`;
set('[data-cpu-pct]', `${cpu.toFixed(0)} %${s.cpu && s.cpu.cores ? ` (${s.cpu.cores} Kerne)` : ''}`);
set('[data-ram-pct]', s.ram ? `${ram.toFixed(0)} % — ${sysGb(s.ram.usedBytes)} von ${sysGb(s.ram.totalBytes)}` : '–');
set('[data-temp]', tc == null ? '–' : `${tc.toFixed(1)} °C${s.temp.zone ? ` (${s.temp.zone})` : ''}`);
const cb = document.querySelector('[data-cpu-bar]'); if (cb) cb.style.width = Math.min(100, cpu) + '%';
const rb = document.querySelector('[data-ram-bar]'); if (rb) rb.style.width = Math.min(100, ram) + '%';
const tb = document.querySelector('[data-turbine]');
if (tb) tb.style.setProperty('--spin', spinSeconds(cpu) + 's');
}
async function tickSysmon() {
if (document.hidden) return; // im Hintergrund kein Polling
try {
sysmon.last = await api.get('/api/sysstat');
} catch (err) { sysmon.last = null; }
paintSysmon(sysmon.last);
}
function startSysmon() {
if (sysmon.timer) return; // ein Ticker reicht für alle render()
tickSysmon();
sysmon.timer = setInterval(tickSysmon, 5000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) tickSysmon(); });
}
// Header record button: start = defaults only (default source = CallRecMix,
// codec auto, speaker count auto — the old options tab is gone on purpose).
// A live placeholder card appears at the top of the recordings list instantly.