From 47288c0479b17ab88dd0496a7f08e950c1c21661 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:52:21 +0200 Subject: [PATCH] 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. --- app/server.js | 5 ++ app/sysstat.js | 105 +++++++++++++++++++++++++++++++++++++++ app/web/src/main.js | 112 ++++++++++++++++++++++++++++++++++++++++++ app/web/src/style.css | 51 +++++++++++++++++++ docs/07-betrieb.md | 29 ++++++++++- docs/08-changelog.md | 33 +++++++++++++ 6 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 app/sysstat.js 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