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
+5
View File
@@ -13,6 +13,7 @@ import * as spk from './spk.js';
import * as search from './search.js'; import * as search from './search.js';
import * as emb from './emb.js'; import * as emb from './emb.js';
import * as gate from './gate.js'; import * as gate from './gate.js';
import * as sysstat from './sysstat.js';
const pexec = promisify(execFile); const pexec = promisify(execFile);
// Der Recorder ist die einzige Wahrheit darueber, ob gerade aufgenommen // Der Recorder ist die einzige Wahrheit darueber, ob gerade aufgenommen
@@ -376,6 +377,10 @@ const wrap = fn => (req, res) => fn(req, res).catch(e => res.status(500).json({
app.get('/api/health', (req, res) => res.json({ ok: true, ...rec.status() })); app.get('/api/health', (req, res) => res.json({ ok: true, ...rec.status() }));
// Kopfzeilen-Anzeige: Platte, CPU, RAM, Temperatur. Reine /proc-Lesevorgänge,
// deshalb ohne wrap() und ohne Warteschlange.
app.get('/api/sysstat', (req, res) => res.json(sysstat.status()));
// --- Bluetooth --- // --- Bluetooth ---
app.post('/api/bt/scan', wrap(async (req, res) => { app.post('/api/bt/scan', wrap(async (req, res) => {
const secs = Math.min(+(req.body && req.body.seconds) || 10, 30); const secs = Math.min(+(req.body && req.body.seconds) || 10, 30);
+105
View File
@@ -0,0 +1,105 @@
// Systemwerte für die Kopfzeile: Plattenbelegung, CPU-Last, RAM, Temperatur.
// Bewusst ohne Shell-Aufrufe (kein df/top) — alles aus /proc bzw. statfs, damit
// die GUI im Sekundentakt fragen darf, ohne Prozesse zu starten.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DISK_PATH = path.join(__dirname, 'recordings');
// ---- CPU ----------------------------------------------------------------
// Auslastung braucht zwei Messpunkte. Ein eigener Ticker hält den Wert bereit,
// sonst wäre die erste Antwort nach jedem Start immer 0 %.
let prevCpu = null;
let cpuPct = 0;
function sampleCpu() {
try {
const line = fs.readFileSync('/proc/stat', 'utf8').split('\n', 1)[0];
const v = line.trim().split(/\s+/).slice(1).map(Number).filter(n => Number.isFinite(n));
if (v.length < 5) return;
const idle = v[3] + v[4]; // idle + iowait
const total = v.reduce((a, b) => a + b, 0);
if (prevCpu) {
const dTotal = total - prevCpu.total;
const dIdle = idle - prevCpu.idle;
if (dTotal > 0) cpuPct = Math.max(0, Math.min(100, (1 - dIdle / dTotal) * 100));
}
prevCpu = { total, idle };
} catch { /* ohne /proc bleibt der letzte Wert stehen */ }
}
sampleCpu();
setInterval(sampleCpu, 2000).unref();
// ---- Temperatur ---------------------------------------------------------
// Interessant sind die SoC-/Kern-Zonen; WLAN-Karte & Co. bleiben draußen, sonst
// zeigt die Anzeige eine Temperatur, die nichts mit der Rechenlast zu tun hat.
const TEMP_ZONES = (() => {
try {
return fs.readdirSync('/sys/class/thermal')
.filter(d => /^thermal_zone\d+$/.test(d))
.map(d => {
const dir = `/sys/class/thermal/${d}`;
let type = d;
try { type = fs.readFileSync(`${dir}/type`, 'utf8').trim(); } catch { }
return { type, file: `${dir}/temp` };
})
.filter(z => /soc|core|center|npu|gpu/i.test(z.type));
} catch { return []; }
})();
function readTemp() {
let best = null;
for (const z of TEMP_ZONES) {
try {
const c = Number(fs.readFileSync(z.file, 'utf8').trim()) / 1000;
if (Number.isFinite(c) && (!best || c > best.c)) best = { c, zone: z.type };
} catch { }
}
return best;
}
// ---- RAM ----------------------------------------------------------------
// MemAvailable statt MemFree: Cache zählt nicht als belegt, sonst stünde das
// Board dauernd auf 95 %.
function readMem() {
try {
const txt = fs.readFileSync('/proc/meminfo', 'utf8');
const kb = k => {
const m = txt.match(new RegExp(`^${k}:\\s+(\\d+) kB`, 'm'));
return m ? Number(m[1]) * 1024 : null;
};
const total = kb('MemTotal');
const avail = kb('MemAvailable') ?? kb('MemFree');
if (!total || avail == null) return null;
return { totalBytes: total, freeBytes: avail, usedBytes: total - avail, usedPct: (1 - avail / total) * 100 };
} catch { return null; }
}
// ---- Platte -------------------------------------------------------------
// Gemessen wird die Partition, auf der die Aufnahmen liegen — das ist der
// Platz, der der App tatsächlich ausgeht. Die Prozentzahl folgt df: der für
// root reservierte Anteil zählt als belegt.
function readDisk() {
try {
const st = fs.statfsSync(fs.existsSync(DISK_PATH) ? DISK_PATH : __dirname);
const bs = st.bsize;
const totalBytes = st.blocks * bs;
const freeBytes = st.bavail * bs;
const usedBytes = (st.blocks - st.bfree) * bs;
const denom = usedBytes + freeBytes;
return { totalBytes, freeBytes, usedBytes, usedPct: denom > 0 ? (usedBytes / denom) * 100 : 0 };
} catch { return null; }
}
export function status() {
const t = readTemp();
return {
cpu: { pct: Math.round(cpuPct * 10) / 10, cores: os.cpus().length, load1: Math.round(os.loadavg()[0] * 100) / 100 },
ram: readMem(),
disk: readDisk(),
temp: t ? { c: Math.round(t.c * 10) / 10, zone: t.zone } : null
};
}
+112
View File
@@ -55,6 +55,7 @@ function render() {
${['recordings', 'projects', 'search', 'voices', 'glossary', 'bluetooth'].map(t => ${['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('')} `<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> </nav>
${sysmonMarkup()}
<button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button> <button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button>
</header> </header>
<main id="view"></main>`; <main id="view"></main>`;
@@ -68,11 +69,122 @@ function render() {
app.querySelector('#themetoggle').onclick = () => app.querySelector('#themetoggle').onclick = () =>
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
app.querySelector('#recbtn').onclick = toggleRecord; app.querySelector('#recbtn').onclick = toggleRecord;
paintSysmon(sysmon.last); // Werte sofort zurückmalen, render() leert das DOM
startSysmon();
bindMarkerInput(); bindMarkerInput();
if (state.recording) startTimerUI(); if (state.recording) startTimerUI();
({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab](); ({ 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, // Header record button: start = defaults only (default source = CallRecMix,
// codec auto, speaker count auto — the old options tab is gone on purpose). // 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. // A live placeholder card appears at the top of the recordings list instantly.
+51
View File
@@ -127,6 +127,57 @@ nav { display: flex; gap: 6px; flex-wrap: wrap; }
.tab.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; } .tab.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.btn.icon.theme { border-radius: 999px; font-size: 15px; line-height: 1; } .btn.icon.theme { border-radius: 999px; font-size: 15px; line-height: 1; }
/* ---- Systemanzeige: Plattenring + Turbine (zwischen Tabs und Theme-Schalter) ---- */
.sysmon { display: flex; align-items: center; gap: 6px; }
.sys-chip {
position: relative; display: inline-flex; align-items: center; gap: 6px;
padding: 4px 9px 4px 6px; border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); color: var(--muted); cursor: default; outline: none;
transition: border-color .15s, background .15s, color .15s;
}
.sys-chip.ok { color: var(--ok); }
.sys-chip.warn { color: var(--warn); background: var(--warn-soft); border-color: var(--warn); }
.sys-chip.danger { color: var(--danger); background: var(--danger-soft); border-color: var(--danger); }
.sys-chip.off { opacity: .55; }
.sys-chip:hover, .sys-chip:focus-visible { border-color: currentColor; }
.sys-val { font-size: 12px; font-variant-numeric: tabular-nums; color: var(--fg); }
/* Plattenbelegung als Ring: r = 15.9155 ⇒ Umfang 100, dasharray = Prozent */
.ring { width: 20px; height: 20px; flex: none; transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 4; }
.ring-bg { stroke: var(--line); }
.ring-fg { stroke: currentColor; stroke-linecap: round; transition: stroke-dasharray .4s ease; }
/* Turbine: Drehdauer kommt als --spin aus main.js (je mehr Last, desto kürzer) */
.turbine { width: 21px; height: 21px; flex: none; }
.tb-blades { fill: currentColor; transform-origin: 12px 12px; animation: tbspin var(--spin, 7s) linear infinite; }
.tb-hub { fill: var(--surface); stroke: currentColor; stroke-width: 1.4; }
@keyframes tbspin { to { transform: rotate(360deg); } }
.sys-chip.off .tb-blades { animation: none; }
@media (prefers-reduced-motion: reduce) { .tb-blades { animation: none; } }
/* Klappkarte bei Mauskontakt (oder Tastaturfokus) */
.sys-pop {
display: none; position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
min-width: 215px; padding: 10px 12px; color: var(--fg);
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
}
.sys-chip:hover .sys-pop, .sys-chip:focus .sys-pop, .sys-chip:focus-within .sys-pop { display: block; }
.sys-pop-h {
font-size: 11px; font-weight: 700; color: var(--muted);
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 7px;
}
.sys-row { display: flex; justify-content: space-between; gap: 14px; font-size: 13px; padding: 2px 0; }
.sys-row span { color: var(--muted); }
.sys-row b { font-weight: 600; font-variant-numeric: tabular-nums; }
.sys-bar { height: 5px; border-radius: 3px; background: var(--inset); overflow: hidden; margin: 3px 0 7px; }
.sys-bar i { display: block; height: 100%; background: var(--accent); transition: width .4s ease; }
.sys-chip.ok .sys-bar i { background: var(--ok); }
.sys-chip.warn .sys-bar i { background: var(--warn); }
.sys-chip.danger .sys-bar i { background: var(--danger); }
@media (max-width: 900px) { .sys-val { display: none; } }
/* ---- Bausteine ---- */ /* ---- Bausteine ---- */
.card { .card {
background: var(--surface); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
+28 -1
View File
@@ -74,13 +74,40 @@ tune2fs -O ^orphan_file,^metadata_csum_seed /dev/mmcblk0p1 && e2fsck -fy /dev/mm
| Pfad | Inhalt | | Pfad | Inhalt |
|---|---| |---|---|
| `/opt/callrec/*.js` | server / recorder / llm / diar / spk / db / bt | | `/opt/callrec/*.js` | server / recorder / llm / diar / spk / db / bt / sysstat |
| `/opt/callrec/web/` | Vite-Frontend (`npx vite build` nach Änderungen an `src/`) | | `/opt/callrec/web/` | Vite-Frontend (`npx vite build` nach Änderungen an `src/`) |
| `/opt/callrec/aime-*.sh` | Setup-/Bridge-/Cap-Skripte | | `/opt/callrec/aime-*.sh` | Setup-/Bridge-/Cap-Skripte |
| `/opt/callrec/milestones/m1-20260709/` | Meilenstein-1-Snapshot inkl. MANIFEST.md (Rollback) | | `/opt/callrec/milestones/m1-20260709/` | Meilenstein-1-Snapshot inkl. MANIFEST.md (Rollback) |
| `/opt/callrec/*.bak-*` | Datei-Backups je Änderung (z. B. `.bak-structsum`, `.bak-anchor`) | | `/opt/callrec/*.bak-*` | Datei-Backups je Änderung (z. B. `.bak-structsum`, `.bak-anchor`) |
| `/etc/sudoers.d/callrec-cpucap` | NOPASSWD für `aime-cpucap.sh` (Thermal-Cap) | | `/etc/sudoers.d/callrec-cpucap` | NOPASSWD für `aime-cpucap.sh` (Thermal-Cap) |
## Systemanzeige in der Kopfzeile (seit 2026-08-17)
Zwischen dem Tab „Bluetooth“ und dem Hell/Dunkel-Schalter stehen zwei Symbole, die den Zustand des
Boards ohne SSH zeigen:
| Symbol | Zeigt an | Klappkarte bei Mauskontakt |
|---|---|---|
| Ring + Prozentzahl | Belegung der Partition mit den Aufnahmen (`/`, eMMC) | Belegt / Frei / Gesamt in GB |
| Turbine | CPU-Last — je höher, desto schneller die Drehung | CPU % (+ Kernzahl), RAM % mit GB, Temperatur mit Zone |
- Datenquelle: `GET /api/sysstat` (`app/sysstat.js`). Alles aus `/proc` bzw. `fs.statfsSync` — **kein**
`df`/`top`-Aufruf, damit die GUI im Sekundentakt fragen darf, ohne Prozesse zu starten.
- CPU-% aus der Differenz zweier `/proc/stat`-Messpunkte. `sysstat.js` misst dafür **selbst** alle 2 s
(`setInterval(...).unref()`); ohne diesen eigenen Ticker wäre die erste Antwort nach jedem Start 0 %.
- RAM rechnet mit `MemAvailable`, nicht `MemFree` — sonst stünde das Board wegen Cache dauernd bei 95 %.
- Temperatur = wärmste der SoC-/Core-/Center-/GPU-/NPU-Zonen; `iwlwifi_1` (WLAN-Karte) fliegt raus, weil
ihr Wert nichts über die Rechenlast sagt.
- Frontend: `sysmonMarkup()` / `paintSysmon()` / `tickSysmon()` in `web/src/main.js`, Poll alle 5 s, im
Hintergrund-Tab (`document.hidden`) ausgesetzt. Ein einziger Ticker für alle `render()`-Durchläufe;
nach jedem `render()` werden die zuletzt geholten Werte sofort zurückgemalt, weil `render()` die
Kopfzeile komplett neu schreibt.
- Drehzahl der Turbine läuft in **Stufen** (`spinSeconds()`: 7 s bei Leerlauf … 0,35 s ab 90 %). Eine
stufenlos gesetzte `animation-duration` würde die laufende Drehung bei jedem Poll neu ansetzen — das
ruckt sichtbar. `prefers-reduced-motion` stoppt die Animation ganz.
- Farbe: grün < 75 %, gelb ab 75 %, rot ab 90 %; bei der Turbine zählt der schlimmste Wert aus CPU, RAM
und Temperatur (Temperatur skaliert 45 °C → 0 %, 90 °C → 100 %).
## Deploy-Ablauf (von Windows aus) ## Deploy-Ablauf (von Windows aus)
```mermaid ```mermaid
+33
View File
@@ -3,6 +3,39 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups). Was, Warum, betroffene Dateien/Backups).
## 2026-08-17 — Systemanzeige in der Kopfzeile: Speicherring + Turbine
**Wunsch:** zwischen dem Bluetooth-Tab und dem Hell/Dunkel-Schalter eine Anzeige, die den belegten
Speicherplatz in Prozent zeigt, dazu ein zweites Symbol, das bei Mauskontakt CPU-Last, RAM und
Temperatur aufklappt — als Turbine, die sich mit steigender Last schneller dreht.
**Umsetzung:**
- Neues Modul `sysstat.js` mit `status()`: Platte über `fs.statfsSync` (gemessen wird die Partition mit
den Aufnahmen, Prozentwert wie `df` inklusive root-Reserve), RAM aus `/proc/meminfo`
(`MemAvailable`, nicht `MemFree`), CPU-% aus der Differenz zweier `/proc/stat`-Messpunkte, Temperatur
als wärmste SoC-/Core-/Center-/GPU-/NPU-Zone (WLAN-Zone `iwlwifi_1` ausgeschlossen).
Eigener 2-s-Ticker (`unref()`), sonst wäre die erste CPU-Antwort nach jedem Start 0 %.
- `GET /api/sysstat` in `server.js` — ohne `wrap()`/Warteschlange, weil es reine `/proc`-Lesevorgänge
sind und die Abfrage keinen Prozess startet.
- GUI (`web/src/main.js`): `sysmonMarkup()` zeichnet beide Chips, `paintSysmon()` schreibt die Werte ins
DOM, `tickSysmon()` pollt alle 5 s (im Hintergrund-Tab ausgesetzt). Ein einziger Ticker für alle
`render()`-Läufe; direkt nach `render()` werden die letzten Werte zurückgemalt, weil `render()` die
Kopfzeile komplett neu schreibt und die Chips sonst bis zum nächsten Poll leer stünden.
- Turbine: fünf Flügel als SVG-Pfade, Drehdauer per CSS-Variable `--spin`. Die Dauer läuft bewusst in
**Stufen** (7 s Leerlauf … 0,35 s ab 90 %) — eine stufenlos nachgeführte `animation-duration` setzt
die laufende Drehung bei jedem Poll neu an und ruckt sichtbar. `prefers-reduced-motion` stoppt sie.
- Speicherring: `<circle r="15.9155">` (Umfang exakt 100) ⇒ `stroke-dasharray` = Prozentwert.
- Ampel: grün < 75 %, gelb ab 75 %, rot ab 90 %; bei der Turbine zählt der schlimmste Wert aus CPU, RAM
und Temperatur (45 °C → 0 %, 90 °C → 100 %).
- Karten öffnen rein über CSS (`.sys-chip:hover .sys-pop`, zusätzlich `:focus-within` für Tastatur) —
kein Handler, der einen `render()`-Durchlauf überleben müsste.
**Dateien:** neu `app/sysstat.js`; geändert `app/server.js` (`.bak-sysmon`), `app/web/src/main.js`
(`.bak-sysmon`), `app/web/src/style.css` (`.bak-sysmon`). Vite-Build + `systemctl restart callrec`
(vorher geprüft: keine Analyse `pending`). Verifiziert: `/api/sysstat` liefert 52,4 % Platte
(`df` sagt 53 %), 46 °C, 8 Kerne. Doku: Kapitel 07.
## 2026-08-17 — Kontext zum Gespräch: ein Freitextfeld nach dem Stop ## 2026-08-17 — Kontext zum Gespräch: ein Freitextfeld nach dem Stop
**Wunsch:** nach dem Beenden einer Aufnahme die Möglichkeit haben, Kontext zum gerade geführten **Wunsch:** nach dem Beenden einer Aufnahme die Möglichkeit haben, Kontext zum gerade geführten