diff --git a/app/server.js b/app/server.js
index ae3b7bb..694586a 100644
--- a/app/server.js
+++ b/app/server.js
@@ -13,6 +13,7 @@ import * as spk from './spk.js';
import * as search from './search.js';
import * as emb from './emb.js';
import * as gate from './gate.js';
+import * as sysstat from './sysstat.js';
const pexec = promisify(execFile);
// 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() }));
+// 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 ---
app.post('/api/bt/scan', wrap(async (req, res) => {
const secs = Math.min(+(req.body && req.body.seconds) || 10, 30);
diff --git a/app/sysstat.js b/app/sysstat.js
new file mode 100644
index 0000000..6094a95
--- /dev/null
+++ b/app/sysstat.js
@@ -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
+ };
+}
diff --git a/app/web/src/main.js b/app/web/src/main.js
index 54930a0..aa8dcb8 100644
--- a/app/web/src/main.js
+++ b/app/web/src/main.js
@@ -55,6 +55,7 @@ function render() {
${['recordings', 'projects', 'search', 'voices', 'glossary', 'bluetooth'].map(t =>
``).join('')}
+ ${sysmonMarkup()}
`;
@@ -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 =>
+ ``).join('');
+
+function sysmonMarkup() {
+ return `
+
+
+
+
–
+
+
Speicherplatz
+
Belegt–
+
Frei–
+
Gesamt–
+
+
+
+
+
+
Systemlast
+
CPU–
+
+
RAM–
+
+
Temperatur–
+
+
+
`;
+}
+
+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.
diff --git a/app/web/src/style.css b/app/web/src/style.css
index 59c37c5..6e1b027 100644
--- a/app/web/src/style.css
+++ b/app/web/src/style.css
@@ -127,6 +127,57 @@ nav { display: flex; gap: 6px; flex-wrap: wrap; }
.tab.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.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 ---- */
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
diff --git a/docs/07-betrieb.md b/docs/07-betrieb.md
index 0cbfa64..f5b1a2b 100644
--- a/docs/07-betrieb.md
+++ b/docs/07-betrieb.md
@@ -74,13 +74,40 @@ tune2fs -O ^orphan_file,^metadata_csum_seed /dev/mmcblk0p1 && e2fsck -fy /dev/mm
| 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/aime-*.sh` | Setup-/Bridge-/Cap-Skripte |
| `/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`) |
| `/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)
```mermaid
diff --git a/docs/08-changelog.md b/docs/08-changelog.md
index 2b55764..35521fb 100644
--- a/docs/08-changelog.md
+++ b/docs/08-changelog.md
@@ -3,6 +3,39 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
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: `` (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
**Wunsch:** nach dem Beenden einer Aufnahme die Möglichkeit haben, Kontext zum gerade geführten