From db50c9616d854d2bc475d2a6e2a86d7ff65d0502 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 22:03:17 +0200 Subject: [PATCH] fix(gui): Ventilator-Icon und Speicheranzeige auf Aufnahme-Vorrat Das erste Icon las sich als Kleeblatt. Jetzt vier gebogene Pinwheel-Fluegel in einem feststehenden Gehaeusering; nur die Fluegel drehen. Form vorab ohne Browser geprueft (lab/preview_fan.py rendert die Bezier-Kandidaten als PNG). Die Prozentzahl misst nicht mehr die Partition, sondern den Aufnahme-Vorrat: 100 % = freier Platz + alle Audiodateien unter recordings/, belegt = diese Dateien. System, Modelle und node_modules fallen aus Zaehler und Nenner heraus; der df-Wert steht als Fusszeile in der Klappkarte. sysstat.js scannt recordings/ per readdir+stat (kein du-Prozess) und puffert 60 s; status() ist dadurch async, die Route laeuft ueber wrap(). Doku: Kapitel 07 + Changelog. --- app/lab/preview_fan.py | 72 +++++++++++++++++++++++++++++++++++++++ app/server.js | 6 ++-- app/sysstat.js | 76 +++++++++++++++++++++++++++++++++--------- app/web/src/main.js | 35 ++++++++++++------- app/web/src/style.css | 19 +++++++---- docs/07-betrieb.md | 27 +++++++++++---- docs/08-changelog.md | 23 +++++++++++++ 7 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 app/lab/preview_fan.py diff --git a/app/lab/preview_fan.py b/app/lab/preview_fan.py new file mode 100644 index 0000000..ff42c67 --- /dev/null +++ b/app/lab/preview_fan.py @@ -0,0 +1,72 @@ +# Vorschau der Ventilator-Kandidaten ohne Browser: Bezier-Segmente abtasten, +# rotieren, mit Pillow fuellen. Rechts jeweils die 22-px-Miniatur (Originalgroesse). +import math +from PIL import Image, ImageDraw + +BG, FG, CASE = "#1d222a", "#e8804c", "#6f5344" + + +def bez(p0, p1, p2, p3, n=80): + for i in range(n + 1): + t = i / n + u = 1 - t + yield (u**3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t**3 * p3[0], + u**3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t**3 * p3[1]) + + +def outline(segs): + pts = [] + for s in segs: + pts.extend(bez(*s)) + return pts + + +def rot(p, deg, c=12.0): + a = math.radians(deg) + x, y = p[0] - c, p[1] - c + return (c + x * math.cos(a) - y * math.sin(a), c + x * math.sin(a) + y * math.cos(a)) + + +def draw(segs, blades, case_r, hub_r, size): + S = size / 24 + img = Image.new("RGB", (size, size), BG) + d = ImageDraw.Draw(img) + if case_r: + d.ellipse([(12 - case_r) * S, (12 - case_r) * S, (12 + case_r) * S, (12 + case_r) * S], + outline=CASE, width=max(1, int(1.4 * S))) + pts = outline(segs) + step = 360 / blades + for i in range(blades): + d.polygon([(x * S, y * S) for x, y in (rot(p, i * step) for p in pts)], fill=FG) + d.ellipse([(12 - hub_r) * S, (12 - hub_r) * S, (12 + hub_r) * S, (12 + hub_r) * S], + fill=BG, outline=FG, width=max(1, int(1.5 * S))) + return img + + +CANDS = { + "A_komma4": (4, [((12, 12), (11.4, 8.6), (11.4, 5.4), (12.7, 3.1)), + ((12.7, 3.1), (15.6, 4.2), (18.2, 6.6), (18.9, 9.4)), + ((18.9, 9.4), (17.2, 11.2), (14.6, 12), (12, 12))]), + "B_schlank4": (4, [((12, 12), (10.6, 9), (10.4, 5.6), (12.2, 3.4)), + ((12.2, 3.4), (14.8, 4.6), (17.2, 7.2), (17.6, 10)), + ((17.6, 10), (16, 11.6), (13.8, 12.2), (12, 12))]), + "C_breit3": (3, [((12, 12), (10.9, 8.4), (11.2, 4.8), (13.3, 2.9)), + ((13.3, 2.9), (16.6, 4.4), (18.8, 7.4), (19.2, 10.6)), + ((19.2, 10.6), (17, 12), (14.2, 12.4), (12, 12))]), + "D_sichel5": (5, [((12, 12), (11.2, 9), (11.3, 5.8), (12.9, 3.6)), + ((12.9, 3.6), (15.1, 5), (16.9, 7.4), (17.3, 10.1)), + ((17.3, 10.1), (15.8, 11.4), (13.9, 12.1), (12, 12))]), +} + +pad = 12 +big, small = 200, 22 +W = pad + len(CANDS) * (big + pad) +sheet = Image.new("RGB", (W, big + small + 3 * pad), BG) +dr = ImageDraw.Draw(sheet) +for i, (name, (blades, segs)) in enumerate(CANDS.items()): + x = pad + i * (big + pad) + sheet.paste(draw(segs, blades, 10.4, 2.1, big), (x, pad)) + sheet.paste(draw(segs, blades, 10.4, 2.1, small), (x + big // 2 - small // 2, big + 2 * pad)) + dr.text((x + 4, big + 2 * pad + small + 2), name, fill="#98a2b3") +sheet.save("fan_preview.png") +print("ok") diff --git a/app/server.js b/app/server.js index 694586a..66400d3 100644 --- a/app/server.js +++ b/app/server.js @@ -377,9 +377,9 @@ 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())); +// Kopfzeilen-Anzeige: Platte, CPU, RAM, Temperatur. /proc + statfs, dazu ein +// gepufferter Größenscan über recordings/ — kein df/du-Prozess. +app.get('/api/sysstat', wrap(async (req, res) => res.json(await sysstat.status()))); // --- Bluetooth --- app.post('/api/bt/scan', wrap(async (req, res) => { diff --git a/app/sysstat.js b/app/sysstat.js index 6094a95..ce709d2 100644 --- a/app/sysstat.js +++ b/app/sysstat.js @@ -78,28 +78,74 @@ function readMem() { } 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() { +// ---- Aufnahmen ---------------------------------------------------------- +// Summe aller Audiodateien unter recordings/. Der Scan ist billig (readdir + +// stat, keine Lesevorgänge), wird aber gepuffert, damit ein Poll im +// Sekundentakt nicht dauernd über das Verzeichnis läuft. +const AUDIO_EXT = /\.(mp3|wav|m4a|opus|ogg|flac|aac)$/i; +const REC_SCAN_TTL_MS = 60_000; +let recCache = { bytes: 0, files: 0, at: 0 }; +let recScan = null; + +async function scanRecordings() { + let bytes = 0, files = 0; 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; } + const entries = await fs.promises.readdir(DISK_PATH, { withFileTypes: true, recursive: true }); + for (const e of entries) { + if (!e.isFile() || !AUDIO_EXT.test(e.name)) continue; + try { + const st = await fs.promises.stat(path.join(e.parentPath || e.path || DISK_PATH, e.name)); + bytes += st.size; files++; + } catch { /* zwischenzeitlich gelöscht */ } + } + } catch { return recCache; } + recCache = { bytes, files, at: Date.now() }; + return recCache; } -export function status() { +function recordingsSize() { + const fresh = Date.now() - recCache.at < REC_SCAN_TTL_MS; + if (!fresh && !recScan) recScan = scanRecordings().finally(() => { recScan = null; }); + // Beim allerersten Aufruf gibt es noch keine Zahl — dann auf den Scan warten, + // sonst zeigt die Anzeige direkt nach dem Start einmal 0 % Belegung. + return recCache.at === 0 ? recScan : recCache; +} + +// ---- Platte ------------------------------------------------------------- +// Zwei Sichtweisen auf denselben Datenträger: +// partition: was df sagt (inklusive System, Modelle, node_modules) +// budget: der Platz, um den es dem Nutzer geht — freier Platz PLUS die +// Aufnahmen, die schon daliegen. Das ist genau das, was ein frisch +// aufgesetztes System dieser Ausbaustufe für Aufnahmen übrig +// hätte. Die Prozentzahl im Ring = Aufnahmen / Budget. +async function readDisk() { + let st; + try { st = fs.statfsSync(fs.existsSync(DISK_PATH) ? DISK_PATH : __dirname); } catch { return null; } + const bs = st.bsize; + const totalBytes = st.blocks * bs; + const freeBytes = st.bavail * bs; + const usedBytes = (st.blocks - st.bfree) * bs; + const partDenom = usedBytes + freeBytes; + + const rec = await recordingsSize(); + const recBytes = rec ? rec.bytes : 0; + const budgetBytes = freeBytes + recBytes; + + return { + totalBytes, freeBytes, usedBytes, + partPct: partDenom > 0 ? (usedBytes / partDenom) * 100 : 0, + recBytes, recFiles: rec ? rec.files : 0, + budgetBytes, + usedPct: budgetBytes > 0 ? (recBytes / budgetBytes) * 100 : 0 + }; +} + +export async 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(), + disk: await 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 aa8dcb8..201af35 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -83,8 +83,11 @@ function render() { // 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(''); +// Ventilator: vier gebogene Flügel im feststehenden Gehäusering. Nur die +// Flügelgruppe dreht sich, der Ring bleibt stehen — sonst sähe man die +// Drehung am runden Gehäuse gar nicht. +const FAN_BLADES = [0, 90, 180, 270].map(a => + ``).join(''); function sysmonMarkup() { return ` @@ -96,16 +99,20 @@ function sysmonMarkup() { –
-
Speicherplatz
-
Belegt–
-
Frei–
-
Gesamt–
+
Speicher für Aufnahmen
+
Aufnahmen–
+
+
Noch frei–
+
Gesamt für Aufnahmen–
+
100 % = freier Platz + alle vorhandenen Aufnahmen
(also der Platz eines frisch aufgesetzten Systems)
+
Partition gesamt–
-
Systemlast
@@ -142,15 +149,19 @@ function paintSysmon(s) { } if (s.disk) { + // Prozent = Aufnahmen / (freier Platz + Aufnahmen). Das System selbst zählt + // bewusst nicht mit: gezeigt wird, wie viel vom Aufnahme-Vorrat weg ist. const p = Math.round(s.disk.usedPct); disk.className = 'sys-chip ' + sysLevel(p); - disk.title = `Speicher ${p} % belegt`; + disk.title = `Aufnahmen belegen ${p} % des verfügbaren Platzes`; 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)); + const db = document.querySelector('[data-disk-bar]'); if (db) db.style.width = Math.min(100, s.disk.usedPct) + '%'; + set('[data-disk-used]', `${sysGb(s.disk.recBytes)}${s.disk.recFiles ? ` (${s.disk.recFiles} Dateien)` : ''}`); set('[data-disk-free]', sysGb(s.disk.freeBytes)); - set('[data-disk-total]', sysGb(s.disk.totalBytes)); + set('[data-disk-total]', sysGb(s.disk.budgetBytes)); + set('[data-disk-part]', `${sysGb(s.disk.totalBytes)} — ${Math.round(s.disk.partPct)} % belegt`); } const cpu = s.cpu ? s.cpu.pct : 0; diff --git a/app/web/src/style.css b/app/web/src/style.css index 6e1b027..c5e48c4 100644 --- a/app/web/src/style.css +++ b/app/web/src/style.css @@ -148,18 +148,20 @@ nav { display: flex; gap: 6px; flex-wrap: wrap; } .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; } +/* Ventilator: nur die Flügel drehen, das Gehäuse steht (sonst wäre an einem + runden Ring keine Drehung zu sehen). Drehdauer kommt als --spin aus main.js. */ +.fan { width: 22px; height: 22px; flex: none; } +.fan-case { fill: none; stroke: currentColor; stroke-width: 1.4; opacity: .45; } +.fan-blades { fill: currentColor; transform-origin: 12px 12px; animation: tbspin var(--spin, 7s) linear infinite; } +.fan-hub { fill: var(--surface); stroke: currentColor; stroke-width: 1.5; } @keyframes tbspin { to { transform: rotate(360deg); } } -.sys-chip.off .tb-blades { animation: none; } -@media (prefers-reduced-motion: reduce) { .tb-blades { animation: none; } } +.sys-chip.off .fan-blades { animation: none; } +@media (prefers-reduced-motion: reduce) { .fan-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); + min-width: 215px; max-width: 300px; 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); } @@ -171,6 +173,9 @@ nav { display: flex; gap: 6px; flex-wrap: wrap; } .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-note { font-size: 11px; line-height: 1.35; color: var(--muted); margin: 6px 0 4px; } +.sys-row.sys-sub { border-top: 1px solid var(--line); margin-top: 4px; padding-top: 5px; font-size: 12px; } +.sys-row.sys-sub b { font-weight: 500; color: var(--muted); } .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); } diff --git a/docs/07-betrieb.md b/docs/07-betrieb.md index f5b1a2b..2b83350 100644 --- a/docs/07-betrieb.md +++ b/docs/07-betrieb.md @@ -88,11 +88,24 @@ 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 | +| Ring + Prozentzahl | Anteil des **Aufnahme-Vorrats**, den die Aufnahmen schon belegen | Aufnahmen / Noch frei / Gesamt in GB, dazu die Partition als Fußzeile | +| Ventilator | 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. +**Bezugsgröße der Prozentzahl** (Wunsch des Nutzers, 2026-08-17): nicht die Partition, sondern der Platz, +den ein *frisch aufgesetztes System dieser Ausbaustufe* für Aufnahmen übrig hätte: + +``` +100 % = freier Platz + Summe aller Audiodateien unter recordings/ +belegt = Summe aller Audiodateien unter recordings/ +``` + +System, Modelle und `node_modules` zählen also weder oben noch unten mit — die Anzeige beantwortet +„wie viel von meinem Aufnahme-Vorrat ist weg?“ und nicht „wie voll ist die eMMC?“. Der `df`-Wert steht +weiterhin klein in der Fußzeile der Klappkarte (`partPct`). + +- Datenquelle: `GET /api/sysstat` (`app/sysstat.js`). `/proc`, `fs.statfsSync` und ein **gepufferter** + Größenscan über `recordings/` (`readdir` rekursiv + `stat`, TTL 60 s, nur Audio-Endungen) — **kein** + `df`/`du`/`top`-Prozess, damit die GUI im Sekundentakt fragen darf. - 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 %. @@ -102,10 +115,12 @@ Boards ohne SSH zeigen: 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 +- Drehzahl des Ventilators 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 +- Icon: vier gebogene Flügel (Pinwheel) im **feststehenden** Gehäusering — nur die Flügelgruppe dreht + sich, sonst wäre an einem runden Ring keine Drehung zu sehen. +- Farbe: grün < 75 %, gelb ab 75 %, rot ab 90 %; beim Ventilator zählt der schlimmste Wert aus CPU, RAM und Temperatur (Temperatur skaliert 45 °C → 0 %, 90 °C → 100 %). ## Deploy-Ablauf (von Windows aus) diff --git a/docs/08-changelog.md b/docs/08-changelog.md index 35521fb..485d9a4 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,29 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-08-17 (Nachtrag) — Ventilator statt Turbine, Speicher relativ zum Aufnahme-Vorrat + +**Zwei Korrekturen des Nutzers am selben Tag:** + +1. **Icon soll wie ein Ventilator aussehen.** Der erste Entwurf (fünf schmale Tropfen ohne Gehäuse) las + sich als Kleeblatt. Jetzt: vier gebogene Pinwheel-Flügel in einem feststehenden Gehäusering; nur die + Flügelgruppe dreht. Die Form wurde vor dem Ausrollen ohne Browser geprüft — die Bézier-Segmente der + Kandidaten wurden mit einem kleinen Python/Pillow-Skript abgetastet, rotiert und als PNG gerendert + (`lab/preview_fan.py`), weil die Chrome-Anbindung an diesem Tag nicht verfügbar war. +2. **Prozentzahl soll den Aufnahme-Vorrat messen, nicht die Partition.** Neue Bezugsgröße: + `100 % = freier Platz + Summe aller Audiodateien unter recordings/`, belegt = die Audiodateien. + Das ist genau der Platz, den ein frisch aufgesetztes System dieser Ausbaustufe für Aufnahmen hätte; + System, Modelle und `node_modules` fallen aus Zähler **und** Nenner heraus. Der `df`-Wert bleibt als + Fußzeile („Partition gesamt“) in der Klappkarte. + +**Umsetzung:** `sysstat.js` scannt `recordings/` mit rekursivem `readdir` + `stat` (nur Audio-Endungen, +kein `du`-Prozess) und puffert das Ergebnis 60 s; nur der allererste Aufruf wartet auf den Scan, sonst +zeigte die Anzeige direkt nach dem Start einmal 0 %. Neue Felder `recBytes`, `recFiles`, `budgetBytes`, +`partPct`; `usedPct` = `recBytes / budgetBytes`. `status()` ist dadurch `async` → die Route läuft jetzt +über `wrap()`. GUI: Balken + Datei-Anzahl in der Karte, erklärende Fußnote, Fußzeile mit dem +Partitionswert. Gegenprobe: Scan meldet 1 957 445 371 Bytes, `du -sb` sagt exakt dasselbe (103 Dateien, +52 mp3 + 51 wav) ⇒ 1,96 GB von 29,6 GB Vorrat = **6,6 %** (Partition dagegen 52 %). + ## 2026-08-17 — Systemanzeige in der Kopfzeile: Speicherring + Turbine **Wunsch:** zwischen dem Bluetooth-Tab und dem Hell/Dunkel-Schalter eine Anzeige, die den belegten