Compare commits
23 Commits
c294bfddf2
..
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9225ed1bf9 | |||
| da0ee8256f | |||
| dd54ec5d42 | |||
| b10734f382 | |||
| dd09c08311 | |||
| 602a45c8b6 | |||
| 586da44fa1 | |||
| 377b6e37b2 | |||
| a217d606bb | |||
| 92bf1307f6 | |||
| a906c6705d | |||
| 8bfa4fc467 | |||
| 60b6973933 | |||
| 57a4196c4b | |||
| 0b659d67e8 | |||
| 7416a925a6 | |||
| 57c338fe53 | |||
| 0fa7ce0f44 | |||
| 3d266418fc | |||
| 61f95c8c52 | |||
| 7704372c3c | |||
| bf4384ad73 | |||
| b03ffb5d10 |
+7
-1
@@ -7,7 +7,7 @@ status: verified
|
||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||
last_updated: "2026-09-23T15:30:00.000Z"
|
||||
last_activity: 2026-09-23
|
||||
last_activity_desc: Quick 260923-le6 — letzte Abnahmebefunde zum Proxmox-Modul behoben (PMG-Teilsumme, Aktualisieren-Knopf nur fuer Admins); 1.3.1 laeuft auf live (Nutzer bestaetigt)
|
||||
last_activity_desc: Quick 260924-m4n — flackernder Test entschaerft, alte DashboardImage-Spalte entfernt (mit Schutzklausel); 1.4.0 vom Nutzer ausdruecklich NICHT freigegeben
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
total_phases: 18
|
||||
@@ -469,6 +469,12 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260923-ku6 | **Drei Befunde aus dem Proxmox-Rundgang behoben.** (1) „Verbindung testen“ pruefte den GESPEICHERTEN Stand statt der Eingabe — wer den Zugang tippt und vor dem Speichern testet, bekam die Antwort zum alten Wert; jetzt eigene Route `POST servers/test` mit Merge-Regel: normale Felder folgen dem Formular (auch geleert), Geheimnisfelder folgen „leer → gespeicherten Wert behalten“, weil das Formular Geheimnisse nie vorbefuellt. (2) Ein frisch angelegter Server zeigte „Ein unerwarteter Fehler ist aufgetreten“, obwohl nur noch nichts abgefragt war — jetzt eigener ruhiger Zustand mit Verweis auf „Jetzt aktualisieren“. (3) Die Klasse `uppercase` faerbte die ganze Zeile und zeigte die Adresse als „HTTPS://…“ — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, 53 Warnungen gehalten (eine neu ausgeloeste `useOptionalChain`-Warnung gleich mit aufgeloest). | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
||||
| 260923-ku6 | **Drei Nachbesserungen aus dem Browser-Rundgang zu 260923-dhh (Proxmox-Modul).** Befund 1 (wichtig): „Verbindung testen" pruefte den gespeicherten Server statt des Formulars — im Formular abgeschaltete Zertifikatspruefung oder ein neu eingetipptes Geheimnis griffen erst nach dem Speichern. Fix: neues `TestProxmoxServerDto` + Merge-Baustein `resolveEffectiveTestServer` in `ProxmoxService`, neue Route `POST servers/test` fuer die Neuanlage (noch kein gespeicherter Server), Geheimnisfelder behalten die bestehende „leer gelassen -> gespeicherten Wert weiterverwenden"-Regel. Befund 2 (wichtig): ein frisch angelegter, nie abgefragter Server zeigte faelschlich „Ein unerwarteter Fehler ist aufgetreten" statt eines ruhigen Hinweises — behoben ueber `status.lastPolledAt === null`. Befund 3 (kosmetisch): `uppercase` faerbte die ganze Statuszeile inkl. Adresse gross — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
||||
| 260923-le6 | **Zwei Abnahmebefunde zum Proxmox-Modul behoben.** (1) `sumOrNull` in `normalizePmg` liefert jetzt `null`, sobald EIN Teilwert (Spam/Viren je Richtung) fehlt — vorher stille Teilsumme als vollstaendige Zahl (Blocker aus 260923-dhh-VERIFICATION, Wahrheit 7). (2) „Jetzt aktualisieren“ nur noch fuer ADMIN/SUPER_ADMIN sichtbar (Endpunkt verlangte das schon); `ServerCard` bekommt `isAdmin`, Nicht-Admins lesen bei nie abgefragtem Server „Die Werte erscheinen nach der naechsten automatischen Abfrage“ statt eines Verweises auf den Knopf. Neuer Seitentest `proxmox-page-roles.test.tsx` (5 Rollenfaelle). Offener Randfall: inaktiver, nie abgefragter Server — Text passt dort nicht ganz, Nutzerentscheidung. Proxmox-Tests api 82, web 26 gruen; Typpruefung beider Seiten fehlerfrei; Biome ohne neue Befunde. | 2026-09-23 | c13d657,2eb86e1,2f8dd14,e1b191b | [260923-le6-proxmox-abnahmebefunde-sumornull-null-be](./quick/260923-le6-proxmox-abnahmebefunde-sumornull-null-be/) |
|
||||
| 260923-fst | **Favoriten-Kachel bis auf eine Spalte schmal ziehbar** (fast): `WIDGET_CONSTRAINTS.favorites.minW` 3 -> 1; Titel kuerzt, Symbol bleibt. Im Browser gezogen: 321 -> 47 px. | 2026-09-23 | b03ffb5 | — |
|
||||
| 260923-bug | **Fehler melden: Bildschirmfoto scheiterte an einem fremden Bild** (fast): `html-to-image` bricht die ganze Aufnahme ab, sobald ein `<img>` ohne CORS nicht nachladbar ist -> Haekchen gesperrt. Jetzt `imagePlaceholder` + `onImageErrorHandler`, zweiter Versuch ohne Bilder/Rahmen. Im echten Linux-Client 1.3.1 nachgestellt (Probe-Bild google favicon) und nach dem Fix gegengeprueft. | 2026-09-23 | bf4384a | — |
|
||||
| 260923-lrr | **Favoriten: eigenes Symbol hochladen, Symbol sofort aktualisiert, Cloudflare-Meldung.** Versionszaehler `iconVersion` an der Symboladresse (`?v=`) statt 24-h-Zwischenspeicher mit fester Adresse (Ursache „neue Logo-Adresse, nichts passiert“); `Cache-Control: private`. Upload PNG/JPEG/GIF/WebP/ICO/SVG bis 512 KB nach Dateiinhalt, Ablage `user-files/favorite-icons/<userId>/<id>.<ext>`, Vorrang vor Logo-Adresse, Entfernen-Knopf. Neue Logo-Adresse wird beim Speichern einmal zur Probe abgerufen; scheitert es (Cloudflare-Pruefung, 403), bleibt das Formular offen mit deutscher Meldung und Hinweis aufs Hochladen. Aufraeumen der Dateien auch beim Loeschen einer Kachel/eines Reiters (T-LRR-07 geschlossen). Browser-Nachweis: rot hochgeladen -> sofort rot (v=1), blau -> sofort blau (v=2), Entfernen -> altes Logo (v=3), httpbin 403 -> Meldung, google favicon -> sofort (v=4). Nachtrag Orchestrator: Zeile `dashboard.service.ts`/`favoriteLink` in der Zugriffsklassifikation. api 1368, web 742 gruen. | 2026-09-23 | 7704372,61f95c8 | [260923-lrr-favoriten-eigenes-symbol-hochladen-und-s](./quick/260923-lrr-favoriten-eigenes-symbol-hochladen-und-s/) |
|
||||
| 260924-h7x | **Proxmox-Seite neu gestaltet (Status bestimmt das Bild) und Dashboard-Reiter in die Kopfzeile.** Nutzer hob am 24.09. die Umbausperre vom 23.09. selbst auf. Design-Plan aus dem frontend-design-Skill: Statusfarben als OKLCH-Tokens (`--status-ok/warn/down/idle/orphan`, dazu `-fg`-Textvarianten fuer 4,5:1), Gesundheitsbalken mit Legende, Karten mit Statusleiste links und im Statuston getoentem Schatten, eingelassene Messfelder, Knoten als Einschuebe mit Balken nach Schwellen (80/92 %, Sicherung > 26 h), PMG-Zahlfelder. **Deaktivierter Server = „Offline & verwaist“** (Vorrang vor allem, keine alten Werte, gestrichelt). Sortierung down/warn/ok/idle/orphan, Spaltenfluss statt Raster. Reiter als eingelassener Umschalter per Portal in der Kopfzeilenmitte (`header-center-slot`), eigene Zeile entfallen, Pfeiltasten, weiche Randausblendung bei Ueberlauf; unter 640 px Logo nur Bildmarke. Browser: hell/dunkel 1400 px, 390 px ohne Ueberlauf. web 789 gruen. | 2026-09-24 | 0fa7ce0,57c338f,7416a92,0b659d6,57a4196 | [260924-h7x-proxmox-seite-status-design-und-dashboar](./quick/260924-h7x-proxmox-seite-status-design-und-dashboar/) |
|
||||
| 260924-i8v | **Proxmox-Kachel fuers Dashboard.** Modul-Kachel ueber den Weg aus 260922-m1h (Typ `proxmox` in packages/shared + Modulbindung, API-Freigabeliste, Registry, Katalog), nur fuer Benutzer mit Modulzugriff. Kompakter Gesundheitsbalken + Zusammenfassung in Worten, Serverliste nach Dringlichkeit mit je einer Kennzahl (Gaeste/Auslastung, aelteste Sicherung, eingehende Mails, unbekannt nie 0), Links auf /modules/proxmox (nicht im Bearbeitungsmodus), liest jede Minute den Zwischenstand (pausiert bei verborgenem Tab, loest NIE eine Abfrage aus), Titel + Serverauswahl an der Kachel und unter Einstellungen > Dashboard, Groessenstufen per Container-Query. Gemeinsame Teile nach `components/proxmox/` verschoben. Browser: Katalog, Kachel hell/dunkel, schmale Stufe (nur Punkte+Namen). web 864, api 1370 gruen. | 2026-09-24 | a906c67,92bf130,a217d60,377b6e3,586da44,602a45c | [260924-i8v-proxmox-kachel-fuers-dashboard](./quick/260924-i8v-proxmox-kachel-fuers-dashboard/) |
|
||||
| 260924-m4n | **Flackernden Test entschaerft, alte Bildspalte entfernt.** (1) `tenant-selector.test.tsx`: Ursache war das Laden der Bausteine INNERHALB des ersten Tests (zaehlte in dessen 5-s-Grenze) -> Import vorab, Doppelfall getrennt, dasselbe in zwei weiteren Marktplatz-Tests; langsamster Web-Test jetzt < 2 s (mit 2 Kernen 1,3 s); act()-Warnungen der Proxmox-Kachel weg. (2) DashboardImage Stufe 2: Migration `20260924120000_dashboard_image_drop_data` mit Schutz (bricht ab, wenn noch Zeilen ohne `storagePath`; Zeilenschutz fuer die Pruefung abgeschaltet, sonst saehe sie still 0), `storagePath` NOT NULL, `data` weg, `system_read_policy` weg, Bootstrap-Umzug + `forSystem()` entfernt, Upload legt Zeile gleich mit Pfad an. Vorbedingung alpha geprueft (0 von 3 ohne Pfad); Live nicht pruefbar. Rueckweg bei Abbruch in `docs/anleitung-betrieb.md` Kap. 4. Browser/API: Bilder laden, Upload+Anzeige+Loeschen ok. api 1364, web 865 gruen. | 2026-09-24 | b10734f,dd54ec5 | [260924-m4n-flackernden-test-entschaerfen-und-dashbo](./quick/260924-m4n-flackernden-test-entschaerfen-und-dashbo/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
---
|
||||
phase: quick-260923-lrr
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
subsystem: apps/api/src/favorites, apps/web/src/components/dashboard/widgets
|
||||
files_modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql
|
||||
- apps/api/src/favorites/favorite-icon-files.ts
|
||||
- apps/api/src/favorites/favorite-icon-files.spec.ts
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/favorites/favorites.service.spec.ts
|
||||
- apps/api/src/favorites/favorites.controller.ts
|
||||
- apps/api/src/favorites/favorites.controller.spec.ts
|
||||
- docs/anleitung-betrieb.md
|
||||
- apps/web/src/lib/favorites-api.ts
|
||||
- apps/web/src/lib/favorites-api.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
autonomous: false
|
||||
requirements: [QUICK-260923-lrr]
|
||||
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Nach dem Speichern einer geaenderten Logo-Adresse zeigt die Favoriten-Kachel sofort das neue Symbol, nicht erst nach 24 Stunden (Bildadresse traegt ?v=<iconVersion>, iconVersion steigt bei jeder Aenderung der Symbolquelle)"
|
||||
- "Laesst sich das Bild unter einer neu eingetragenen Logo-Adresse serverseitig nicht abrufen (z. B. Cloudflare-Pruefung, 403/HTML, nicht erreichbar), wird NICHT gespeichert; das Bearbeitungsformular zeigt eine deutsche Meldung mit dem Hinweis, das Symbol hochzuladen"
|
||||
- "Im Bearbeitungsformular und im Hinzufuegen-Formular laesst sich ein eigenes Symbol hochladen (PNG, JPEG, GIF, WebP, ICO, SVG, hoechstens 512 KB); es erscheint sofort und hat Vorrang vor der Logo-Adresse"
|
||||
- "„Hochgeladenes Symbol entfernen“ loescht das hochgeladene Symbol; die Kachel faellt auf Logo-Adresse bzw. automatische Erkennung zurueck"
|
||||
- "Beim Loeschen eines Favoriten verschwindet auch seine hochgeladene Symboldatei aus user-files/favorite-icons"
|
||||
- "Hochladen, Entfernen und Abrufen eines Symbols gelingt nur dem Besitzer (Benutzer UND Mandant); fremde oder unbekannte Kennung ergibt 404"
|
||||
artifacts:
|
||||
- path: apps/api/src/favorites/favorite-icon-files.ts
|
||||
provides: "FAVORITE_ICON_MAX_BYTES, detectFavoriteIconMime, favoriteIconExtension, resolveFavoriteIconsDir, favoriteIconAbsolutePath"
|
||||
- path: apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql
|
||||
provides: "Spalten uploadedIconMime (TEXT NULL) und iconVersion (INTEGER NOT NULL DEFAULT 0) auf FavoriteLink"
|
||||
- path: apps/api/src/favorites/favorites.service.ts
|
||||
provides: "uploadIcon, removeUploadedIcon, Vorrang hochgeladene Datei in getIconBytes, Dateiloeschung in remove, Abrufprobe fuer neue Logo-Adresse, iconVersion-Erhoehung"
|
||||
- path: apps/api/src/favorites/favorites.controller.ts
|
||||
provides: "POST /favorites/:id/icon (multipart-Feld icon, 512 KB), DELETE /favorites/:id/icon, Cache-Control private"
|
||||
- path: apps/web/src/lib/favorites-api.ts
|
||||
provides: "uploadFavoriteIcon, removeFavoriteIcon, FavoriteRequestError, FAVORITE_ICON_MAX_BYTES, Felder uploadedIconMime/iconVersion"
|
||||
- path: apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
provides: "versionierte Symbol-Adresse, Datei-Auswahl im Bearbeitungs- und Hinzufuegen-Formular, Entfernen-Knopf, Fehlermeldung im Formular"
|
||||
key_links:
|
||||
- from: "FavoriteIcon (favorites-widget.tsx)"
|
||||
to: "GET /favorites/:id/icon"
|
||||
via: "src /api-proxy/favorites/<id>/icon?v=<iconVersion>; Remount-Key enthaelt iconVersion und uploadedIconMime"
|
||||
- from: "FavoritesService.update/uploadIcon/removeUploadedIcon"
|
||||
to: "FavoriteLink.iconVersion"
|
||||
via: "Prisma-Update mit iconVersion increment 1 bei jeder Aenderung der Symbolquelle"
|
||||
- from: "FavoritesService.getIconBytes"
|
||||
to: "user-files/favorite-icons/<userId>/<id>.<ext>"
|
||||
via: "uploadedIconMime gesetzt -> Datei lesen (Vorrang), sonst iconUrl ueber IconDiscoveryService.fetchIconBytes"
|
||||
- from: "uploadFavoriteIcon (favorites-api.ts)"
|
||||
to: "POST /favorites/:id/icon"
|
||||
via: "FormData mit Feld icon, 413 -> iconTooLarge, 400 -> iconInvalidType"
|
||||
- from: "updateFavorite/createFavorite (favorites-api.ts)"
|
||||
to: "422 aus der Abrufprobe"
|
||||
via: "FavoriteRequestError('iconUrlUnreachable') -> t('favorites.iconUrlUnreachable') im Formular"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260923-lrr: Favoriten — eigenes Symbol hochladen, Symbol-Zwischenspeicher nach Aenderung erneuern
|
||||
|
||||
## Ausgangslage (vom Orchestrator und vom Planer am Code geprueft, Stand b03ffb5)
|
||||
|
||||
Meldung des Nutzers: Beim Bearbeiten eines Favoriten eine eigene Logo-Adresse eintragen „bewirkt nichts“; Verdacht Cloudflare-Pruefung vor dem Bild; falls nicht behebbar, soll man ein Symbol hochladen koennen.
|
||||
|
||||
Befund:
|
||||
|
||||
1. **Zwischenspeicher-Fehler (Vorgabe 1).** `FavoriteIcon` in `favorites-widget.tsx` laedt immer `/api-proxy/favorites/<id>/icon`; `getIcon` in `favorites.controller.ts` antwortet mit `Cache-Control` `public`, 24 h. Die Adresse aendert sich bei neuer Logo-Adresse nicht, der Browser zeigt einen Tag lang das alte Bild. Der vorhandene Remount-Key (`iconUrl|url`) setzt nur die Stufe zurueck, nicht den HTTP-Zwischenspeicher.
|
||||
2. **Cloudflare (Vorgabe 2).** Die Bytes holt der Server bei JEDEM Abruf ueber `IconDiscoveryService.fetchIconBytes`. Eine Cloudflare-Pruefung liefert 403/HTML → 502 → die Kachel faellt auf Stufe `direct` (Favicon der Link-Adresse) zurueck — fuer den Nutzer sieht das wie „nichts passiert“ aus. Umgehen der Bot-Sperre ist ausgeschlossen. Stattdessen: beim Speichern einer NEUEN Logo-Adresse einmal probeweise abrufen (4 s Zeitgrenze, `ICON_FETCH_TIMEOUT_MS`), bei Fehlschlag 422 mit deutscher Meldung; das Formular zeigt die Meldung. Kosten: ein Aufruf einer vorhandenen Methode — billig, wird gebaut.
|
||||
3. **Neu: eigenes Symbol hochladen (Vorgabe 3).** Ablage im Dateibereich nach dem Muster `dashboard-images.service.ts` (quick-260922-hk4): `user-files/favorite-icons/<userId>/<favoriteId>.<ext>`, Dateiname IMMER servergeneriert.
|
||||
4. **Texte (Vorgabe 4)** in Sie-Form, Schluessel in `de.json` UND `en.json`; der heute fest verdrahtete Platzhalter „Logo-URL (optional)“ wird dabei ebenfalls uebersetzbar.
|
||||
|
||||
## Festlegungen des Planers (Claude-Ermessen, gebunden fuer die Ausfuehrung)
|
||||
|
||||
- **Datenmodell:** `FavoriteLink` bekommt genau zwei Spalten: `uploadedIconMime String?` (erkannter Typ des hochgeladenen Symbols; `null` = keins) und `iconVersion Int @default(0)` (Zaehler fuer die Bildadresse). KEINE Pfadspalte: der Pfad ist aus `userId`, `id` und Endung des Typs vollstaendig ableitbar — damit gelangt auch kein Serverpfad in API-Antworten (Prisma liefert die ganze Zeile an den Client). `updatedAt` scheidet als Versionsquelle aus, weil Umsortieren und Titelaenderung sonst alle Symbole neu laden liessen.
|
||||
- **iconVersion steigt** (Prisma `{ increment: 1 }`) genau dann, wenn sich die angezeigte Quelle aendert: gespeicherte `iconUrl` weicht vom alten Wert ab; Symbol hochgeladen; hochgeladenes Symbol entfernt. Nicht bei Titel, Link-Adresse ohne Symbolwechsel oder Reihenfolge. Bestandszeilen starten mit 0 → Adresse `?v=0` unterscheidet sich von der bisherigen unversionierten, alte 24-h-Eintraege im Browser greifen also sofort nicht mehr.
|
||||
- **Vorrang:** hochgeladenes Symbol vor `iconUrl`. Fehlt die Datei trotz gesetztem Typ, wird protokolliert und auf `iconUrl` zurueckgefallen; ohne `iconUrl` → 404.
|
||||
- **Abrufprobe:** nur wenn eine NICHT leere `iconUrl` uebergeben wird, die vom gespeicherten Wert abweicht (bei `update`) bzw. ueberhaupt uebergeben wird (bei `create`). Fehlschlag → `UnprocessableEntityException` (422), nichts wird geschrieben. Der Web-Klient uebersetzt 422 selbst (Schluessel `favorites.iconUrlUnreachable`), damit die Meldung sprachrichtig ist. Interne Adressen (vom SSRF-Schutz abgewiesen) fallen ebenfalls unter 422 — konsistent, denn sie wurden auch bisher nie angezeigt; der Hinweis zeigt auf das Hochladen.
|
||||
- **Hochladen im Bearbeitungsformular:** Datei wird ausgewaehlt und beim Klick auf „Speichern“ hochgeladen (erst PATCH, dann Upload) — passt zu Speichern/Abbrechen. „Hochgeladenes Symbol entfernen“ wirkt sofort (wie Loeschen), das Formular bleibt offen. Im Hinzufuegen-Formular: nach `createFavorite` wird die gewaehlte Datei fuer die neue Kennung hochgeladen; scheitert nur der Upload, bleibt der Favorit angelegt und die Meldung erscheint.
|
||||
- **Tracer-Modus aus (bewusst):** die Architektur ist bereits bewiesen — Dateiablage in `user-files` mit Besitzpruefung (hk4), Multer-Upload mit Routen-Grenze (pi9) und der Symbol-Proxy existieren. Eine duenne Scheibe braechte keine Information; der Ende-zu-Ende-Nachweis ist Aufgabe 3 (Browser). Reihenfolge Schnittstelle zuerst: API (Aufgabe 1), dann Web (Aufgabe 2).
|
||||
- **Keine neuen Pakete.** Erkennung von ICO und SVG per Signatur bzw. Textpruefung, wie `dashboard-image-rules.ts` es fuer vier Formate vormacht.
|
||||
- **Qualitaetsregeln wie bisher:** in Produktionscode keine neue `any`, keine neue `!`-Zusicherung, kein `biome-ignore`; `as unknown as` in `apps/api/src` bleibt bei hoechstens 29 (gezaehlt vom Planer an b03ffb5). `biome lint` auf `favorites-widget.tsx` meldet heute 3 vorbestehende Warnungen (noNonNullAssertion Z. 149, zweimal noImgElement) — die Zahl darf nicht steigen, also KEIN neues `<img>` (keine Vorschau im Formular; die Zeile selbst zeigt das Symbol).
|
||||
|
||||
<objective>
|
||||
Favoriten-Symbole aktualisieren sich nach einer Aenderung sofort; eine nicht abrufbare Logo-Adresse wird beim Speichern klar gemeldet statt still gespeichert; je Favorit laesst sich ein eigenes Symbol hochladen und wieder entfernen, abgelegt im Dateibereich und beim Loeschen des Favoriten mit entfernt.
|
||||
|
||||
Purpose: Der Nutzer hat eine Logo-Adresse hinter einer Cloudflare-Pruefung — die ist nicht abrufbar, und selbst eine abrufbare neue Adresse erschien wegen des Zwischenspeichers einen Tag lang nicht. Hochladen ist der verlaessliche Weg.
|
||||
Output: Prisma-Migration, Dateiablage-Hilfen, erweiterter Favoriten-Dienst/-Controller mit Tests, erweiterter Web-Klient und Widget mit Tests, Texte de/en, Changelog und Anleitungen, Browser-Nachweis.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@./CLAUDE.md
|
||||
@apps/api/src/favorites/favorites.service.ts
|
||||
@apps/api/src/favorites/favorites.controller.ts
|
||||
@apps/api/src/dashboard/dashboard-images.service.ts
|
||||
@apps/api/src/dashboard/dashboard-image-rules.ts
|
||||
@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
@apps/web/src/lib/favorites-api.ts
|
||||
|
||||
<interfaces>
|
||||
Vorhandene Vertraege, die der Ausfuehrer nutzt (am Code geprueft, nicht neu erkunden):
|
||||
|
||||
- `forTenant(this.prisma, tenantId, userId)` aus `../prisma/prisma-tenant.extension` — jede Methode des Favoriten-Dienstes laeuft ueber genau einen so gebundenen Klienten.
|
||||
- `IconDiscoveryService.fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }>` — SSRF-geschuetzt, 4 s Zeitgrenze, wirft bei jedem Fehler (blockiert, Zeitgrenze, kein `image/*`, groesser 1 MB, ungueltige Adresse).
|
||||
- `detectImageMime(buffer: Uint8Array): 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' | null` aus `apps/api/src/dashboard/dashboard-image-rules.ts` (reine Funktion, ohne Nest).
|
||||
- `UploadedFileLike { buffer: Buffer; originalname: string; mimetype: string; size: number }` aus `apps/api/src/auth/types/auth-user.ts`.
|
||||
- `FileInterceptor` aus `@nestjs/platform-express`; multers `LIMIT_FILE_SIZE` bildet Nest auf 413 ab (Muster `dashboard-images.controller.ts`).
|
||||
- Controller-Kontext: `extractContext(req)` im Favoriten-Controller (`req.tenantId ?? req.user?.tenantId`) — die neuen Routen nutzen DIESE Quelle, NICHT `@CurrentUser()` (Begruendung im Kopfkommentar des Controllers, 260911-gwh).
|
||||
- Testmuster Dienst: `favorites.service.spec.ts` (Zwei-Klienten-Fake, `makeIconDiscovery` mit `fetchIconBytes`-Attrappe ab Z. 196); echtes Temp-Verzeichnis per Umgebungsschalter wie `dashboard-images.service.spec.ts` Z. 89-93.
|
||||
- Testmuster Controller: `dashboard-images.controller.spec.ts` Z. 1-30 (Attrappe fuer `FileInterceptor`) und Test 2/3.
|
||||
- Testmuster Web-Klient: `apps/web/src/lib/dashboard-images-api.test.ts`.
|
||||
- Next-Rewrite `/api-proxy/:path*` (next.config.ts Z. 38) reicht Query-Parameter und Cookies an die API durch.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: API — Symbol hochladen/entfernen, Vorrang beim Ausliefern, Versionszaehler, Abrufprobe fuer neue Logo-Adresse</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql, apps/api/src/favorites/favorite-icon-files.ts, apps/api/src/favorites/favorite-icon-files.spec.ts, apps/api/src/favorites/favorites.service.ts, apps/api/src/favorites/favorites.service.spec.ts, apps/api/src/favorites/favorites.controller.ts, apps/api/src/favorites/favorites.controller.spec.ts, docs/anleitung-betrieb.md</files>
|
||||
<read_first>apps/api/src/favorites/favorites.service.spec.ts (Z. 1-260, Fake-Aufbau), apps/api/src/dashboard/dashboard-images.service.spec.ts (Z. 40-110, Temp-Verzeichnis), apps/api/src/dashboard/dashboard-images.controller.spec.ts (Z. 1-100), apps/api/prisma/schema.prisma (Z. 414-430, model FavoriteLink)</read_first>
|
||||
<behavior>
|
||||
favorite-icon-files.spec.ts:
|
||||
- detectFavoriteIconMime: PNG/JPEG/GIF/WebP-Signaturen ergeben dieselben Typen wie detectImageMime; Bytes 00 00 01 00 (mindestens 6 Bytes) ergeben 'image/x-icon'; `<svg xmlns=...>`, `<?xml version="1.0"?>` gefolgt von `<svg>`, fuehrendes BOM/Leerzeichen, Kommentar oder `<!DOCTYPE svg ...>` vor `<svg` ergeben 'image/svg+xml'; `<html><svg>`, `<!DOCTYPE html>`, leerer Puffer, Klartext, PDF-Signatur und 00 00 02 00 (CUR) ergeben null
|
||||
- favoriteIconExtension: png, jpg, gif, webp, ico, svg; unbekannter Typ ergibt null
|
||||
- favoriteIconAbsolutePath: liegt unter resolveFavoriteIconsDir()/<userId>/<id>.<ext>; Segmente mit '..', '/', '\\' oder leer ergeben null; unbekannter Typ ergibt null
|
||||
- resolveFavoriteIconsDir beachtet FAVORITE_ICONS_DIR
|
||||
favorites.service.spec.ts (neue describe-Bloecke, Temp-Verzeichnis ueber FAVORITE_ICONS_DIR):
|
||||
- uploadIcon PNG: Datei liegt unter <dir>/<userId>/<id>.png mit genau den Bytes, Zeile hat uploadedIconMime 'image/png' und iconVersion um 1 hoeher, Rueckgabe ist die aktualisierte Zeile
|
||||
- uploadIcon ohne Datei -> BadRequestException; Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert
|
||||
- uploadIcon Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)
|
||||
- uploadIcon fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben
|
||||
- erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2
|
||||
- getIconBytes mit hochgeladenem Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen
|
||||
- getIconBytes, Typ gesetzt aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck; ohne iconUrl -> NotFoundException
|
||||
- getIconBytes ohne Upload, ohne iconUrl -> NotFoundException (Bestandsverhalten)
|
||||
- removeUploadedIcon: Datei weg, uploadedIconMime null, iconVersion +1; ohne Upload -> Zeile unveraendert, keine Erhoehung; fremd -> NotFoundException
|
||||
- remove() eines Favoriten mit Upload: Zeile und Datei weg; Fehler beim Datei-Entfernen wird geschluckt (Loeschen gelingt trotzdem)
|
||||
- update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft die Probe -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen
|
||||
- update mit unveraenderter iconUrl: keine Probe, keine Erhoehung; update nur Titel: keine Erhoehung; update mit neuer, erreichbarer iconUrl: iconVersion +1
|
||||
- create mit expliziter iconUrl: Probe; Probe wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen
|
||||
favorites.controller.spec.ts (neu):
|
||||
- FileInterceptor wird mit 'icon' und { limits: { fileSize: 512 * 1024, files: 1 } } aufgerufen
|
||||
- getIcon setzt Content-Type aus dem Dienst, Cache-Control 'private, max-age=86400', X-Content-Type-Options nosniff, Content-Security-Policy "default-src 'none'; sandbox"
|
||||
- uploadIcon und removeUploadedIcon reichen tenantId aus req.tenantId (vor req.user.tenantId) und userId aus req.user.id an den Dienst; ohne Mandant -> ForbiddenException
|
||||
- POST ':id/icon' und DELETE ':id/icon' sind als Routen-Metadaten vorhanden
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst die Tests aus `<behavior>` schreiben und rot sehen, dann umsetzen (Vorgaben 1-3 des Orchestrators, Festlegungen oben).
|
||||
|
||||
(a) Schema und Migration (Festlegung Datenmodell). In `model FavoriteLink` nach `iconUrl` die Felder `uploadedIconMime String?` und `iconVersion Int @default(0)` einfuegen, je mit kurzem Kommentar (quick-260923-lrr). Neue Migration `apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql`: ein `ALTER TABLE "FavoriteLink"` mit `ADD COLUMN "uploadedIconMime" TEXT` und `ADD COLUMN "iconVersion" INTEGER NOT NULL DEFAULT 0`. Deutscher Kopfkommentar im Stil von 20260922120000: wozu die Spalten dienen, dass die Datei unter `user-files/favorite-icons/<userId>/<id>.<ext>` liegt und kein Pfad gespeichert wird (ableitbar), dass die bestehende RLS-Regel auf `FavoriteLink` zeilenbezogen ist und fuer neue Spalten nichts braucht, dass `migrate deploy` beim Start (`apps/api/scripts/migrate-and-start.sh`) die Migration anwendet. Danach `pnpm --filter @tessera/api exec prisma generate` (kein `prisma format` auf das ganze Schema — fremder Diff).
|
||||
|
||||
(b) Neue Datei `apps/api/src/favorites/favorite-icon-files.ts` (rein, ohne Nest/Prisma, Kopfkommentar deutsch nach Muster `dashboard-image-rules.ts`): `FAVORITE_ICON_MAX_BYTES = 512 * 1024`; Typ `FavoriteIconMime` = die vier Typen von `detectImageMime` plus `'image/x-icon'` und `'image/svg+xml'`; `detectFavoriteIconMime(buffer)` ruft zuerst `detectImageMime` (Import aus `../dashboard/dashboard-image-rules`), prueft dann ICO (erste vier Bytes 00 00 01 00, Puffer mindestens 6 Bytes), dann SVG: die ersten 4096 Bytes als UTF-8, BOM und fuehrende Leerzeichen entfernen; der Anfang darf nur aus optionaler XML-Deklaration, beliebig vielen Kommentaren und optionalem DOCTYPE mit Wurzel svg bestehen, dann muss `<svg` folgen, direkt gefolgt von Leerzeichen, `>` oder `/` (ein regulaerer Ausdruck, gross/klein egal); alles andere null, die Funktion wirft nie. `favoriteIconExtension(mime)` bildet auf png/jpg/gif/webp/ico/svg ab, sonst null. `resolveFavoriteIconsDir()` nach Muster `resolveDashboardImagesDir()`: Umgebungsschalter `FAVORITE_ICONS_DIR` (nur Tests), sonst `path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'favorite-icons')`. `favoriteIconAbsolutePath(userId, id, mime)`: null, wenn Endung unbekannt oder ein Segment nicht nur aus Buchstaben, Ziffern und Bindestrich besteht; sonst `path.resolve(base, userId, id + '.' + ext)` mit Pruefung, dass das Ergebnis unter `base + path.sep` liegt (sonst null). Kein Byte aus der Anfrage (insbesondere nicht `originalname`) geht je in einen Pfad.
|
||||
|
||||
(c) `favorites.service.ts`: `Logger` ergaenzen. Kopfkommentar um einen Absatz 260923-lrr erweitern (Ablage, Vorrang, Versionszaehler, halbe Zustaende, Abrufprobe, 404 statt 403). Neue private Hilfe `assertIconUrlLoadable(iconUrl)`: ruft `this.iconDiscovery.fetchIconBytes(iconUrl)`, jeder Fehler wird zu `UnprocessableEntityException('Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.')`. Keine Umgehung von Bot-Sperren, keine anderen Header als die vorhandenen.
|
||||
- `create()`: nach der Widget-Besitzpruefung und vor `create`, wenn `dto.iconUrl` nicht leer ist, `assertIconUrlLoadable(dto.iconUrl)`.
|
||||
- `update()`: im Zweig mit nicht leerer `dto.iconUrl` die Probe nur, wenn der Wert von `link.iconUrl` abweicht; nach dem Aufbau von `data` gilt: ist `data.iconUrl` gesetzt und ungleich `link.iconUrl`, dann `data.iconVersion = { increment: 1 }`.
|
||||
- Neue Methode `uploadIcon(tenantId, id, userId, file: UploadedFileLike | undefined)`: ohne Datei `BadRequestException('Bitte wählen Sie eine Bilddatei aus.')`; Puffer groesser `FAVORITE_ICON_MAX_BYTES` → `PayloadTooLargeException` (zweites Netz); Typ per `detectFavoriteIconMime`, null → `BadRequestException('Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.')`; Zeile ueber den gebundenen Klienten holen, `!link || link.userId !== userId || link.tenantId !== tenantId` → `NotFoundException('FavoriteLink not found')`; Zielpfad per `favoriteIconAbsolutePath(link.userId, link.id, mime)` (null → `InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.')`); Ordner rekursiv anlegen, Datei schreiben (Fehler → protokollieren, dieselbe InternalServerErrorException); dann `favoriteLink.update` mit `uploadedIconMime: mime` und `iconVersion: { increment: 1 }`. Scheitert dieses Update und weicht der neue Pfad vom alten ab, die neue Datei wieder entfernen (Fehler schlucken) und neu werfen. Nach Erfolg: hatte die Zeile vorher einen anderen Typ mit anderem Pfad, die alte Datei entfernen (Fehler protokollieren und schlucken, Muster T-HK4-04). Rueckgabe: aktualisierte Zeile.
|
||||
- Neue Methode `removeUploadedIcon(tenantId, id, userId)`: gleiche Besitzpruefung; `uploadedIconMime === null` → Zeile unveraendert zurueck; sonst Update `uploadedIconMime: null`, `iconVersion: { increment: 1 }`, danach Datei entfernen (Fehler schlucken). Rueckgabe: aktualisierte Zeile.
|
||||
- `remove()`: nach dem Loeschen der Zeile, falls `link.uploadedIconMime` gesetzt, die Datei entfernen (Fehler protokollieren und schlucken).
|
||||
- `getIconBytes()`: Besitzpruefung auf `!link || link.userId !== userId` (404) vorziehen; ist `uploadedIconMime` gesetzt, Datei lesen und `{ contentType: link.uploadedIconMime, body }` liefern; fehlt sie, warnen und weitermachen; danach wie bisher: ohne `iconUrl` 404, sonst `fetchIconBytes`, Fehler → 502. Den Doc-Kommentar anpassen.
|
||||
|
||||
(d) `favorites.controller.ts`: zwei neue Routen direkt nach `getIcon` und vor `@Patch(':id')`: `@Post(':id/icon')` mit `@UseInterceptors(FileInterceptor('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 } }))`, Parameter `@Param('id', ParseUUIDPipe)`, `@Req()`, `@UploadedFile() file?: UploadedFileLike` → `uploadIcon`; `@Delete(':id/icon')` mit `ParseUUIDPipe` → `removeUploadedIcon`. Beide ueber `extractContext`. In `getIcon` den bisherigen Wert mit `public` durch `'private, max-age=86400'` ersetzen (die Adresse ist jetzt versioniert, benutzerbezogene Inhalte gehoeren in keinen gemeinsamen Zwischenspeicher wie Nginx Proxy Manager); `nosniff` und die CSP mit `sandbox` bleiben unveraendert — sie decken auch hochgeladene SVG ab. Kopfkommentar-Routenliste um die zwei Routen und den Hinweis `?v=` ergaenzen.
|
||||
|
||||
(e) Tests: `favorites.service.spec.ts` erweitern — der Fake braucht fuer `favoriteLink.update` die Behandlung von `{ increment: n }` bei `iconVersion`, Bestandszeilen im Fake bekommen `uploadedIconMime: null` und `iconVersion: 0`; bestehende Tests, die `create`/`update` mit expliziter `iconUrl` aufrufen, brauchen eine aufloesende `fetchIconBytes`-Attrappe (nur so weit anpassen, wie noetig). Temp-Verzeichnis pro Test ueber `FAVORITE_ICONS_DIR`, danach Umgebung wiederherstellen und Verzeichnis loeschen. Neue Datei `favorites.controller.spec.ts` nach Muster `dashboard-images.controller.spec.ts`.
|
||||
|
||||
(f) `docs/anleitung-betrieb.md` Kap. 6 (Z. ~286, Liste „Hochgeladene Dateien“): „Symbole des Favoriten-Widgets unter `user-files/favorite-icons/<Benutzerkennung>/`“ ergaenzen.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate && pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard && pnpm --filter @tessera/api exec tsc --noEmit && pnpm exec biome lint apps/api/src/favorites && test "$(grep -rc 'as unknown as' apps/api/src --include=*.ts | awk -F: '{s+=$2} END {print s}')" -le 29 && grep -c "'private, max-age=86400'" apps/api/src/favorites/favorites.controller.ts</automated>
|
||||
</verify>
|
||||
<done>Alle neuen und bestehenden Tests in src/favorites und src/dashboard gruen; tsc ohne Fehler; biome lint auf apps/api/src/favorites ohne Befund; Migration 20260923160000_favorite_icon_upload vorhanden; `as unknown as` in apps/api/src hoechstens 29; Betriebsanleitung nennt user-files/favorite-icons.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Web — versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular, Texte und Doku</name>
|
||||
<files>apps/web/src/lib/favorites-api.ts, apps/web/src/lib/favorites-api.test.ts, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||
<read_first>apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (Z. 1-120 und 426-490), apps/web/src/lib/dashboard-images-api.test.ts, apps/web/src/lib/dashboard-images-api.ts (Muster Upload und readMessage)</read_first>
|
||||
<behavior>
|
||||
favorites-api.test.ts (neu, fetch per vi.stubGlobal):
|
||||
- uploadFavoriteIcon schickt POST an /favorites/<id>/icon mit credentials include, FormData mit genau dem Feld icon, OHNE eigenen Content-Type-Header; 200 -> Zeile
|
||||
- uploadFavoriteIcon: Datei groesser 512 KB -> FavoriteRequestError reason iconTooLarge, fetch NICHT aufgerufen; 413 -> iconTooLarge; 400 -> iconInvalidType; 500 -> iconUploadFailed
|
||||
- removeFavoriteIcon schickt DELETE an /favorites/<id>/icon, liefert die Zeile
|
||||
- updateFavorite und createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable; anderer Fehler -> wie bisher Error
|
||||
favorites-widget.test.tsx (neue describe 'Eigenes Symbol (quick-260923-lrr)'):
|
||||
- Proxy-Bild traegt ?v=<iconVersion>; Zeile ohne iconVersion -> ?v=0
|
||||
- Speichern mit neuer Logo-Adresse, updateFavorite liefert iconVersion 1 -> src des Proxy-Bildes endet danach auf ?v=1 (Cache-Bust sichtbar)
|
||||
- Zeile nur mit uploadedIconMime (iconUrl null) -> Proxy-Bild statt Direktbild
|
||||
- Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Zeile zeigt Ergebnis (neues ?v=), Formular schliesst
|
||||
- updateFavorite wirft FavoriteRequestError('iconUrlUnreachable') -> Text 'favorites.iconUrlUnreachable' INNERHALB des Formulars (role alert), Formular bleibt offen, uploadFavoriteIcon nicht aufgerufen
|
||||
- Upload wirft FavoriteRequestError('iconTooLarge') -> 'favorites.iconTooLarge' im Formular, Formular bleibt offen
|
||||
- Knopf 'favorites.iconRemoveButton' nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen
|
||||
- Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)
|
||||
- Logo-Adress-Feld zeigt den Platzhalter 'favorites.iconUrlPlaceholder' (kein fest verdrahteter Text mehr)
|
||||
</behavior>
|
||||
<action>
|
||||
Tests aus `<behavior>` zuerst, dann umsetzen (Vorgaben 1, 3, 4; Festlegungen oben).
|
||||
|
||||
(a) `favorites-api.ts`: `FavoriteLink` um optionale Felder `uploadedIconMime?: string | null` und `iconVersion?: number` erweitern (optional, weil Testdaten und aeltere Antworten sie nicht tragen). Export `FAVORITE_ICON_MAX_BYTES = 512 * 1024`. Export `type FavoriteErrorReason = 'iconUrlUnreachable' | 'iconTooLarge' | 'iconInvalidType' | 'iconUploadFailed'` und `class FavoriteRequestError extends Error` mit oeffentlichem, schreibgeschuetztem `reason`. `createFavorite`/`updateFavorite`: bei Status 422 `FavoriteRequestError('iconUrlUnreachable')`, sonst unveraendert. Neu `uploadFavoriteIcon(id, file)`: zuerst Groessenpruefung gegen die Konstante (iconTooLarge, ohne Anfrage), dann FormData mit Feld `icon` (Dateiname mitgeben), POST an `${API_URL}/favorites/<id>/icon`, `credentials: 'include'`, KEIN Content-Type-Header (Muster `uploadDashboardImage`); 413 → iconTooLarge, 400 → iconInvalidType, sonst nicht ok → iconUploadFailed. Neu `removeFavoriteIcon(id)`: DELETE `${API_URL}/favorites/<id>/icon`, bei Fehler `Error('Failed to remove favorite icon')`. Kopfkommentar um 260923-lrr ergaenzen.
|
||||
|
||||
(b) `favorites-widget.tsx`:
|
||||
- `FavoriteIcon`: Server-Symbol vorhanden, wenn `fav.iconUrl` oder `fav.uploadedIconMime` gesetzt ist; `proxySrc` = `/api-proxy/favorites/<encodeURIComponent(id)>/icon?v=<fav.iconVersion ?? 0>`. Der Remount-Key an der Aufrufstelle in `FavoriteTile` enthaelt zusaetzlich `iconVersion` und `uploadedIconMime`, damit nach einer Aenderung wieder mit Stufe `proxy` begonnen wird. Kopfkommentar: warum `?v=` (24-h-Zwischenspeicher, Adresse muss sich mit der Quelle aendern).
|
||||
- Neuer Zustand im Widget: `editIconFile: File | null`, `editError: string | null`, `editBusy: boolean`, `newIconFile: File | null` plus Ref auf das Datei-Feld des Hinzufuegen-Formulars (zum Zuruecksetzen). Hilfsfunktion, die einen Fehler auf einen Textschluessel abbildet: `FavoriteRequestError` → `favorites.<reason>`, sonst `favorites.error`; angezeigt wird `t(schluessel)`.
|
||||
- `startEdit`/`cancelEdit` setzen `editIconFile`, `editError`, `editBusy` zurueck.
|
||||
- `handleSaveEdit`: `editBusy` setzen; `updateFavorite` wie bisher; ist eine Datei gewaehlt, danach `uploadFavoriteIcon(id, datei)` und dessen Zeile verwenden; Zeile im Zustand ersetzen und Formular schliessen. Fehler: `editError` setzen, Formular bleibt offen; ist `updateFavorite` gelungen und nur der Upload gescheitert, die aktualisierte Zeile trotzdem uebernehmen. `editBusy` im finally zuruecksetzen.
|
||||
- Neu `handleRemoveIcon(id)`: `removeFavoriteIcon`, Zeile ersetzen, Formular bleibt offen; Fehler → `editError`.
|
||||
- `handleAdd`: nach `createFavorite` und ist `newIconFile` gesetzt, `uploadFavoriteIcon(created.id, datei)`; bei Erfolg dessen Zeile anhaengen, bei Fehler die angelegte Zeile anhaengen und `setError(t(schluessel))`. Danach Felder und Datei-Feld (per Ref, `value = ''`) zuruecksetzen.
|
||||
- Hinzufuegen-Formular: zwischen URL-Feld und Knopf ein Datei-Feld mit sichtbarer Beschriftung `t('favorites.iconUploadLabel')`, `data-testid="favorite-add-icon-upload"`.
|
||||
- Bearbeitungsformular in `FavoriteTile` (neue Props an BEIDEN Aufrufstellen, Liste und Kacheln): Logo-Adress-Feld mit `placeholder` und `aria-label` = `t('favorites.iconUrlPlaceholder')` statt des fest verdrahteten Textes; darunter ein `label` mit `t('favorites.iconUploadLabel')` und `<input type="file">` (`accept` = image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg; `data-testid` = `favorite-icon-upload-<id>`), Hinweiszeile `t('favorites.iconUploadHint')`; bei gesetztem `uploadedIconMime` der Satz `t('favorites.iconUploadedHint')` und ein Knopf `t('favorites.iconRemoveButton')`; `editError` als `<p role="alert">` in `text-destructive` im Formular; Speichern-Knopf waehrend `editBusy` deaktiviert. Klassen im Stil der vorhandenen Felder (text-xs, border-input). KEIN neues `<img>`.
|
||||
- Kopfkommentar der Datei um einen Punkt 260923-lrr ergaenzen.
|
||||
|
||||
(c) Testattrappe in `favorites-widget.test.tsx`: die Fabrik fuer `@/lib/favorites-api` wird asynchron und uebernimmt per `vi.importActual` die echte `FavoriteRequestError` und `FAVORITE_ICON_MAX_BYTES`; `uploadFavoriteIcon` und `removeFavoriteIcon` kommen als `vi.fn()` dazu. Datei-Auswahl per `fireEvent.change(feld, { target: { files: [datei] } })`.
|
||||
|
||||
(d) Texte in `widgets.favorites` (de mit echten Umlauten, en sinngleich):
|
||||
- iconUrlPlaceholder: „Logo-Adresse (optional)“ / „Logo URL (optional)“
|
||||
- iconUploadLabel: „Eigenes Symbol hochladen“ / „Upload custom icon“
|
||||
- iconUploadHint: „PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.“ / „PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.“
|
||||
- iconUploadedHint: „Für diesen Favoriten ist ein eigenes Symbol hochgeladen.“ / „A custom icon has been uploaded for this favorite.“
|
||||
- iconRemoveButton: „Hochgeladenes Symbol entfernen“ / „Remove uploaded icon“
|
||||
- iconUrlUnreachable: „Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.“ / englische Entsprechung
|
||||
- iconTooLarge: „Die Datei ist zu groß – erlaubt sind höchstens 512 KB.“ / „The file is too large – at most 512 KB is allowed.“
|
||||
- iconInvalidType: „Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.“ / „Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.“
|
||||
- iconUploadFailed: „Das Symbol konnte nicht hochgeladen werden.“ / „The icon could not be uploaded.“
|
||||
Meldet der Umlaut-Waechter (`src/messages`) ein korrekt geschriebenes Wort, dieses Wort in `UMLAUT_ALLOWLIST` aufnehmen — nie die Schreibweise verbiegen.
|
||||
|
||||
(e) `CHANGELOG.md`, Abschnitt „Unveröffentlicht“: unter „### Neu“ einen Punkt (Favoriten-Widget: eigenes Symbol je Link hochladen — PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB — beim Hinzufügen und im Bearbeitungsformular; Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“); neuen Unterabschnitt „### Behoben“ mit einem Punkt (nach Ändern der Logo-Adresse erscheint das neue Symbol sofort statt erst nach einem Tag; ist das Bild unter der Adresse nicht abrufbar, etwa wegen einer Cloudflare-Prüfung, sagt das Formular das jetzt, statt still zu speichern). Einfache Worte, Stil der vorhandenen Eintraege.
|
||||
`docs/anleitung-anwender.md` Z. 85 (Tabellenzeile Favoriten, bleibt EINE Zeile): ergaenzen, dass man im Bearbeitungsformular eine Logo-Adresse eintragen oder ein eigenes Symbol hochladen kann (Formate, 512 KB, Vorrang, Entfernen-Knopf) und dass Tessera beim Speichern meldet, wenn eine Seite automatische Abrufe blockiert.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/lib/favorites-api.test.ts src/messages && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/web/src/lib/favorites-api.ts apps/web/src/lib/favorites-api.test.ts apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx && test "$(pnpm exec biome lint apps/web/src/components/dashboard/widgets/favorites-widget.tsx 2>&1 | grep -cE 'favorites-widget.tsx:[0-9]+:[0-9]+ lint/')" -le 3</automated>
|
||||
</verify>
|
||||
<done>Web-Tests in src/components/dashboard, src/lib/favorites-api.test.ts und src/messages gruen (inkl. Umlaut-Waechter); tsc ohne Fehler; biome lint ohne neue Befunde (favorites-widget.tsx hoechstens die 3 vorbestehenden Warnungen); de.json und en.json tragen alle neun neuen Schluessel; CHANGELOG und Anwenderhandbuch ergaenzt.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Aufgabe 3: Browser-Nachweis am lokalen Stack (vom Orchestrator per Playwright MCP)</name>
|
||||
<what-built>Symbol-Upload je Favorit mit Entfernen, versionierte Symbol-Adresse (sofortige Aktualisierung), Abrufprobe mit deutscher Meldung fuer nicht abrufbare Logo-Adressen, Dateiloeschung beim Loeschen des Favoriten.</what-built>
|
||||
<how-to-verify>
|
||||
Durchgefuehrt vom Orchestrator, nicht vom Nutzer. Nur lokal — nie auf dem Testserver deployen.
|
||||
1. `docker compose up -d --build web api`; in `docker compose logs api` muss die Migration `20260923160000_favorite_icon_upload` als angewendet erscheinen (migrate-and-start.sh).
|
||||
2. Anmelden, Dashboard in den Bearbeitungsmodus, Favoriten-Kachel (falls keine vorhanden: hinzufuegen, einen Favoriten anlegen).
|
||||
3. Favorit bearbeiten, kleine PNG-Datei waehlen, Speichern: Das Symbol wechselt OHNE Neuladen der Seite. Nachweis ueber das gerenderte `img` (Attribut `src` endet auf `?v=<n>`, `naturalWidth > 0`) und Screenshot — NICHT per `fetch` aus der Seite messen (Fetch-Falle, siehe Memory).
|
||||
4. Erneut bearbeiten, SVG hochladen: Symbol wechselt sofort, `?v=` ist gestiegen.
|
||||
5. „Hochgeladenes Symbol entfernen“: Knopf verschwindet, Kachel zeigt wieder Logo-Adresse bzw. erkanntes Favicon.
|
||||
6. Logo-Adresse auf eine andere oeffentlich abrufbare Bildadresse aendern (z. B. von `https://github.com/favicon.ico` auf `https://www.google.com/favicon.ico`), Speichern: Symbol wechselt sofort.
|
||||
7. Logo-Adresse auf eine nicht abrufbare Adresse setzen (z. B. `https://example.invalid/logo.png`, oder eine Adresse hinter Cloudflare-Pruefung, falls der Nutzer eine nennt), Speichern: deutsche Meldung „Das Bild unter dieser Adresse konnte nicht geladen werden …“ im Formular, Formular bleibt offen, nichts gespeichert.
|
||||
8. Datei ueber 512 KB waehlen: Meldung „Die Datei ist zu groß …“; umbenannte Textdatei als .png: Meldung „Nur Bilder im Format …“.
|
||||
9. Neuen Favoriten mit gewaehlter Datei hinzufuegen: erscheint direkt mit dem hochgeladenen Symbol.
|
||||
10. Favoriten mit hochgeladenem Symbol loeschen, danach `docker compose exec api ls -la /app/user-files/favorite-icons/<userId>/`: keine Datei mit dessen Kennung mehr.
|
||||
</how-to-verify>
|
||||
<resume-signal>Orchestrator meldet „bestanden“ mit Screenshot-Pfaden oder beschreibt die Abweichung je Schritt.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → API (POST /favorites/:id/icon) | nicht vertrauenswuerdige Datei (Bytes, Name, behaupteter Typ) ueberquert hier |
|
||||
| API → Dateisystem (user-files/favorite-icons) | aus Zeilendaten abgeleiteter Pfad wird geschrieben/gelesen/geloescht |
|
||||
| API → fremder Webserver (Abrufprobe, Icon-Proxy) | serverseitiger Abruf einer vom Nutzer genannten Adresse |
|
||||
| API → Browser (GET /favorites/:id/icon) | gespeicherte, evtl. aktive Inhalte (SVG) werden ausgeliefert |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-LRR-01 | Tampering | favoriteIconAbsolutePath | high | mitigate | Dateiname nur aus Zeilen-UUID + Endung des an den Bytes ERKANNTEN Typs; Segmente nur [A-Za-z0-9-]; Ergebnis muss unter resolveFavoriteIconsDir() liegen, sonst null; originalname geht in keinen Pfad |
|
||||
| T-LRR-02 | Elevation of Privilege | GET /favorites/:id/icon mit hochgeladenem SVG | high | mitigate | Typ aus Magic Bytes/SVG-Pruefung statt file.mimetype; Auslieferung behaelt X-Content-Type-Options nosniff und CSP "default-src 'none'; sandbox"; Anzeige nur als img (kein Skript) |
|
||||
| T-LRR-03 | Information Disclosure | uploadIcon/removeUploadedIcon/getIconBytes | high | mitigate | forTenant-Bindung plus Vergleich userId UND tenantId gegen den Sitzungsnachweis, fremd/unbekannt -> 404 (nie 403, kein Existenzorakel); Tests fuer fremden Benutzer und fremden Mandanten |
|
||||
| T-LRR-04 | Denial of Service | POST /favorites/:id/icon | medium | mitigate | multer limits fileSize 512 KB, files 1 (413); zweites Netz im Dienst; genau eine Datei je Favorit (Ueberschreiben, alte Endung wird entfernt) |
|
||||
| T-LRR-05 | Information Disclosure | Cache-Control der Symbolantwort | medium | mitigate | private statt public — kein gemeinsamer Zwischenspeicher (Nginx Proxy Manager) haelt benutzerbezogene Symbole; Versionierung per ?v= macht lange Browser-Zwischenspeicherung trotzdem korrekt |
|
||||
| T-LRR-06 | Spoofing | Abrufprobe beim Speichern | low | accept | nutzt unveraendert fetchIconBytes mit bestehendem SSRF-Schutz (isPublicHttpUrl, Weiterleitungswaechter, 4 s, 1 MB) — derselbe Abruf, den GET /favorites/:id/icon ohnehin ausloest; keine neue Angriffsflaeche, keine Umgehung von Bot-Sperren |
|
||||
| T-LRR-07 | Denial of Service | Datei-Leichen nach Loeschen eines Widgets/Reiters | low | accept | FavoriteLink faellt dort per Datenbank-Kaskade, am Dienst vorbei; Dateien bleiben liegen, sind ohne Zeile nie abrufbar, je Favorit hoechstens 512 KB im eigenen Benutzerordner. Einzelloeschung raeumt auf (Vorgabe erfuellt); Restrisiko im SUMMARY benennen |
|
||||
| T-LRR-08 | Repudiation | halbe Zustaende Upload/Loeschen | low | mitigate | Upload: Datei zuerst, Zeile danach, bei Zeilenfehler neue Datei zurueckgenommen; Loeschen: Zeile zuerst, Dateifehler protokolliert und geschluckt (Muster T-HK4-04) |
|
||||
| T-LRR-SC | Tampering | Paketinstallationen | low | accept | keine neuen Pakete; ICO/SVG-Erkennung ohne Abhaengigkeit |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Nach allen Aufgaben (Ausgangslage vom Planer an b03ffb5 gemessen: api src/favorites 49/49 gruen, web favorites-widget + src/messages 23/23 gruen, beide tsc sauber, biome lint auf den Favoriten-Dateien 3 vorbestehende Warnungen in favorites-widget.tsx):
|
||||
- `pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard` gruen
|
||||
- `pnpm --filter @tessera/web exec vitest run src/components/dashboard src/lib/favorites-api.test.ts src/messages` gruen
|
||||
- `pnpm --filter @tessera/api exec tsc --noEmit` und `pnpm --filter @tessera/web exec tsc --noEmit` sauber
|
||||
- `pnpm exec biome lint` auf allen beruehrten TS-Dateien: keine neuen Befunde
|
||||
- Browser-Nachweis (Aufgabe 3) bestanden
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Geaenderte Logo-Adresse und hochgeladenes Symbol erscheinen ohne Seiten-Neuladen (versionierte Adresse).
|
||||
- Nicht abrufbare Logo-Adresse wird mit deutscher Meldung im Formular abgewiesen.
|
||||
- Upload (PNG, JPEG, GIF, WebP, ICO, SVG, ≤ 512 KB) im Bearbeitungs- und Hinzufuegen-Formular; Entfernen faellt zurueck; Loeschen des Favoriten entfernt die Datei.
|
||||
- Besitzpruefung Benutzer + Mandant fuer alle neuen Wege, 404 fuer Fremdes.
|
||||
- Texte de/en vollstaendig, Umlaut-Waechter gruen; Changelog, Anwender- und Betriebsanleitung ergaenzt.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260923-lrr-favoriten-eigenes-symbol-hochladen-und-s/260923-lrr-SUMMARY.md` when done — mit Restrisiko T-LRR-07 (Datei-Leichen bei Widget-/Reiter-Loeschung) als offenem Punkt.
|
||||
</output>
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
---
|
||||
phase: quick-260923-lrr
|
||||
plan: 01
|
||||
subsystem: api+web (favorites)
|
||||
tags: [nestjs, prisma, nextjs, upload, cache-busting, favorites]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "FavoriteLink.uploadedIconMime/iconVersion (Migration 20260923160000)"
|
||||
- "favorite-icon-files.ts: Erkennung/Pfadbildung/Best-effort-Loeschung fuer hochgeladene Favoriten-Symbole"
|
||||
- "FavoritesService.uploadIcon/removeUploadedIcon, Vorrang in getIconBytes, Abrufprobe in create/update"
|
||||
- "POST/DELETE /favorites/:id/icon"
|
||||
- "Web: uploadFavoriteIcon/removeFavoriteIcon/FavoriteRequestError in favorites-api.ts"
|
||||
- "FavoritesWidget: versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Fehlermeldung im Formular"
|
||||
- "DashboardService.removeWidget/deleteDashboard raeumen Symboldateien kaskadiert geloeschter Favoriten auf (T-LRR-07)"
|
||||
affects: [dashboard, favorites]
|
||||
|
||||
actuals:
|
||||
tokens: 31900
|
||||
tasks: 2
|
||||
commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Dateiablage nach Muster dashboard-images.service.ts: user-files/favorite-icons/<userId>/<id>.<ext>, Dateiname immer servergeneriert"
|
||||
- "Versionszaehler (iconVersion) statt updatedAt fuer Cache-Busting einer Bild-URL"
|
||||
- "Abrufprobe vor dem Speichern einer externen URL statt stiller Speicherung eines nicht ladbaren Wertes"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/favorites/favorite-icon-files.ts
|
||||
- apps/api/src/favorites/favorite-icon-files.spec.ts
|
||||
- apps/api/src/favorites/favorites.controller.spec.ts
|
||||
- apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql
|
||||
- apps/web/src/lib/favorites-api.test.ts
|
||||
modified:
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/favorites/favorites.controller.ts
|
||||
- apps/api/src/dashboard/dashboard.service.ts
|
||||
- apps/web/src/lib/favorites-api.ts
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
|
||||
key-decisions:
|
||||
- "iconVersion statt updatedAt als Cache-Bust-Quelle, weil Umsortieren/Titelaenderung sonst jedes Symbol neu laden liessen"
|
||||
- "Hochgeladenes Symbol hat Vorrang vor iconUrl; fehlt die Datei trotz gesetztem Typ, faellt der Dienst protokolliert auf iconUrl zurueck statt 404"
|
||||
- "Abrufprobe nur bei neuer/abweichender iconUrl, nicht bei jedem Speichern — vermeidet unnoetige Netzwerkaufrufe"
|
||||
- "T-LRR-07 (im Plan als Restrisiko akzeptiert) zusaetzlich geschlossen: DashboardService raeumt Symboldateien kaskadiert geloeschter Favoriten jetzt best effort auf"
|
||||
|
||||
requirements-completed: [QUICK-260923-lrr]
|
||||
|
||||
duration: 45min
|
||||
completed: 2026-09-23
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260923-lrr: Favoriten — eigenes Symbol hochladen, Zwischenspeicher nach Änderung erneuern (Summary)
|
||||
|
||||
**Favoriten-Symbole bekommen eine versionierte Adresse (`?v=<iconVersion>`), ein hochgeladenes eigenes Symbol (PNG/JPEG/GIF/WebP/ICO/SVG, ≤512 KB) hat Vorrang vor der Logo-Adresse, und eine serverseitige Abrufprobe weist eine nicht ladbare Logo-Adresse beim Speichern mit einer deutschen Meldung ab statt sie still zu übernehmen.**
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Der Nutzer meldete: Eine eigene Logo-Adresse eintragen „bewirkt nichts“ (Verdacht: Cloudflare-Prüfung vor dem Bild), und selbst eine tatsächlich abrufbare neue Adresse erschien wegen eines 24-Stunden-Zwischenspeichers einen Tag lang nicht. Beide Ursachen wurden am Code bestätigt (siehe `260923-lrr-PLAN.md`, Abschnitt „Ausgangslage“) und in diesem Lauf behoben; zusätzlich lässt sich jetzt ein eigenes Symbol hochladen.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Dauer:** ca. 45 Minuten
|
||||
- **Aufgaben:** 2 von 2 geplanten Aufgaben ausgeführt (Aufgabe 3, Browser-Nachweis, ist ein separater Checkpoint und wird vom Orchestrator im Anschluss durchgeführt — siehe unten)
|
||||
- **Geänderte/neue Dateien:** 19
|
||||
|
||||
## Aufgaben-Commits
|
||||
|
||||
1. **Aufgabe 1: API — Symbol hochladen/entfernen, Vorrang, Versionszähler, Abrufprobe** — `7704372` (feat)
|
||||
2. **Aufgabe 2: Web — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Texte** — `61f95c8` (feat)
|
||||
|
||||
Beide Commits liegen direkt auf `main` (Orchestrator-Vorgabe für diesen Lauf: kein Worktree, `git.allow_default_branch_commits: true` in `.planning/config.json`).
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Zwischenspeicher-Fehler behoben:** `GET /favorites/:id/icon` sendet jetzt `Cache-Control: private, max-age=86400` (statt `public`); die Bildadresse im Widget trägt `?v=<iconVersion>`, das bei jeder Änderung der Symbolquelle um eins steigt — ein geändertes Symbol erscheint jetzt ohne Neuladen der Seite.
|
||||
- **Cloudflare-Fall behoben:** Eine neue, explizit eingetragene Logo-Adresse wird beim Speichern einmal serverseitig abgerufen (`assertIconUrlLoadable`, 4 s Zeitgrenze über den vorhandenen `IconDiscoveryService`); scheitert der Abruf, antwortet die API mit 422 und einer deutschen Meldung im Formular — nichts wird gespeichert. Keine Umgehung von Bot-Sperren.
|
||||
- **Eigenes Symbol hochladen:** Neue Felder `FavoriteLink.uploadedIconMime`/`iconVersion` (Migration `20260923160000_favorite_icon_upload`), Ablage unter `user-files/favorite-icons/<userId>/<id>.<ext>` (Dateiname immer servergeneriert, Muster `dashboard-images.service.ts`), Erkennung von PNG/JPEG/GIF/WebP/ICO/SVG an den Bytes (`favorite-icon-files.ts`). Vorrang vor `iconUrl` beim Ausliefern. Hochladen und Entfernen im Bearbeitungs- und Hinzufügen-Formular des Widgets.
|
||||
- **T-LRR-07 zusätzlich geschlossen** (im Plan als Restrisiko mit Disposition „accept“ eingetragen, siehe unten): Löscht ein Nutzer ein ganzes Widget oder einen Dashboard-Reiter, entfernt die Datenbank-Kaskade (`onDelete: Cascade`) die betroffenen `FavoriteLink`-Zeilen, ohne den Favoriten-Dienst zu durchlaufen — dessen Datei-Aufräumung griff dort bisher nicht. `DashboardService.removeWidget`/`deleteDashboard` merken sich jetzt vor der Kaskade, welche Favoriten ein hochgeladenes Symbol tragen, und entfernen deren Dateien danach best effort (nie blockierend für das Löschen selbst).
|
||||
|
||||
## Dateien erstellt/geändert
|
||||
|
||||
**API:**
|
||||
- `apps/api/prisma/schema.prisma` — `FavoriteLink.uploadedIconMime`/`iconVersion`
|
||||
- `apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql` — neue Migration, lokal angewendet (siehe Verifikation)
|
||||
- `apps/api/src/favorites/favorite-icon-files.ts` (neu) — Erkennung, Pfadbildung, best-effort Dateientfernung
|
||||
- `apps/api/src/favorites/favorite-icon-files.spec.ts` (neu) — 15 Tests
|
||||
- `apps/api/src/favorites/favorites.service.ts` — `uploadIcon`/`removeUploadedIcon`, Abrufprobe, Vorrang in `getIconBytes`
|
||||
- `apps/api/src/favorites/favorites.service.spec.ts` — 49 Tests (24 neu)
|
||||
- `apps/api/src/favorites/favorites.controller.ts` — `POST`/`DELETE /favorites/:id/icon`, `Cache-Control: private`
|
||||
- `apps/api/src/favorites/favorites.controller.spec.ts` (neu) — 7 Tests
|
||||
- `apps/api/src/dashboard/dashboard.service.ts` — T-LRR-07-Aufräumung in `removeWidget`/`deleteDashboard`
|
||||
- `apps/api/src/dashboard/dashboard.service.spec.ts` — 66 Tests (5 neu)
|
||||
- `docs/anleitung-betrieb.md` — `user-files/favorite-icons/` ergänzt
|
||||
|
||||
**Web:**
|
||||
- `apps/web/src/lib/favorites-api.ts` — `FavoriteRequestError`, `uploadFavoriteIcon`/`removeFavoriteIcon`, `FAVORITE_ICON_MAX_BYTES`
|
||||
- `apps/web/src/lib/favorites-api.test.ts` (neu) — 10 Tests
|
||||
- `apps/web/src/components/dashboard/widgets/favorites-widget.tsx` — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Fehlermeldung im Formular
|
||||
- `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx` — 27 Tests (10 neu, 1 bestehender Test an `?v=0` angepasst)
|
||||
- `apps/web/src/messages/de.json`/`en.json` — neun neue Texte unter `widgets.favorites`
|
||||
- `CHANGELOG.md`, `docs/anleitung-anwender.md` — ergänzt
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
- **iconVersion statt updatedAt:** `updatedAt` scheidet als Versionsquelle aus, weil Umsortieren oder eine Titeländerung sonst jedes Symbol neu laden ließen. `iconVersion` steigt gezielt nur bei einer Änderung der Symbolquelle.
|
||||
- **Vorrang und Rückfall:** Ein hochgeladenes Symbol hat Vorrang vor `iconUrl`. Fehlt die Datei trotz gesetztem Typ (praktisch nur bei einer manuellen Änderung am Dateisystem denkbar), protokolliert der Dienst eine Warnung und fällt auf `iconUrl` zurück, statt 404 zu werfen — das entspricht dem im Plan festgelegten Verhalten.
|
||||
- **Abrufprobe nur bei Änderung:** Die Probe läuft nur, wenn eine neue oder gegenüber der Zeile abweichende `iconUrl` übergeben wird — nicht bei jedem Speichern. Vermeidet unnötige Netzwerkaufrufe beim bloßen Ändern von Titel oder Reihenfolge.
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
### Vom Orchestrator angeordnete Zusatzanforderung (kein Regelabweichungsfund, sondern expliziter Auftrag)
|
||||
|
||||
**1. T-LRR-07 geschlossen — Datei-Leichen nach Kaskadenlöschung eines Widgets/Reiters**
|
||||
- **Gefunden während:** vor Aufgabe 1, auf ausdrückliche Anweisung des Orchestrators (Constraint „closes the plan's accepted gap T-LRR-07“)
|
||||
- **Befund:** `DashboardService.removeWidget` (einzelnes Widget) und `DashboardService.deleteDashboard` (ganzer Reiter) löschen `WidgetInstance`-Zeilen; `FavoriteLink.widgetId` trägt `onDelete: Cascade`, wodurch die Datenbank die zugehörigen Favoriten-Zeilen mitlöscht, OHNE `FavoritesService.remove()` zu durchlaufen — dessen Datei-Aufräumung griff dort also nicht. Der Plan hatte dies als Restrisiko T-LRR-07 mit Disposition „accept“ eingetragen (Dateien bleiben liegen, sind ohne Zeile nie abrufbar, höchstens 512 KB je Favorit).
|
||||
- **Fix:** `favorite-icon-files.ts` bekam eine zusätzliche, nie werfende Funktion `removeFavoriteIconFileBestEffort(userId, id, mime)`. `DashboardService.removeWidget`/`deleteDashboard` lesen VOR der Löschung die betroffenen Favoriten mit gesetztem `uploadedIconMime` (ein reiner Lesezugriff, außerhalb der Löschtransaktion) und entfernen NACH erfolgreicher Löschung deren Dateien best effort — ein Dateifehler wird protokolliert und geschluckt, er kann das Löschen des Widgets/Reiters nie verhindern oder zurücknehmen (Muster T-HK4-04).
|
||||
- **Dateien geändert:** `apps/api/src/favorites/favorite-icon-files.ts`, `apps/api/src/dashboard/dashboard.service.ts`, `apps/api/src/dashboard/dashboard.service.spec.ts`
|
||||
- **Tests:** 5 neue Tests in `dashboard.service.spec.ts` (Datei wird entfernt, fehlende Datei wird geschluckt, Favoriten ohne Symbol lösen keinen Dateizugriff aus — für beide Methoden je Fall bzw. anteilig)
|
||||
- **Verifikation:** `pnpm --filter @tessera/api exec vitest run src/dashboard src/favorites` grün (218 Tests)
|
||||
- **Commit:** `7704372` (Teil des Aufgabe-1-Commits, da API-seitig und eng an `favorite-icon-files.ts` gekoppelt)
|
||||
|
||||
**Restrisiko nach diesem Fix:** Das Löschen eines EINZELNEN Favoriten über `DELETE /favorites/:id` sowie die beiden neuen Wege raumen die Datei immer auf. Ein denkbarer Rest bleibt nur, wenn ein Dateisystemfehler ausgerechnet beim best-effort-Entfernen auftritt (protokolliert, nie blockierend) — dasselbe Restrisiko, das die Bilderrahmen-Funktion (`dashboard-images.service.ts`, T-HK4-04) für ihre Bilder ebenfalls bewusst trägt.
|
||||
|
||||
---
|
||||
|
||||
**Gesamt:** 1 Zusatzanforderung umgesetzt (kein Regel-1/2/3-Fund im eigentlichen Sinn, da vom Orchestrator vorgegeben statt während der Ausführung entdeckt — inhaltlich entspricht die Umsetzung Regel 2, fehlende sicherheits-/korrektheitsrelevante Funktionalität).
|
||||
**Auswirkung auf den Plan:** Kein Scope Creep über die Orchestrator-Vorgabe hinaus; alle übrigen Aufgaben wurden wie im Plan spezifiziert umgesetzt.
|
||||
|
||||
## Verifikation (durchgeführt)
|
||||
|
||||
```
|
||||
pnpm --filter @tessera/api exec prisma generate # OK
|
||||
pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard # 218/218 grün
|
||||
pnpm --filter @tessera/api exec tsc --noEmit # sauber
|
||||
pnpm exec biome lint apps/api/src/favorites apps/api/src/dashboard # keine Befunde
|
||||
pnpm --filter @tessera/web exec vitest run src/components/dashboard \
|
||||
src/lib/favorites-api.test.ts src/messages # 276/276 grün
|
||||
pnpm --filter @tessera/web exec tsc --noEmit # sauber
|
||||
pnpm exec biome lint apps/web/src/lib/favorites-api.ts \
|
||||
apps/web/src/lib/favorites-api.test.ts \
|
||||
apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx # keine Befunde
|
||||
biome lint favorites-widget.tsx # 3 Befunde (alle vorbestehend, wie vom Plan erlaubt)
|
||||
grep -rc "as unknown as" apps/api/src # ≤ 29 (Plan-Obergrenze eingehalten)
|
||||
```
|
||||
|
||||
Migration `20260923160000_favorite_icon_upload` wurde lokal über die Container-IP der `db` mit `prisma migrate deploy` angewendet (Vorgabe des Orchestrators: `tessera:tessera_dev`, kein Host-Port).
|
||||
|
||||
## Bekannte Stubs
|
||||
|
||||
Keine — jede neu geschriebene Funktion ist mit echten Daten verdrahtet, keine Platzhalter.
|
||||
|
||||
## Aufgabe 3 — Browser-Nachweis (noch offen, Orchestrator)
|
||||
|
||||
Aufgabe 3 des Plans (`checkpoint:human-verify`, `gate="blocking"`) ist ein separater Verifikationsschritt am lokalen Docker-Stack (Symbol hochladen/entfernen, Cache-Bust im Browser, Abrufprobe, Dateigrößen-/Typgrenzen, Dateiaufräumung nach Löschen) und wird laut Auftrag NICHT von diesem Ausführungslauf durchgeführt — das übernimmt der Orchestrator im Anschluss per Playwright MCP. Diese SUMMARY dokumentiert ausschließlich die abgeschlossenen Aufgaben 1 und 2.
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
- Orchestrator: Aufgabe 3 (Browser-Nachweis) durchführen, siehe `260923-lrr-PLAN.md`.
|
||||
- Kein Blocker für Aufgabe 3 aus Sicht der API/Web-Implementierung — alle automatisierten Prüfungen sind grün, die Migration ist lokal bereits angewendet.
|
||||
|
||||
---
|
||||
*Quick-Aufgabe: 260923-lrr*
|
||||
*Abgeschlossen (Aufgaben 1–2): 2026-09-23*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle in dieser Summary genannten neuen Dateien sowie beide Commits (`7704372`, `61f95c8`) wurden gegen das Repository geprüft und gefunden.
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
---
|
||||
quick_id: 260924-h7x
|
||||
type: quick
|
||||
wave: 1
|
||||
autonomous: true
|
||||
files_modified:
|
||||
- apps/web/src/app/globals.css
|
||||
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts (new)
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/*.test.tsx
|
||||
- apps/web/src/components/layout/header.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-tabs.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/messages/de.json, en.json
|
||||
- CHANGELOG.md
|
||||
---
|
||||
|
||||
# Quick 260924-h7x — Proxmox-Seite: Status-Design mit Tiefe; Dashboard-Reiter in die Kopfzeile
|
||||
|
||||
Nutzerauftrag (24.09.): „Kennzeichnen als offline & verwaist. Das Proxmox-Modul scheint zu
|
||||
funktionieren, aber es gefällt mir optisch gar nicht. Benutze den Design-Skill und überarbeite
|
||||
etwas — mehr Tiefe, Farben nach Status etc. Die Reiter sind da, aber die müssen woanders hin,
|
||||
die nehmen zu viel Platz ein. Auch mit Design.“
|
||||
|
||||
Die frühere Sperre „KEIN Umbau der Proxmox-Modulseite“ (23.09.) ist damit durch den Nutzer
|
||||
selbst aufgehoben.
|
||||
|
||||
Design-Plan vom Orchestrator (frontend-design-Skill) — verbindlich, nicht neu erfinden.
|
||||
Keine neuen Pakete. App-Texte deutsch in Sie-Form, alle Schlüssel in de.json UND en.json.
|
||||
Keine Großbuchstaben-Etiketten (kein `uppercase`, kein `tracking-widest`), keine
|
||||
Mittelpunkt-Ketten „A · B · C“ in neuen Texten, kein „→“ an Knöpfen.
|
||||
|
||||
## Design-Tokens (in `globals.css`, `:root` und `.dark`)
|
||||
|
||||
Statusfarben als OKLCH-Variablen plus Tailwind-Abbildung in `@theme inline`
|
||||
(`--color-status-ok` usw., damit `bg-status-ok`, `text-status-ok`, `border-status-ok/40` gehen):
|
||||
|
||||
| Token | hell | dunkel | Bedeutung |
|
||||
|---|---|---|---|
|
||||
| `--status-ok` | `oklch(0.62 0.15 152)` | `oklch(0.72 0.15 152)` | in Ordnung |
|
||||
| `--status-warn` | `oklch(0.72 0.15 70)` | `oklch(0.80 0.14 75)` | Warnung |
|
||||
| `--status-down` | `oklch(0.58 0.21 27)` | `oklch(0.68 0.19 27)` | nicht erreichbar / Fehler |
|
||||
| `--status-idle` | `oklch(0.62 0.02 260)` | `oklch(0.60 0.02 260)` | noch nicht abgefragt |
|
||||
| `--status-orphan` | `oklch(0.55 0.01 260)` | `oklch(0.52 0.01 260)` | offline & verwaist |
|
||||
| `--well` | `oklch(0.975 0.003 260)` | `oklch(0.235 0.01 260)` | eingelassene Messfelder |
|
||||
|
||||
Kontrast prüfen: Text in Statusfarbe auf `--card` muss ≥ 4.5:1 (Fließtext) bzw. ≥ 3:1
|
||||
(große/fette Zahlen) erreichen — sonst Helligkeit anpassen und die gemessene Zahl in der
|
||||
SUMMARY nennen.
|
||||
|
||||
## Task 1 — Statuslogik als reine Funktionen (TDD)
|
||||
|
||||
Neue Datei `components/proxmox-status.ts`:
|
||||
|
||||
- `serverHealth(server): 'ok' | 'warn' | 'down' | 'idle' | 'orphan'`
|
||||
- `!server.isActive` → `orphan` (VORRANG vor allem anderen — „offline & verwaist“, auch wenn
|
||||
alte Messwerte im Zwischenlager liegen)
|
||||
- kein `status` oder `status.lastPolledAt === null` → `idle`
|
||||
- `!status.reachable` → `down`
|
||||
- erreichbar + irgendein Messwert über Warnschwelle → `warn`
|
||||
- sonst `ok`
|
||||
- Schwellen als EINE benannte Konstante `THRESHOLDS`: Last/Arbeitsspeicher/Datenspeicher
|
||||
warn ≥ 0.80, kritisch ≥ 0.92; PBS letzte Sicherung älter als 26 h → warn; `lastVerifyState`
|
||||
ungleich `ok` (und nicht null) → warn; PMG `virusCount > 0` → warn.
|
||||
- `meterLevel(fraction | null): 'ok' | 'warn' | 'crit' | 'unknown'` für die Balken.
|
||||
- `formatAge(epochSeconds|iso, now)` → „vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“ über
|
||||
`Intl.RelativeTimeFormat('de')` bzw. next-intl — keine neue Abhängigkeit.
|
||||
- Tests für jede Verzweigung inkl. `null`-Werte (unbekannt ≠ 0, nie Warnung aus `null`).
|
||||
|
||||
## Task 2 — Proxmox-Seite und Karte neu gestalten
|
||||
|
||||
**Seitenkopf:** Titel „Proxmox“ links, darunter die Beschreibung. Rechts „Jetzt aktualisieren“
|
||||
(nur Admins, wie heute) als richtiger Knopf mit Kreispfeil-Symbol (dreht sich während des Laufs;
|
||||
`motion-reduce:animate-none`) und „Einstellungen“ als ruhiger Textlink. Breite `max-w-5xl`.
|
||||
|
||||
**Gesundheitsbalken (das eine prägnante Element der Seite):** direkt unter dem Kopf ein
|
||||
waagrechter Balken, 8 px hoch, voll gerundet, anteilig in Segmente je Zustand geteilt
|
||||
(Reihenfolge down, warn, ok, idle, orphan; leere Zustände entfallen). Darunter eine Zeile mit
|
||||
Legende: farbiger Punkt + Zahl + Wort („2 in Ordnung“, „1 nicht erreichbar“, „1 offline &
|
||||
verwaist“ …, ICU-Plural). Segmente `role="img"` mit `aria-label`, das die Zusammenfassung vorliest.
|
||||
Keine Zahl-in-riesig-plus-Verlauf-Heldenkachel.
|
||||
|
||||
**Kartenraster:** `grid gap-4 lg:grid-cols-2`. Sortierung: down, warn, ok, idle, orphan, darin
|
||||
`position`. (Die Einstellungsseite behält ihre Reihenfolge.)
|
||||
|
||||
**Karte (Tiefe über Status, nicht Einheits-Schatten):**
|
||||
- Karte `rounded-xl bg-card` mit Rand `border-border`, links eine 4 px breite Statusleiste
|
||||
(absolut positioniert, volle Höhe, Farbe = Status).
|
||||
- Schatten zweischichtig und im Statuston getönt, z. B.
|
||||
`shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-x)]` — bei `orphan` KEIN
|
||||
farbiger Schatten, stattdessen gestrichelter Rand (`border-dashed`), Inhalt
|
||||
`opacity-70 saturate-50`.
|
||||
- Kopfzeile: Produktsymbol (kleines inline-SVG je Typ: PVE = Server-Einschübe, PBS =
|
||||
Archivkiste/Datenträger, PMG = Briefumschlag) in einem 32 px Feld mit `bg-well`, daneben Name
|
||||
(fett) und darunter Produktname ausgeschrieben („Virtualisierung“, „Datensicherung“,
|
||||
„Mail-Gateway“) plus Adresse klein und gedämpft. Rechts eine Statuspille: Punkt + Wort
|
||||
(„In Ordnung“, „Warnung“, „Nicht erreichbar“, „Noch nicht abgefragt“, „Offline & verwaist“),
|
||||
Hintergrund Statusfarbe /12, Text in Statusfarbe. Bei `ok` pulsiert der Punkt EINMAL beim
|
||||
Laden nicht — keine Dauer-Animation.
|
||||
- Fuß der Karte: „Letzte Abfrage vor 4 Min.“ (relativ, `title` mit exaktem Zeitpunkt).
|
||||
|
||||
**Messbereich je Produkt, als eingelassene Felder** (`bg-well`, `rounded-lg`,
|
||||
`shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]`):
|
||||
- **PVE:** oben zwei Kennzahlen nebeneinander: laufende Gäste (groß, `tabular-nums`) und
|
||||
gestoppte Gäste (gedämpft), Knotenzahl klein. Darunter je Knoten ein „Einschub“: Knotenname
|
||||
links, rechts zwei schmale Balken „Prozessor“ und „Arbeitsspeicher“ mit Prozentzahl
|
||||
(`tabular-nums`) und bei Speicher „12,0 / 64,0 GB“ als `title`/kleine Zeile. Balkenfarbe
|
||||
nach `meterLevel`. Unbekannter Wert: Balken schraffiert/leer + Text „unbekannt“, NIE 0 %.
|
||||
Deutsche Zahlformate (Komma) über `Intl.NumberFormat('de-DE')`.
|
||||
- **PBS:** je Datenspeicher ein Einschub: Name, Füllstandsbalken mit „1,2 / 4,0 TB“, darunter
|
||||
„Letzte Sicherung vor 5 Std.“ (warn-Farbe, wenn > 26 h; „noch keine Sicherung“ gedämpft) und
|
||||
Prüfstatus als kleine Pille (ok grün, sonst warn, null „unbekannt“ grau).
|
||||
- **PMG:** vier Zahlfelder im 2×2-Raster: Eingehend, Ausgehend, Spam, Viren; Viren > 0 in
|
||||
down-Farbe, sonst normal. `null` → „unbekannt“.
|
||||
|
||||
**Zustände ohne Messwerte:**
|
||||
- `down`: roter Hinweisblock im Well (`bg-status-down/8`, Rand links in Statusfarbe) mit der
|
||||
bekannten Fehlermeldung (`errors.*`) und darunter gedämpft „Zuletzt erreichbar: vor 2 Tagen“
|
||||
bzw. nichts, wenn nie. `errorDetail` klein und gedämpft in eigener Zeile, nicht in Klammern
|
||||
an den Satz gehängt.
|
||||
- `idle`: ruhiger Text wie heute (Admin-/Nicht-Admin-Variante bleibt, 260923-le6).
|
||||
- `orphan`: Text „Dieser Server ist deaktiviert und wird nicht mehr abgefragt.“ plus für Admins
|
||||
der Hinweis, dass er in den Einstellungen wieder aktiviert werden kann (Link). Alte Messwerte
|
||||
werden bei `orphan` NICHT angezeigt.
|
||||
|
||||
**Leer-, Lade-, Fehlerzustand der Seite:** Leerzustand als Well mit Serversymbol und Satz +
|
||||
Link (Admins); Laden als zwei Skelett-Karten (`animate-pulse`, `motion-reduce:animate-none`).
|
||||
|
||||
Tests: bestehende ServerCard-/Seitentests anpassen (Texte/Strukturen), neue Tests für
|
||||
orphan (Vorrang, keine alten Werte), Sortierung, Gesundheitsbalken-Zusammenfassung, unbekannte
|
||||
Werte als „unbekannt“. Die Nur-Admin-Regeln aus 260923-le6 müssen weiter getestet sein.
|
||||
|
||||
## Task 3 — Dashboard-Reiter in die Kopfzeile
|
||||
|
||||
Heute belegt `DashboardTabs` eine eigene Zeile über dem Raster (`app/(portal)/page.tsx`), die
|
||||
Kopfzeilenmitte (`components/layout/header.tsx`) zeigt nur den Text „Startseite“.
|
||||
|
||||
- Kopfzeile bekommt in der Mitte einen Einhängepunkt `<div id="header-center-slot">`. Auf der
|
||||
Startseite (`pathname === '/'`) entfällt der Text „Startseite“; auf allen anderen Seiten bleibt
|
||||
alles wie heute.
|
||||
- `DashboardTabs` rendert per `createPortal` in diesen Einhängepunkt (sobald er im DOM ist —
|
||||
`useEffect` + State; Rückfall: solange kein Einhängepunkt da ist, nichts rendern). Die eigene
|
||||
Zeile über dem Raster entfällt ersatzlos.
|
||||
- Gestaltung als kompakter Umschalter mit Tiefe: eine eingelassene Spur (`bg-muted`, `rounded-lg`,
|
||||
`p-0.5`, `shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)]`, Höhe 32 px), darin die Reiter als
|
||||
Textknöpfe (`text-sm`, `px-3`, `h-7`); der aktive Reiter liegt erhaben darauf (`bg-card`,
|
||||
`shadow-sm`, `font-medium`, Text `foreground`), inaktive gedämpft mit Hover. Keine
|
||||
Unterstreichung, kein Gelb-Flächen-Reiter.
|
||||
- Viele Reiter: Spur höchstens `max-w-[min(56vw,720px)]`, waagrecht scrollbar ohne sichtbare
|
||||
Leiste, an den Rändern weich ausgeblendet (`mask-image` nur wenn überläuft), aktiver Reiter
|
||||
wird ins Bild gescrollt.
|
||||
- Bearbeitungsmodus: Umbenennen/Löschen/Ziehen/Anlegen bleiben funktional gleich (alle
|
||||
bestehenden Tests grün halten bzw. nur Selektoren anpassen). „+“ als 28-px-Rundknopf am Ende
|
||||
der Spur (immer sichtbar, wie heute der Anlegen-Knopf sichtbar ist — Verhalten beibehalten).
|
||||
Löschen-Kreuz erscheint im Bearbeitungsmodus klein im Reiter.
|
||||
- Genau EIN Reiter: Umschalter trotzdem zeigen (sonst weiß niemand, dass es Reiter gibt), aber
|
||||
nur mit „+“ daneben.
|
||||
- Mobil (< md): die Spur darf die Kopfzeilenmitte füllen; Logo-Schriftzug bleibt, Aktionen rechts
|
||||
bleiben erreichbar — nichts darf die Kopfzeile sprengen (overflow prüfen).
|
||||
- Tastatur: Pfeiltasten links/rechts zwischen Reitern (`role="tablist"`/`tab`, `aria-selected`),
|
||||
sichtbarer Fokusring.
|
||||
- `navigation "Dashboard-Reiter"`-Beschriftung bleibt als `aria-label`.
|
||||
|
||||
Tests: dashboard-tabs.test.tsx an Portal anpassen (Einhängepunkt im Test anlegen), neuer Test:
|
||||
Kopfzeile zeigt „Startseite“ nicht auf `/`, aber auf anderen Pfaden; Pfeiltasten.
|
||||
|
||||
## Tore
|
||||
|
||||
- `pnpm --filter web exec vitest run` komplett grün
|
||||
- `pnpm turbo run type-check lint` grün, Biome-Warnungen web nicht mehr als 53
|
||||
- CHANGELOG „Unveröffentlicht“: je ein Eintrag unter „Neu“/„Geändert“ in Nutzersprache
|
||||
- Browser-Nachweis macht der Orchestrator (hell UND dunkel, 1400 px und 390 px Breite)
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
---
|
||||
quick_id: 260924-h7x
|
||||
phase: quick
|
||||
plan: 260924-h7x
|
||||
subsystem: web / proxmox-modul, dashboard, kopfzeile
|
||||
status: complete
|
||||
tags: [proxmox, design, statusfarben, dashboard-reiter, kopfzeile, a11y]
|
||||
requires: [quick-260923-dhh (Proxmox-Modul), quick-260923-le6 (Nur-Admin-Regeln), quick-260923-ad9 (Dashboard-Reiter)]
|
||||
provides:
|
||||
- Statuslogik proxmox-status.ts (serverHealth, THRESHOLDS, meterLevel, formatAge, Sortierung, Zusammenfassung)
|
||||
- Statusfarben-Tokens --status-* / --status-*-fg / --well in globals.css
|
||||
- Einhaengepunkt header-center-slot in der Kopfzeile
|
||||
affects:
|
||||
- apps/web/src/app/(portal)/modules/proxmox/*
|
||||
- apps/web/src/components/dashboard/dashboard-tabs.tsx
|
||||
- apps/web/src/components/layout/header.tsx
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Statusklassen als statische Tailwind-Zeichenketten (status-styles.ts), damit Tailwind sie findet
|
||||
- Tests mit echtem NextIntlClientProvider + de.json statt Uebersetzungs-Attrappe (ICU-Plural, Zahlformate mitgeprueft)
|
||||
- createPortal in einen Kopfzeilen-Einhaengepunkt, Rueckfall = nichts rendern
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.test.ts
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/status-styles.ts
|
||||
- apps/web/src/components/layout/header-slot.ts
|
||||
modified:
|
||||
- apps/web/src/app/globals.css
|
||||
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-tabs.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-tabs.test.tsx
|
||||
- apps/web/src/components/layout/header.tsx
|
||||
- apps/web/src/components/layout/header.test.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/messages/umlaut-dictionary.ts
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- Statusfarben bekommen eine Textvariante --status-*-fg; die Flaechentoene aus dem Plan bleiben fuer Balken, Punkte und Leisten, erreichen als Schrift auf Weiss aber nur 2,6 bis 4,9:1
|
||||
- Bei „offline & verwaist“ werden nur Symbol und Name gedaempft; gedaempfte muted-Schrift fiele unter 3:1
|
||||
- Der „+“-Knopf der Reiter bleibt wie bisher nur im Bearbeitungsmodus sichtbar, sitzt aber ausserhalb der scrollenden Spur („immer sichtbar“ = nie weggescrollt)
|
||||
- Pfeiltasten verschieben nur den Fokus, gewaehlt wird mit Eingabe/Leertaste (ein Reiterwechsel laedt das ganze Dashboard)
|
||||
- Ziehhinweis als sr-only-Beschreibung der Spur plus Tooltip statt eigener Zeile
|
||||
metrics:
|
||||
duration: 16min
|
||||
completed: 2026-09-24
|
||||
tasks: 3
|
||||
files: 19
|
||||
plan_head_before: 3d266418fc83f4b0e5f5240f0fcd5017aebe0d10
|
||||
actuals:
|
||||
tokens: 35900
|
||||
tasks: 3
|
||||
commits: 4
|
||||
---
|
||||
|
||||
# Quick 260924-h7x: Proxmox-Seite mit Status-Design, Dashboard-Reiter in der Kopfzeile
|
||||
|
||||
Die Proxmox-Seite zeigt den Zustand jetzt über Farbe und Tiefe: oben ein Gesundheitsbalken mit Legende, darunter Karten, sortiert nach Zustand, mit Statusleiste, im Statuston getöntem Schatten und Statuspille. Die Messwerte liegen in eingelassenen Feldern mit Balken, relativen Zeitangaben und deutschem Zahlformat. Deaktivierte Server erscheinen als „Offline & verwaist“, ohne veraltete Messwerte. Die Dashboard-Reiter sitzen als kompakter Umschalter per Portal in der Mitte der Kopfzeile.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Task 1 — Statuslogik (TDD, 0fa7ce0).** `proxmox-status.ts` enthält nur reine Funktionen:
|
||||
- `serverHealth`: `orphan` hat Vorrang vor allem anderen, dann `idle`, `down`, `warn`, `ok`.
|
||||
- `THRESHOLDS` ist die einzige Quelle für die Schwellen: 0,80 Warnung, 0,92 kritisch, 26 h Sicherungsalter.
|
||||
- `meterLevel`, `ratio`, `isBackupStale`, `sortServersByHealth`, `summarizeHealth`.
|
||||
- `formatAge` nutzt `Intl.RelativeTimeFormat` und liefert „vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“ sowie „jetzt“ unter einer Minute.
|
||||
|
||||
Ein `null` löst nirgends eine Warnung aus. 24 Tests. Die RED-Phase war belegt, weil das Modul fehlte.
|
||||
|
||||
**Task 2 — Seite und Karte (57c338f, Nachbesserung 0b659d6).**
|
||||
- **Tokens:** `--status-ok/warn/down/idle/orphan` und `--well` nach der Tabelle im Plan, jeweils hell und dunkel. Dazu kommt eine Textvariante `--status-*-fg` (Begründung unter Kontrast). Alle Tokens sind in `@theme inline` abgebildet. Mit einem Tailwind-Probelauf ist nachgewiesen, dass `bg-status-ok/12`, `bg-status-down/8`, `bg-well`, die getönten Schatten und `motion-reduce:animate-none` wirklich erzeugt werden.
|
||||
- **Kopf:** Titel und Beschreibung stehen links. Rechts steht „Einstellungen“ als ruhiger Textlink, danach „Jetzt aktualisieren“ als Hauptknopf mit Kreispfeil. Der Kreispfeil dreht sich während des Laufs, bei reduzierter Bewegung nicht. Beides ist nur für Admins sichtbar. Die Seitenbreite ist `max-w-5xl`.
|
||||
- **Gesundheitsbalken:** 8 px hoch, anteilig geteilt in der Reihenfolge down, warn, ok, idle, orphan. Leere Zustände entfallen. Der Balken ist `role="img"` und liest die Zusammenfassung vor, zum Beispiel „Zustand der Server: 1 nicht erreichbar, 2 in Ordnung“. Die Legende zeigt Punkt, fette Zahl und Wort über ICU-Plural.
|
||||
- **Karte:** Statusleiste links, 4 px breit. Zweischichtiger, im Statuston getönter Schatten; bei `orphan` gibt es keinen farbigen Schatten, sondern einen gestrichelten Rand. Das Produktsymbol steht in einem 32-px-Well (Server-Einschübe, Archivkiste, Briefumschlag). Darunter stehen Name, ausgeschriebener Produktname und Adresse. Die Statuspille hat keine Animation. Im Fuß steht „Letzte Abfrage vor 4 Min.“, der exakte Zeitpunkt liegt im `title`.
|
||||
- **PVE:** Laufende Gäste groß, gestoppte gedämpft, dazu die Knotenzahl. Je Knoten ein Einschub mit Balken für Prozessor und Arbeitsspeicher und „12,0 / 64,0 GB“. Unbekannte Werte erscheinen als schraffierte Spur mit „unbekannt“, nie als 0 %.
|
||||
- **PBS:** Je Datenspeicher der Füllstand mit „1,2 / 4,0 TB“ und „Letzte Sicherung vor 5 Std.“; bei mehr als 26 h in Warnfarbe. Dazu eine Prüfpille: grün bei ok, Warnfarbe bei anderem Ergebnis, grau bei „unbekannt“.
|
||||
- **PMG:** Ein 2×2-Raster aus Zahlfeldern. Viren über 0 stehen in der Fehlerfarbe.
|
||||
- **Zustände:**
|
||||
- `down`: Hinweisblock mit linkem Rand. Die Fehlermeldung steht im Klartext, `errorDetail` in einer eigenen Zeile, darunter „Zuletzt erreichbar vor 2 Tagen“.
|
||||
- `idle`: Admin- und Nicht-Admin-Text wie bisher.
|
||||
- `orphan`: Hinweissatz; Admins bekommen zusätzlich einen Verweis auf die Einstellungen.
|
||||
- Laden: zwei Skelettkarten.
|
||||
- Keine Server: Well mit Symbol.
|
||||
- **Aktuelle Zeitangaben:** Ein Minutentakt hält die relativen Zeitangaben aktuell.
|
||||
|
||||
**Task 3 — Reiter in der Kopfzeile (7416a92).**
|
||||
- **Einhängepunkt:** Die Kopfzeile rendert einen leeren `#header-center-slot`. Die Kennung steht in `components/layout/header-slot.ts`. Auf `/` entfällt „Startseite“, auf allen anderen Seiten bleibt alles wie bisher. Die Kopfzeilenmitte ist `min-w-0`, Logo und Aktionen rechts sind `shrink-0`.
|
||||
- **Portal:** `DashboardTabs` rendert per `createPortal` in den Einhängepunkt. Ohne Einhängepunkt rendert die Leiste nichts. Die eigene Zeile über dem Raster ist entfallen.
|
||||
- **Gestaltung:** Eine eingelassene Spur (`bg-muted`, inset-Schatten, 32 px), darauf liegt der aktive Reiter erhaben (`bg-card`, `shadow-sm`, `font-medium`).
|
||||
- **Überlauf:** Die Spur ist `max-w-[min(56vw,720px)]` breit, auf Mobilgeräten volle Breite. Sie scrollt ohne sichtbare Leiste. Die Ränder werden über `mask-image` weich ausgeblendet, aber nur bei Überlauf. Der aktive Reiter wird ins Bild gescrollt.
|
||||
- **Tastatur:** `role="tablist"`/`tab` mit `aria-selected` und wanderndem `tabIndex`. Pfeil links/rechts, Pos1 und Ende bewegen den Fokus, mit Umlauf. Der Fokusring ist sichtbar.
|
||||
- **Bearbeitungsmodus:** Der „+“-Knopf ist ein 28-px-Rundknopf außerhalb der Spur. Umbenennen und Löschen sitzen klein im Reiter. Der Löschdialog hängt am Dokumentkörper statt im Stapelkontext der Kopfzeile. Die Beschriftung `navigation "Dashboard-Reiter"` bleibt.
|
||||
|
||||
**CHANGELOG** unter „Unveröffentlicht“: ein Eintrag unter „Neu“ (Proxmox-Seite) und ein neuer Abschnitt „Geändert“ (Reiter in der Kopfzeile).
|
||||
|
||||
## Kontrast (gemessen, WCAG-Formel, OKLCH → sRGB)
|
||||
|
||||
Die Flächentöne aus dem Plan erreichen als Schrift auf `--card` (hell) nur diese Werte:
|
||||
|
||||
| Token | hell | dunkel |
|
||||
|---|---|---|
|
||||
| ok | 3,41 | 6,46 |
|
||||
| warn | 2,55 | 7,92 |
|
||||
| down | 4,76 | 4,80 |
|
||||
| idle | 3,64 | 3,82 |
|
||||
| orphan | 4,85 | 2,74 |
|
||||
|
||||
Nach der Planregel wurde deshalb die Helligkeit angepasst, und zwar als eigene Textvariante `--status-*-fg`. Die Flächentöne bleiben wie geplant.
|
||||
|
||||
| Textvariante | hell auf card | hell auf Pille (12 %) | dunkel auf card | dunkel auf Pille |
|
||||
|---|---|---|---|---|
|
||||
| ok `0.50 0.13 152` / `0.76 0.15 152` | 5,64 | 4,94 | 7,44 | 6,02 |
|
||||
| warn `0.52 0.12 60` / `0.82 0.14 75` | 5,72 | 5,15 | 8,47 | 6,63 |
|
||||
| down `0.52 0.20 27` / `0.74 0.16 27` | 6,11 | 5,10 | 6,09 | 5,24 |
|
||||
| idle `0.50 0.02 260` / `0.72 0.02 260` | 6,00 | 5,28 | 6,08 | 5,24 |
|
||||
| orphan `0.50 0.01 260` / `0.70 0.01 260` | 6,00 | 5,17 | 5,64 | 5,06 |
|
||||
|
||||
Alle Werte liegen bei mindestens 4,5:1. Gedämpfte Schrift auf verwaisten Karten (`opacity-70`) hätte bei `muted-foreground` nur 2,75:1 (hell) bzw. 3,02:1 (dunkel). Deshalb wird dort nur der Name in Vordergrundfarbe gedämpft (7,54 bzw. 7,13).
|
||||
|
||||
Die Fläche `warn` hat im hellen Modus als Grafik 2,55:1 zu Weiß. Balken und Punkte in Warnfarbe stehen aber immer neben einer Zahl oder einem Wort, die Farbe trägt die Aussage also nicht allein.
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
**1. [Rule 2 – Kontrast] Textvariante der Statusfarben.** Der Plan erlaubt ausdrücklich, die Helligkeit anzupassen. Umgesetzt ist das als zusätzliches `-fg`-Token und nicht als Änderung der Flächentöne, damit Balken und Leisten die geplante Leuchtkraft behalten. Betrifft `globals.css` und `status-styles.ts`.
|
||||
|
||||
**2. [Rule 2 – Kontrast] Dämpfung bei „verwaist“ eingegrenzt.** Der Plan sieht `Inhalt opacity-70 saturate-50` vor. Umgesetzt ist das nur an Symbol und Name. Pille, Produktname, Adresse, Hinweis und Fuß bleiben voll lesbar, weil gedämpfte Schrift unter 3:1 fiele. Commit 0b659d6.
|
||||
|
||||
**3. Auslegung „+“ „immer sichtbar“.** Der Knopf bleibt wie bisher nur im Bearbeitungsmodus sichtbar, so verlangt es „Verhalten beibehalten“ und bestehender Test 4. Er sitzt jedoch außerhalb der scrollenden Spur, damit er nie weggescrollt wird.
|
||||
|
||||
**4. Zusätzliche Dateien:**
|
||||
- `HealthBar.tsx` und `status-styles.ts` (Proxmox): die Klassen werden an zwei Stellen gebraucht.
|
||||
- `components/layout/header-slot.ts`: die Kopfzeile soll nicht die Reiter-Komponente importieren.
|
||||
- `umlaut-dictionary.ts`: „Prozessor“ und „Arbeitsspeicher“ sind korrektes Deutsch mit „ss“ und stehen jetzt auf der Freigabeliste der Umlaut-Wache.
|
||||
|
||||
**5. Übersetzungsschlüssel.** Nicht mehr genutzte Schlüssel wurden ersetzt:
|
||||
- aus `card.pve.*`: `nodeCount`, `guests`
|
||||
- aus `card.pbs.*`: `lastBackup`, `verifyState`
|
||||
- aus `card.*`: `lastPolledLabel`, `lastOkLabel`
|
||||
|
||||
Neu sind `health.*`, `legend.*`, `card.product.*` und weitere. Die Änderungen stehen in de.json und en.json gleichermaßen.
|
||||
|
||||
**6. Formatierung.** `biome format` wurde auf die geänderten Proxmox-Dateien angewendet. Dadurch ist auch `proxmox-status.ts` aus Task 1 in Commit 57c338f rein umformatiert.
|
||||
|
||||
## Tore
|
||||
|
||||
- `pnpm --filter web exec vitest run`: 87 Dateien, 789 Tests grün. Proxmox allein: 65.
|
||||
- `pnpm turbo run type-check lint`: 9/9 erfolgreich.
|
||||
- Biome-Warnungen web: 53, nicht mehr als vorher (53).
|
||||
- Verbotene Muster: kein `uppercase`, kein `tracking-widest`, keine Mittelpunkt-Ketten und kein „→“ in den neuen Dateien (per grep geprüft).
|
||||
- Den Browser-Nachweis (hell/dunkel, 1400 px/390 px) macht wie vereinbart der Orchestrator. Docker wurde nicht neu gebaut.
|
||||
|
||||
## Hinweise für die Browser-Prüfung
|
||||
|
||||
- **Mobile Kopfzeile bei 390 px:** Rechnerisch bleiben für die Mitte etwa 80 px, also etwa ein Reiter sichtbar, der Rest ist scrollbar. Im Bearbeitungsmodus kommen 28 px für „+“ dazu. Bitte prüfen, dass nichts überläuft.
|
||||
- **Ränder der Spur:** Die weiche Ausblendung erscheint nur bei Überlauf. Mit drei oder mehr langen Reiternamen kann man das prüfen.
|
||||
- **Verwaiste Karte:** gestrichelter Rand, graue Leiste, kein farbiger Schatten, keine alten Messwerte.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: proxmox-status.ts, proxmox-status.test.ts, HealthBar.tsx, status-styles.ts, header-slot.ts
|
||||
- FOUND: 0fa7ce0, 57c338f, 7416a92, 0b659d6
|
||||
@@ -0,0 +1,524 @@
|
||||
---
|
||||
phase: quick-260924-i8v
|
||||
plan: 01
|
||||
quick_id: 260924-i8v
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260924-i8v]
|
||||
files_modified:
|
||||
- packages/shared/src/index.ts
|
||||
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||
- apps/web/src/components/proxmox/proxmox-status.ts (git mv aus app/(portal)/modules/proxmox/components/)
|
||||
- apps/web/src/components/proxmox/proxmox-status.test.ts (git mv)
|
||||
- apps/web/src/components/proxmox/HealthBar.tsx (git mv)
|
||||
- apps/web/src/components/proxmox/status-styles.ts (git mv)
|
||||
- apps/web/src/components/proxmox/proxmox-server-picker.tsx (neu)
|
||||
- apps/web/src/components/proxmox/proxmox-server-picker.test.tsx (neu)
|
||||
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/app/(portal)/page.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget.tsx (neu)
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx (neu)
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts (neu)
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts (neu)
|
||||
- apps/web/src/components/settings/proxmox-widget-config-form.tsx (neu)
|
||||
- apps/web/src/components/settings/proxmox-widget-config-form.test.tsx (neu)
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 60000
|
||||
raw_tokens: 60000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Wer das Proxmox-Modul nutzen darf, findet im Katalog „Widget hinzufügen“ die Kachel „Proxmox“ (als letzte, die übrigen neun in unveränderter Reihenfolge) und kann sie anlegen; die API akzeptiert den Typ proxmox"
|
||||
- "Wer das Modul nicht nutzen darf, sieht die Kachel weder im Katalog noch auf dem Dashboard (Katalogfilter als Komfort, verbindlich serverseitig in DashboardService.getWidgets, fail-closed)"
|
||||
- "Oben in der Kachel steht ein 6 px hoher Gesundheitsbalken und eine Zeile in Worten: „Alles in Ordnung“ in der Ok-Farbe, sonst z. B. „1 nicht erreichbar, 1 mit Warnung“ in der Farbe des schlimmsten Zustands"
|
||||
- "Darunter stehen die Server sortiert nach down, warn, ok, idle, orphan, je mit Statuspunkt, Name und genau einer rechtsbündigen Kennzahl; ein unbekannter Wert heißt „unbekannt“, nie 0"
|
||||
- "Ein Klick auf eine Serverzeile öffnet /modules/proxmox; im Bearbeitungsmodus führt keine Zeile irgendwohin und die ganze Kachel bleibt ziehbar"
|
||||
- "Die Kachel liest alle 60 s neu aus dem Zwischenlager (GET servers), pausiert bei verborgenem Browser-Tab und löst niemals eine Abfrage bei Proxmox aus"
|
||||
- "Titel und Serverauswahl lassen sich im Bearbeitungsmodus direkt an der Kachel und unter Einstellungen > Dashboard festlegen; keine Auswahl bedeutet alle Server"
|
||||
- "Schmale Kachel (unter 15rem): nur Punkte und Namen; sehr kleine Kachel (unter 7.5rem hoch oder unter 8rem breit): nur Balken und Zusammenfassung"
|
||||
artifacts:
|
||||
- path: "apps/web/src/components/dashboard/widgets/proxmox-widget.tsx"
|
||||
provides: "ProxmoxWidget (WidgetProps) — Balken, Zusammenfassung, Serverliste, Minutentakt, Bearbeitungsmodus"
|
||||
- path: "apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts"
|
||||
provides: "reine Funktionen: resolveProxmoxWidgetConfig, selectServers, healthSummary, widgetKeyFigure"
|
||||
- path: "apps/web/src/components/proxmox/"
|
||||
provides: "gemeinsamer Ort für proxmox-status.ts, HealthBar.tsx (mit variant compact), status-styles.ts, proxmox-server-picker.tsx"
|
||||
- path: "apps/web/src/components/settings/proxmox-widget-config-form.tsx"
|
||||
provides: "Einstellungsformular der Kachel (Titel + Serverauswahl) für Einstellungen > Dashboard"
|
||||
- path: "packages/shared/src/index.ts"
|
||||
provides: "WIDGET_TYPES enthält 'proxmox', WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }"
|
||||
key_links:
|
||||
- from: "packages/shared/src/index.ts WIDGET_MODULE_SLUGS"
|
||||
to: "apps/api/src/dashboard/dashboard.service.ts getWidgets (über widget-module-map.ts)"
|
||||
via: "getModuleSlugForWidgetType('proxmox') === 'proxmox'"
|
||||
pattern: "proxmox: 'proxmox'"
|
||||
- from: "apps/web/src/app/(portal)/page.tsx"
|
||||
to: "proxmox-widget.tsx"
|
||||
via: "registerWidget('proxmox', ProxmoxWidget)"
|
||||
pattern: "registerWidget\\('proxmox'"
|
||||
- from: "proxmox-widget.tsx"
|
||||
to: "apps/web/src/lib/proxmox-api.ts listServers"
|
||||
via: "einziger Import aus proxmox-api; Intervall 60 s + visibilitychange"
|
||||
pattern: "listServers"
|
||||
- from: "proxmox-widget.tsx"
|
||||
to: "apps/web/src/components/proxmox/HealthBar.tsx"
|
||||
via: "<HealthBar variant=\"compact\" counts=... />"
|
||||
pattern: "variant=\"compact\""
|
||||
- from: "apps/web/src/components/settings/widget-settings-panel.tsx"
|
||||
to: "proxmox-widget-config-form.tsx"
|
||||
via: "widget.widgetType === 'proxmox'"
|
||||
pattern: "ProxmoxWidgetConfigForm"
|
||||
---
|
||||
|
||||
# Quick 260924-i8v — Proxmox-Kachel fürs Dashboard
|
||||
|
||||
Nutzerauftrag (24.09.): „Jetzt die Proxmox-Kachel fürs Dashboard bauen.“
|
||||
|
||||
Grundlagen, auf denen dieser Plan steht (nicht neu erfinden):
|
||||
- **quick-260922-m1h** hat den Weg „ein Modul bringt seine Kachel mit“ gebaut: `WIDGET_TYPES` +
|
||||
`WIDGET_MODULE_SLUGS` in `packages/shared/src/index.ts` (die API validiert per `@IsIn` gegen genau
|
||||
diese Liste), `registerWidget()` in `(portal)/page.tsx`, `visibleWidgetTypes()` als Katalogfilter.
|
||||
Eine Kachel mit `moduleSlug` verschwindet für Benutzer ohne Modulzugriff automatisch aus Katalog
|
||||
**und** Dashboard (serverseitig `DashboardService.getWidgets`, fail-closed). Für „gesperrt“ ist
|
||||
deshalb **nichts** zu bauen — die Kachel erscheint schlicht nicht; `widgets.unavailable` im
|
||||
Wrapper bleibt der Rückfall.
|
||||
- **quick-260924-h7x** hat die Statussprache der Proxmox-Seite festgelegt: `serverHealth`,
|
||||
`THRESHOLDS`, `meterLevel`, `formatAge`, `sortServersByHealth`, `summarizeHealth`, `HEALTH_ORDER`,
|
||||
`HealthBar`, `HEALTH_STYLE`/`METER_TEXT`/`WELL`, Tokens `--status-*` und `--status-*-fg` in
|
||||
`globals.css`. Diese Teile werden **wiederverwendet, nicht kopiert** — dafür ziehen sie an einen
|
||||
neutralen Ort `apps/web/src/components/proxmox/`.
|
||||
|
||||
Verbindliche Gestaltungsregeln (wie h7x): Zustand steuert die Optik; keine Großbuchstaben-Etiketten,
|
||||
keine Mittelpunkt-Ketten, kein Pfeilzeichen an Knöpfen oder in Texten; App-Texte deutsch in
|
||||
Sie-Form, jeder neue Schlüssel in `de.json` **und** `en.json`. Keine neuen Pakete. Schriftfarbe in
|
||||
Statusfarbe immer über die `-fg`-Variante (`HEALTH_STYLE[h].text`), Flächen über `fill` — so bleibt
|
||||
der in h7x gemessene Kontrast von mindestens 4,5:1 erhalten.
|
||||
|
||||
Rasterrechnung (aus `dashboard-grid.tsx`: 24 Spalten, `rowHeight` 20, `margin` 8): Höhe h Zeilen =
|
||||
20h + 8(h−1) px, also 4 Zeilen = 104 px, 8 Zeilen = 216 px. Breite bei rund 1400 px Inhalt: eine
|
||||
Spalte ≈ 50 px, 3 Spalten ≈ 166 px, 8 Spalten ≈ 456 px.
|
||||
|
||||
<objective>
|
||||
Die erste echte Modul-Kachel: „Proxmox“ zeigt auf dem Dashboard den Zustand der Proxmox-Server in der
|
||||
Statussprache der Modulseite — kompakter Gesundheitsbalken, Zusammenfassung in Worten, darunter die
|
||||
Server nach Dringlichkeit mit je einer Kennzahl, Klick führt zur Modulseite. Sie liest nur das
|
||||
Zwischenlager, frischt sich minütlich auf, lässt sich auf Titel und Serverauswahl einstellen und
|
||||
passt sich per Container-Query an kleine Kachelgrößen an.
|
||||
|
||||
Purpose: Der Nutzer sieht den Zustand seiner Proxmox-Umgebung, ohne die Modulseite zu öffnen.
|
||||
Output: Kachel-Komponente samt reiner Modell-Funktionen, gemeinsamer Proxmox-Ordner, Einstellungsformular,
|
||||
Registry-/Katalog-/API-Tests angepasst, Doku und Changelog.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@./CLAUDE.md
|
||||
@.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md
|
||||
@.planning/quick/260924-h7x-proxmox-seite-status-design-und-dashboar/260924-h7x-SUMMARY.md
|
||||
@apps/web/src/components/dashboard/widget-registry.tsx
|
||||
@apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||
@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
@apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts
|
||||
@apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx
|
||||
@apps/web/src/app/(portal)/modules/proxmox/components/status-styles.ts
|
||||
@apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
@apps/web/src/lib/proxmox-api.ts
|
||||
@apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
|
||||
Schnittstellen, die der Executor braucht (aus dem Code gelesen, Stand 8bfa4fc):
|
||||
|
||||
- `WidgetProps = { instanceId: string; config: Record<string, unknown>; isEditMode: boolean }`
|
||||
- `WIDGET_CONSTRAINTS: Record<WidgetType, { minW; minH; defaultW; defaultH }>`
|
||||
- `registerWidget(type: WidgetType, component: ComponentType<WidgetProps>)`
|
||||
- `visibleWidgetTypes(registry, accessibleModuleSlugs: readonly string[] | null)`
|
||||
- `listServers(): Promise<ProxmoxServer[]>` — `GET /modules/proxmox/servers`, `@UseModule('proxmox')`,
|
||||
für alle Rollen mit Modulzugriff lesbar (nur Schreib-/Abfrage-Endpunkte sind Admin-only).
|
||||
- `ProxmoxServer`: `id, name, productType ('pve'|'pbs'|'pmg'), isActive, position, status: ProxmoxServerStatus | null`;
|
||||
`status.metrics`: `pve { guestsRunning, guestsStopped, nodes[{cpu, mem, maxmem}], storages[{disk, maxdisk}] }`,
|
||||
`pbs { datastores[{ used, total, lastBackupAt (Unix-Sekunden), lastVerifyState }] }`,
|
||||
`pmg { countIn, countOut, spamCount, virusCount }` — alle Messwerte `number | null`.
|
||||
- `serverHealth(server, now) → 'ok'|'warn'|'down'|'idle'|'orphan'`, `HEALTH_ORDER = ['down','warn','ok','idle','orphan']`,
|
||||
`summarizeHealth(servers, now) → Record<ServerHealth, number>`, `sortServersByHealth(servers, now)`,
|
||||
`ratio(used, total)`, `meterLevel(fraction)`, `isBackupStale(lastBackupAt, now)`, `toEpochMs`,
|
||||
`formatAge(value, now, locale) → 'vor 5 Std.' | null`.
|
||||
- `HEALTH_STYLE[h] = { fill, pill, text, shadow }`, `METER_TEXT[level]`.
|
||||
- `updateWidgetConfig(instanceId, partialConfig)` aus `@/lib/dashboard-api` (Muster Favoriten-Titel).
|
||||
- Admin-Erkennung wie auf der Modulseite: `useAuthStore((s) => s.user)`, Rolle `ADMIN` oder `SUPER_ADMIN`.
|
||||
- Wiederverwendbare Übersetzungen (Namensraum `proxmox`): `legend.<h>` (ICU-Plural, „nicht erreichbar“,
|
||||
„mit Warnung“ …), `health.<h>` („In Ordnung“, „Warnung“ …), `loadError`, `loading`,
|
||||
`card.unknownValue` („unbekannt“), `card.pbs.noBackupYet`, `card.settingsLink`, `card.product.<typ>`.
|
||||
- Tailwind 4.3.1, geprüft per Probelauf in der Planung: `@max-[15rem]:hidden` erzeugt
|
||||
`@container (width < 15rem)`, `[@container(max-height:7.5rem)]:hidden` erzeugt
|
||||
`@container (max-height:7.5rem)`. Der Container ist der Kachelrumpf im Wrapper (`@container-size`).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Aufgabe 1: Durchstich — Kacheltyp „proxmox“ von der Typliste über API-Whitelist, Registry und Katalog bis zur Anzeige von Balken und Zusammenfassung</name>
|
||||
<files>apps/web/src/components/proxmox/proxmox-status.ts, apps/web/src/components/proxmox/proxmox-status.test.ts, apps/web/src/components/proxmox/HealthBar.tsx, apps/web/src/components/proxmox/status-styles.ts, apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts, apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<action>
|
||||
**1. Gemeinsame Teile an neutralen Ort ziehen (Git-Historie erhalten).** Mit `git mv` die vier Dateien
|
||||
`proxmox-status.ts`, `proxmox-status.test.ts`, `HealthBar.tsx`, `status-styles.ts` aus
|
||||
`apps/web/src/app/(portal)/modules/proxmox/components/` nach `apps/web/src/components/proxmox/` verschieben.
|
||||
Ihre gegenseitigen relativen Importe bleiben gültig. In `modules/proxmox/page.tsx` und
|
||||
`components/ServerCard.tsx` (und in Tests, die direkt importieren) auf `@/components/proxmox/...`
|
||||
umstellen. `ServerCard.tsx` bleibt am Modulort, nur die Seite braucht sie. Keine Logikänderung;
|
||||
Kopfkommentare um einen Satz ergänzen („seit 260924-i8v gemeinsam für Modulseite und Dashboard-Kachel“).
|
||||
Grund: eine Kachel unter `components/` soll nicht in einen Routenordner unter `app/` greifen.
|
||||
|
||||
**2. HealthBar bekommt eine kompakte Variante.** Optionale Prop `variant: 'full' | 'compact'`,
|
||||
Standard `full` (Modulseite unverändert). `compact`: Balken `h-1.5` (6 px) statt `h-2`, **ohne**
|
||||
Legende; der Balken ist dann `aria-hidden`, weil die Kachel darunter die Zusammenfassung als
|
||||
sichtbaren Text zeigt (sonst würde ein Vorleser sie doppelt vorlesen). `data-testid="health-bar"`
|
||||
bleibt; zusätzlich `data-variant` für Tests.
|
||||
|
||||
**3. Typ und Modulbindung eintragen** in `packages/shared/src/index.ts`: `'proxmox'` **ans Ende** von
|
||||
`WIDGET_TYPES` (die neun bisherigen behalten Reihenfolge, der Katalog zeigt Proxmox zuletzt);
|
||||
`WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }` — der Slug ist derselbe wie `@UseModule('proxmox')` im
|
||||
Controller und `slug: 'proxmox'` in `proxmox.seed.ts`. Den Kommentar „heute bewusst leer“
|
||||
richtigstellen. Nur löschbare TypeScript-Syntax (Warnung in der Datei beachten, m1h).
|
||||
|
||||
**4. Registry** (`widget-registry.tsx`): `WIDGET_CONSTRAINTS.proxmox = { minW: 3, minH: 4, defaultW: 8, defaultH: 8 }`
|
||||
mit Kommentar im Stil der Nachbarn: 4 Zeilen = 104 px reichen genau für Balken und Zusammenfassung
|
||||
(die Liste blendet sich darunter per Container-Query aus); 8×8 ≈ 456×216 px bei 1400 px Breite zeigt
|
||||
rund sechs Serverzeilen; 3 Spalten ≈ 166 px = Punkte und Namen. Inline-SVG `ProxmoxIcon` im
|
||||
Projektmuster (Server-Einschübe wie das Leersymbol der Modulseite: zwei abgerundete Rechtecke mit je
|
||||
einem Punkt). Registry-Eintrag `proxmox` mit `nameKey: 'proxmox.name'`, `descriptionKey:
|
||||
'proxmox.description'` (Namensraum `widgets`), `moduleSlug: WIDGET_MODULE_SLUGS.proxmox`,
|
||||
`component: PlaceholderWidget`.
|
||||
|
||||
**5. Modell-Datei anlegen** `widgets/proxmox-widget-model.ts` (rein, ohne React), zunächst mit
|
||||
`healthSummary(counts)` => `{ allOk: boolean; entries: Array<{ health; count }>; worst: ServerHealth | null }`:
|
||||
`entries` = alle Zustände außer `ok` mit Anzahl > 0, in `HEALTH_ORDER`; `allOk` = es gibt Server und
|
||||
`entries` ist leer; `worst` = erster Zustand in `HEALTH_ORDER` mit Anzahl > 0. Aufgabe 2 erweitert die Datei.
|
||||
|
||||
**6. Kachel, erster Schnitt** `widgets/proxmox-widget.tsx`, `'use client'`, exportiert
|
||||
`ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps)`. Aus `@/lib/proxmox-api` wird
|
||||
**ausschließlich** `listServers` (und Typen) importiert — die Funktion für die manuelle Abfrage, die
|
||||
`POST …/poll` auslöst, darf in keiner Kachel-Datei vorkommen (T-I8V-02). Beim Einhängen einmal laden
|
||||
(Abbruch-Flag gegen setState nach dem Aushängen); Zustand `servers: ProxmoxServer[] | null`,
|
||||
`loadFailed: boolean` (Fehler als Flag, nicht als Text — `t` gehört nicht in Effekt-Abhängigkeiten,
|
||||
Befund 14 aus favorites-widget), `now` beim erfolgreichen Laden setzen. Darstellung, Wurzel
|
||||
`flex h-full flex-col overflow-hidden`, Innenabstand `p-2.5`:
|
||||
- Laden: 6 px hohe Leiste `bg-muted`, pulsierend mit `motion-reduce:animate-none`, dazu sr-only `proxmox.loading`.
|
||||
- Laden fehlgeschlagen und noch nie eine Liste: Satz `proxmox.loadError`, gedämpft, zentriert.
|
||||
- Leere Liste: Satz `widgets.proxmox.empty`; für Admins darunter `proxmox.card.settingsLink` als
|
||||
Link auf `/modules/proxmox/settings` — im Bearbeitungsmodus nur als Text, ohne Link.
|
||||
- Sonst: `HealthBar variant="compact"` mit `summarizeHealth(servers, now)` und darunter die
|
||||
Zusammenfassung als `p` (`text-sm font-medium truncate`, `title` = voller Text): bei `allOk`
|
||||
`widgets.proxmox.allOk` in `HEALTH_STYLE.ok.text`; sonst die Einträge als „Anzahl + Wort aus
|
||||
`proxmox.legend.<h>` (mit `count`)“, mit Komma und Leerzeichen verbunden, in
|
||||
`HEALTH_STYLE[worst].text`. Die Anzahl `ok` wird nicht genannt.
|
||||
|
||||
**7. Anmelden**: in `(portal)/page.tsx` Import und `registerWidget('proxmox', ProxmoxWidget)` nach
|
||||
`xframe`; in `(portal)/page.test.tsx` ein `vi.mock` für `proxmox-widget` wie für die übrigen Kacheln.
|
||||
|
||||
**8. Texte** (de/en) unter `widgets.proxmox`: `name` „Proxmox“/„Proxmox“, `description` „Zustand Ihrer
|
||||
Proxmox-Server auf einen Blick“/„Health of your Proxmox servers at a glance“, `allOk` „Alles in
|
||||
Ordnung“/„All good“, `empty` „Noch kein Proxmox-Server eingetragen.“/„No Proxmox server added yet.“
|
||||
|
||||
**9. Bestehende Tests nachziehen** — sie benutzen „proxmox“ bisher als Beispiel für einen
|
||||
*unbekannten* Typ, das stimmt jetzt nicht mehr:
|
||||
- `widget-registry.test.tsx`: `ALL_WIDGET_TYPES` um `'proxmox'` am Ende ergänzen, „neun“ in den
|
||||
Testnamen zu „zehn“; der Test „keine Kachel trägt einen moduleSlug“ wird zu „nur proxmox trägt
|
||||
moduleSlug 'proxmox', alle anderen keinen“; die beiden Unbekannt-Typ-Tests nehmen
|
||||
`'gibt-es-nicht'`; `visibleWidgetTypes(WIDGET_REGISTRY, [])` erwartet alle Typen außer proxmox,
|
||||
neu dazu `['proxmox']` => alle zehn; Constraints proxmox = 3/4/8/8.
|
||||
- `widget-catalog-modal.test.tsx`: Reihenfolgetest mit `accessibleModuleSlugs={['proxmox']}` => alle
|
||||
zehn in `WIDGET_TYPES`-Reihenfolge; mit `[]` => neun ohne Proxmox. Die beiden Tests, die bisher
|
||||
vorübergehend `clock` zur Modul-Kachel gemacht haben, prüfen jetzt die echte Kachel „Proxmox“
|
||||
(fehlt bei `[]`, erscheint bei `['proxmox']`, fehlt bei `null` während „Notizen“ bleibt).
|
||||
- `apps/api/src/dashboard/widget-module-map.spec.ts`: „die neun Kacheln sind Plattform-Kacheln“ wird
|
||||
zu „alle außer proxmox ohne Modul, `getModuleSlugForWidgetType('proxmox') === 'proxmox'`“; die
|
||||
DTO-Whitelist deckt `'proxmox'` über `it.each([...WIDGET_TYPES])` von selbst ab.
|
||||
|
||||
**10. Neuer Test** `widgets/proxmox-widget.test.tsx` mit echtem `NextIntlClientProvider` +
|
||||
`de.json` (Muster `proxmox-page-roles.test.tsx`), `vi.mock('@/lib/proxmox-api')` mit `listServers`
|
||||
**und** einem Spion für die manuelle Abfragefunktion, Attrappe für `@/lib/stores/auth-store` und
|
||||
`next/link`, Baukasten `makeServer(overrides)` mit Zwischenlager je Zustand. Fälle: alle ok =>
|
||||
„Alles in Ordnung“ mit Klasse `text-status-ok-fg`; je ein down, warn, ok => „1 nicht erreichbar, 1 mit
|
||||
Warnung“ mit `text-status-down-fg`; kompakter Balken vorhanden (`data-variant="compact"`, `h-1.5`),
|
||||
keine Legende; leere Liste => Satz, Admin sieht Link auf `/modules/proxmox/settings`, Rolle USER
|
||||
nicht; `listServers` lehnt ab => „Die Serverliste konnte nicht geladen werden.“; der Abfrage-Spion
|
||||
wird nie aufgerufen.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/components/proxmox "src/app/(portal)/page.test.tsx" "src/app/(portal)/modules/proxmox" && pnpm --filter @tessera/api exec vitest run src/dashboard && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api exec tsc --noEmit && test ! -e "apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts" && grep -q "proxmox: 'proxmox'" packages/shared/src/index.ts && grep -q "registerWidget('proxmox'" "apps/web/src/app/(portal)/page.tsx"</automated>
|
||||
</verify>
|
||||
<done>Typ `proxmox` steht in `WIDGET_TYPES` (zuletzt) und in `WIDGET_MODULE_SLUGS`; die API-Whitelist akzeptiert ihn, `getModuleSlugForWidgetType('proxmox')` liefert `'proxmox'`; Registry, Katalog und Seite kennen die Kachel; die Kachel lädt die Serverliste und zeigt kompakten Balken plus Zusammenfassung bzw. Lade-, Fehler- und Leerzustand. Die vier gemeinsamen Dateien liegen unter `components/proxmox/`, die Modulseite verhält sich unverändert (ihre Tests grün). Web- und API-Tests der betroffenen Bereiche sowie beide type-checks grün. Commit `feat(260924-i8v): …`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Serverliste mit Kennzahl je Zeile, Sortierung, Serverfilter, Links, Größenstufen per Container-Query und Minutentakt</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts, apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts, apps/web/src/components/dashboard/widgets/proxmox-widget.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx, apps/web/src/components/proxmox/proxmox-status.ts, apps/web/src/components/proxmox/proxmox-status.test.ts, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<behavior>
|
||||
- Modell: resolveProxmoxWidgetConfig — Titel kein String wird zu leer; serverIds kein Array wird zu leer; Nicht-Strings und leere Strings fallen weg; doppelte Kennungen einmal
|
||||
- Modell: selectServers — leere Auswahl liefert alle Server; Auswahl filtert nach Kennung; nur noch gelöschte Kennungen ausgewählt liefert leere Liste mit selectionGone true
|
||||
- Modell: widgetKeyFigure — down/idle/orphan liefern kind status; PVE ok liefert guests (running, total = running + stopped), total 0 liefert noGuests; PVE warn liefert load mit dem höchsten bekannten Anteil aus Knoten-CPU, Knoten-RAM und Speicher; PBS liefert backup mit der ÄLTESTEN bekannten letzten Sicherung samt stale-Flag, Datenspeicher ohne jede Sicherung liefern noBackup, keine Datenspeicher liefern unknown; PMG liefert mailIn aus countIn, null liefert unknown; metrics null bei erreichbarem Server liefert unknown
|
||||
- proxmox-status: formatPercent(0.87, 'de') passt auf /87\s%/; formatCount(12904, 'de') ergibt „12.904“
|
||||
- Kachel: Zeilen erscheinen in der Reihenfolge down, warn, ok, idle, orphan
|
||||
- Kachel: Kennzahlen „3/4 Gäste laufen“, „Auslastung 87 %“ (warn), „Sicherung vor 5 Std.“, „12.904 eingehend“, „nicht erreichbar“, „offline & verwaist“, „noch nicht abgefragt“; unbekannte Werte zeigen „unbekannt“ und nie eine 0 (kein „0 eingehend“, kein „0 %“)
|
||||
- Kachel: config.serverIds beschränkt Zeilen UND Balken/Zusammenfassung auf die Auswahl; nur gelöschte Kennungen → Satz selectionGone
|
||||
- Kachel: Ansichtsmodus — jede Zeile ist ein Link auf /modules/proxmox; Bearbeitungsmodus — keine Links in der Kachel
|
||||
- Kachel: config.title nicht leer → Überschrift h2; leer → keine Kopfzeile
|
||||
- Kachel mit falschen Zeitgebern: nach 60 s zweiter Aufruf von listServers; bei document.visibilityState hidden kein Aufruf im Takt; visibilitychange zurück auf visible lädt sofort; nach dem Aushängen keine weiteren Aufrufe; die manuelle Abfragefunktion wird in keinem Fall aufgerufen
|
||||
- Kachel: scheitert ein späteres Nachladen, bleibt die zuletzt geladene Liste stehen (kein Fehlersatz)
|
||||
- Kachel: Kennzahl trägt die Klasse @max-[15rem]:hidden, die Liste [@container(max-height:7.5rem)]:hidden und @max-[8rem]:hidden
|
||||
</behavior>
|
||||
<action>
|
||||
**RED zuerst**: `proxmox-widget-model.test.ts` neu und die Fälle in `proxmox-widget.test.tsx` gemäß
|
||||
`<behavior>` schreiben, laufen lassen, Fehlschlag belegen, dann umsetzen.
|
||||
|
||||
**1. Zahlformat nicht verdoppeln.** `formatPercent(fraction, locale)` und `formatCount(value, locale)`
|
||||
aus `ServerCard.tsx` unverändert als Exporte nach `components/proxmox/proxmox-status.ts` ziehen;
|
||||
`ServerCard.tsx` importiert sie von dort. Zwei Tests in `proxmox-status.test.ts` (deutsches
|
||||
Prozentformat hat ein geschütztes Leerzeichen — per Regex mit `\s` prüfen).
|
||||
|
||||
**2. Modell erweitern** (`proxmox-widget-model.ts`, rein):
|
||||
- `resolveProxmoxWidgetConfig(config)` => `{ title: string; serverIds: string[] }`, abwehrend wie
|
||||
`picture-frame-config.ts` (siehe `<behavior>`).
|
||||
- `selectServers(servers, serverIds)` => `{ servers: ProxmoxServer[]; selectionGone: boolean }`.
|
||||
- `widgetKeyFigure(server, now)` => Unterscheidungstyp `KeyFigure`:
|
||||
`{ kind: 'status'; health: 'down'|'idle'|'orphan' }`, `{ kind: 'guests'; running; total }`,
|
||||
`{ kind: 'noGuests' }`, `{ kind: 'load'; fraction; level: MeterLevel }`,
|
||||
`{ kind: 'backup'; at: number; stale: boolean }`, `{ kind: 'noBackup' }`,
|
||||
`{ kind: 'mailIn'; count }`, `{ kind: 'unknown' }`. Regeln: zuerst `serverHealth` — bei down, idle,
|
||||
orphan immer `status` (ein verwaister Server zeigt keine alten Messwerte, wie in h7x). Bei ok/warn
|
||||
und `metrics === null` => `unknown`. PVE: im Zustand warn der höchste **bekannte** Anteil aus
|
||||
`nodes[].cpu`, `ratio(mem, maxmem)` und `ratio(disk, maxdisk)` der Speicher (`level =
|
||||
meterLevel(fraction)`), sonst Gäste; ist `guestsRunning`/`guestsStopped` keine Zahl => `unknown`.
|
||||
PBS: die älteste bekannte `lastBackupAt` über alle Datenspeicher (sie ist der Grund für eine
|
||||
Warnung „Sicherung zu alt“), `stale = isBackupStale(at, now)`. PMG: `countIn`. Ein unbekannter
|
||||
Wert ist nie 0 (Grundregel aus proxmox-status.ts).
|
||||
|
||||
**3. Kachel ausbauen** (`proxmox-widget.tsx`):
|
||||
- Konfiguration über `resolveProxmoxWidgetConfig(config)`; Kopfzeile nur bei nicht leerem Titel,
|
||||
Markup wie die Favoriten-Kopfzeile (Rand unten, `truncate text-sm font-semibold` als `h2`).
|
||||
Die Eingabe im Bearbeitungsmodus folgt in Aufgabe 3.
|
||||
- Ablauf: `selectServers` => Balken und Zusammenfassung aus der **gefilterten** Liste =>
|
||||
`sortServersByHealth(gefiltert, now)` => Zeilen. `selectionGone` => Satz `widgets.proxmox.selectionGone`
|
||||
statt Balken und Liste.
|
||||
- Liste als `ul`, `min-h-0 flex-1 overflow-y-auto`, dazu `[@container(max-height:7.5rem)]:hidden`
|
||||
und `@max-[8rem]:hidden` (sehr kleine Kachel: nur Balken und Zusammenfassung). Keinen weiteren
|
||||
Container in der Kachel setzen — der Rumpf im Wrapper ist bereits `@container-size`, ein innerer
|
||||
Container würde die Abfragen umlenken.
|
||||
- Zeile: `flex items-center gap-2 rounded-md px-1.5 py-1 text-sm`; Punkt `h-2 w-2 shrink-0
|
||||
rounded-full` + `HEALTH_STYLE[h].fill`, `aria-hidden`; Name `min-w-0 flex-1 truncate` (bei orphan
|
||||
`opacity-70`, wie h7x nur den Namen dämpfen); sr-only das Zustandswort `proxmox.health.<h>`, damit
|
||||
der Zustand nie nur an der Farbe hängt; Kennzahl `shrink-0 tabular-nums text-xs` rechtsbündig mit
|
||||
`@max-[15rem]:hidden` (schmale Kachel: nur Punkte und Namen). Wiederholt die Kennzahl nur das
|
||||
Zustandswort (`kind: 'status'`), ist sie `aria-hidden`.
|
||||
- Kennzahltexte über `useLocale()`: guests => `widgets.proxmox.guests`; noGuests =>
|
||||
`widgets.proxmox.noGuests`; load => `widgets.proxmox.load` mit `formatPercent`; backup =>
|
||||
`widgets.proxmox.backupAgo` mit `formatAge(at, now, locale)`; noBackup => `proxmox.card.pbs.noBackupYet`;
|
||||
mailIn => `widgets.proxmox.mailIn` mit `formatCount`; status => `proxmox.legend.<h>` mit `count: 1`;
|
||||
unknown => `proxmox.card.unknownValue`. Farben: status => `HEALTH_STYLE[h].text`; load =>
|
||||
`METER_TEXT[level]`; backup mit `stale` => `HEALTH_STYLE.warn.text`; unknown und alle übrigen =>
|
||||
`text-muted-foreground`.
|
||||
- Ansichtsmodus: jede Zeile ist ein `next/link` auf `/modules/proxmox` mit `hover:bg-muted/60` und
|
||||
sichtbarem Fokusring. Bearbeitungsmodus: dieselbe Zeile als `div` ohne Ziel und ohne Tabstopp.
|
||||
Bewusste Abweichung vom Favoriten-Muster (dort Anker mit verhindertem Klick): Links stehen im
|
||||
Abbruch-Selektor von `dashboard-grid.tsx`, Anker-Zeilen würden das Ziehen über fast die ganze
|
||||
Kachel blockieren. Das gilt auch für den Admin-Link im Leerzustand.
|
||||
- Minutentakt: Konstante `REFRESH_MS = 60_000`; `setInterval` ruft nur dann `listServers` auf, wenn
|
||||
`document.visibilityState !== 'hidden'`; ein `visibilitychange`-Hörer lädt sofort, sobald die Seite
|
||||
wieder sichtbar ist. Aufräumen beim Aushängen: Intervall, Hörer, Abbruch-Flag. Scheitert ein
|
||||
Nachladen, nachdem schon eine Liste da war, bleibt sie stehen; den Fehlersatz gibt es nur ohne
|
||||
jede Liste. `now` wird bei jedem erfolgreichen Laden gesetzt (relative Zeitangaben).
|
||||
|
||||
**4. Texte** (de/en) unter `widgets.proxmox`: `guests` „{running}/{total} {total, plural, one {Gast läuft} other {Gäste laufen}}“ /
|
||||
„{running}/{total} {total, plural, one {guest running} other {guests running}}“; `noGuests` „keine Gäste“/„no guests“;
|
||||
`load` „Auslastung {percent}“/„Load {percent}“; `backupAgo` „Sicherung {age}“/„Backup {age}“;
|
||||
`mailIn` „{count} eingehend“/„{count} incoming“; `selectionGone` „Die ausgewählten Server gibt es nicht
|
||||
mehr. Wählen Sie im Bearbeitungsmodus andere aus.“/„The selected servers no longer exist. Choose others in edit mode.“
|
||||
Umlaute echt schreiben; die Umlaut-Wache (`umlaut-guard.spec.ts`) muss grün bleiben.
|
||||
|
||||
**5. Tests für die Zeitgeber**: `vi.useFakeTimers()`, Zeitvorschub mit
|
||||
`await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })`, `document.visibilityState`
|
||||
per `Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => … })`
|
||||
umschalten und `visibilitychange` auf `document` auslösen; nach jedem Test echte Zeitgeber zurück
|
||||
und die Eigenschaft wiederherstellen. Die Container-Query-Stufen sind in jsdom nicht auswertbar —
|
||||
dort nur die Klassen prüfen; die tatsächliche Wirkung prüft der Browser-Nachweis.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/proxmox-widget src/components/proxmox "src/app/(portal)/modules/proxmox" src/messages && test "$(grep -vE '^\s*(//|\*|/\*|\{/\*)' apps/web/src/components/dashboard/widgets/proxmox-widget.tsx apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts | grep -c 'pollServer')" -eq 0 && test "$(grep -c 'function formatPercent' "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx")" -eq 0</automated>
|
||||
</verify>
|
||||
<done>Alle `<behavior>`-Fälle grün, RED-Phase im Commit-Verlauf belegt (Test-Commit vor Umsetzungs-Commit). Die Kachel zeigt die sortierte, gefilterte Serverliste mit genau einer Kennzahl je Zeile, verlinkt im Ansichtsmodus, bleibt im Bearbeitungsmodus ziehbar, stuft sich per Container-Query ab und lädt minütlich nur aus dem Zwischenlager nach. Kein Zahlformat doppelt (ServerCard nutzt die gemeinsamen Funktionen), Modulseiten-Tests weiter grün.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 3: Titel und Serverauswahl einstellen (an der Kachel im Bearbeitungsmodus und unter Einstellungen > Dashboard), Doku, Changelog, Gesamt-Tore</name>
|
||||
<files>apps/web/src/components/proxmox/proxmox-server-picker.tsx, apps/web/src/components/proxmox/proxmox-server-picker.test.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx, apps/web/src/components/settings/proxmox-widget-config-form.tsx, apps/web/src/components/settings/proxmox-widget-config-form.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/widget-settings-panel.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md, CHANGELOG.md</files>
|
||||
<behavior>
|
||||
- Auswahl-Bauteil: zeigt je Server ein Kästchen, sortiert nach position, Beschriftung Name plus Produktwort (Virtualisierung/Datensicherung/Mail-Gateway); ausgewählte Kennungen sind angehakt
|
||||
- Auswahl-Bauteil: Anhaken/Abhaken ruft onChange mit der neuen Kennungsliste in Listenreihenfolge; Kennungen gelöschter Server fallen dabei heraus; alles abhaken ergibt eine leere Liste
|
||||
- Auswahl-Bauteil: der Hinweis „Ohne Auswahl zeigt die Kachel alle Server.“ steht sichtbar da
|
||||
- Kachel im Bearbeitungsmodus: Titelfeld (Beschriftung „Titel“) speichert entprellt nach 1500 ms per updateWidgetConfig mit { title }
|
||||
- Kachel im Bearbeitungsmodus: Knopf „Server auswählen“ mit aria-expanded öffnet die Auswahl an Stelle der Liste; eine Änderung speichert sofort per updateWidgetConfig mit { serverIds } und filtert die Anzeige
|
||||
- Kachel: Verlassen des Bearbeitungsmodus schließt die Auswahl
|
||||
- Einstellungsformular: lädt die Server einmal per listServers, zeigt Lade-, Fehler- und Leersatz; Titeländerung ruft onChange({ title }), Auswahländerung onChange({ serverIds })
|
||||
- Einstellungsbereich: Kachel-Typ proxmox rendert das Formular; ein gesetzter Titel erscheint hinter „Proxmox #1“
|
||||
- Weder Auswahl-Bauteil noch Formular noch Kachel rufen die manuelle Abfragefunktion auf
|
||||
</behavior>
|
||||
<action>
|
||||
**RED zuerst** für Auswahl-Bauteil, Formular, Einstellungsbereich und die neuen Kachel-Fälle gemäß
|
||||
`<behavior>`, dann umsetzen.
|
||||
|
||||
**1. Gemeinsames Auswahl-Bauteil** `components/proxmox/proxmox-server-picker.tsx`:
|
||||
`ProxmoxServerPicker({ servers, selectedIds, onChange })` als `fieldset` mit `legend`
|
||||
`widgets.proxmox.serversLabel` („Angezeigte Server“), Hinweis `widgets.proxmox.serversHint` („Ohne
|
||||
Auswahl zeigt die Kachel alle Server.“), je Server ein Kästchen, Reihenfolge nach `position`,
|
||||
Beschriftung Name plus gedämpftes Produktwort `proxmox.card.product.<typ>`. Eindeutige
|
||||
Feldkennungen je Instanz über `useId`. `onChange(next)`: Kennungen in Listenreihenfolge, nur
|
||||
vorhandene Server — so räumt jede Änderung Kennungen gelöschter Server mit auf. Keine eigene
|
||||
Datenabfrage im Bauteil; es bekommt die Liste hereingereicht.
|
||||
|
||||
**2. Kachel im Bearbeitungsmodus** (`proxmox-widget.tsx`): Innenabstand oben `pt-5`, damit die
|
||||
20 px hohe Griffleiste des Wrappers das Titelfeld nicht verdeckt (vgl. `top-6` im XFrame). Die
|
||||
Kopfzeile erscheint im Bearbeitungsmodus immer: Titelfeld im Favoriten-Muster (Klasse
|
||||
`widgetNoDrag`, Beschriftung `widgets.proxmox.titleLabel`, Platzhalter `widgets.proxmox.titlePlaceholder`,
|
||||
Entprellung 1500 ms, `updateWidgetConfig(instanceId, { title })`, Zeitgeber beim Aushängen löschen),
|
||||
daneben ein kleiner Textknopf `widgets.proxmox.chooseServers` („Server auswählen“) mit
|
||||
`aria-expanded` und `data-no-drag`. Offen ersetzt die Auswahl den Listenbereich (scrollbar, Hülle
|
||||
mit `widgetNoDrag`, damit Klicks auf Beschriftungen kein Ziehen starten); sie bekommt die bereits
|
||||
geladene, **ungefilterte** Liste — kein zusätzlicher Abruf. Eine Änderung setzt den lokalen Zustand
|
||||
und speichert sofort mit `updateWidgetConfig(instanceId, { serverIds })` (Muster Ansichtswechsel der
|
||||
Favoriten); die Anzeige filtert sofort mit. Verlässt der Nutzer den Bearbeitungsmodus, schließt
|
||||
die Auswahl. Lokaler Zustand für Titel und Auswahl wird aus `config` initialisiert (wie `viewMode`
|
||||
bei den Favoriten).
|
||||
Grund für die Auswahl direkt an der Kachel: die Seite Einstellungen > Dashboard zeigt nur die
|
||||
Kacheln des ersten Reiters (bekannte Grenze aus quick-260923-ad9); eine Proxmox-Kachel auf einem
|
||||
zweiten Reiter wäre sonst nicht einstellbar. Die Einstellungsseite selbst wird **nicht** geändert.
|
||||
|
||||
**3. Einstellungsformular** `components/settings/proxmox-widget-config-form.tsx`:
|
||||
`ProxmoxWidgetConfigForm({ config, onChange })` — Titelfeld (Kennung `proxmox-widget-title`,
|
||||
Aufbau wie `FavoritesConfig`, sendet den rohen Tippwert), darunter nach einmaligem `listServers()`
|
||||
das Auswahl-Bauteil; Ladesatz `proxmox.loading`, Fehlersatz `proxmox.loadError`, ohne Server
|
||||
`widgets.proxmox.empty`. Aus `@/lib/proxmox-api` nur `listServers` und Typen importieren.
|
||||
In `widget-settings-panel.tsx` einen Zweig `widget.widgetType === 'proxmox'` mit dem Formular
|
||||
ergänzen und `'proxmox'` in die Bedingung für den Titel-Zusatz in der Instanz-Kopfzeile aufnehmen.
|
||||
|
||||
**4. Texte** (de/en) unter `widgets.proxmox`: `titleLabel` „Titel“/„Title“, `titlePlaceholder`
|
||||
„Titel (optional)“/„Title (optional)“, `serversLabel` „Angezeigte Server“/„Servers shown“,
|
||||
`serversHint` „Ohne Auswahl zeigt die Kachel alle Server.“/„With nothing selected, the tile shows all servers.“,
|
||||
`chooseServers` „Server auswählen“/„Choose servers“.
|
||||
|
||||
**5. Doku** in Alltagssprache:
|
||||
- `docs/anleitung-anwender.md`: neue Zeile „Proxmox“ in der Tabelle „Verfügbare Widgets“ (was die
|
||||
Kachel zeigt, Klick führt zur Proxmox-Seite, nur mit Zugriff auf das Modul sichtbar, aktualisiert
|
||||
sich jede Minute aus dem zuletzt gespeicherten Stand und fragt die Server dabei nicht neu ab,
|
||||
Titel und Serverauswahl im Bearbeitungsmodus oder unter Einstellungen > Dashboard); Proxmox in
|
||||
die Aufzählung „Für Uhr, Suchleiste, … gibt es zusätzliche Einstellungen“ und in den Absatz
|
||||
„Dashboard > Widgets“ aufnehmen; im Abschnitt „### Proxmox“ ein kurzer Absatz zur Kachel.
|
||||
- `docs/anleitung-entwicklung.md`, Abschnitt „Eine Kachel zum Modul“: Proxmox als erstes echtes
|
||||
Beispiel nennen (`proxmox-widget.tsx`, Modellfunktionen in `proxmox-widget-model.ts`) und dass die
|
||||
gemeinsame Statuslogik seit 260924-i8v unter `apps/web/src/components/proxmox/` liegt.
|
||||
|
||||
**6. CHANGELOG.md** unter „## Unveröffentlicht“, Abschnitt „### Neu“, ein Stichpunkt in Nutzersprache, im
|
||||
Stil der Nachbarn (Aufzählung mit Semikolon oder Gedankenstrich, keine Mittelpunkte): Dashboard-Kachel
|
||||
„Proxmox“ — farbiger Balken mit „Alles in Ordnung“ oder z. B. „1 nicht erreichbar“, darunter die
|
||||
Server, auffällige zuerst, mit je einer Kennzahl (laufende Gäste, letzte Sicherung, eingehende
|
||||
Mails); Klick öffnet die Proxmox-Seite; eigener Titel und Auswahl einzelner Server; aktualisiert
|
||||
sich jede Minute, ohne die Server neu abzufragen; nur für Benutzer mit Zugriff auf das Modul.
|
||||
|
||||
**7. Gesamt-Tore** (alle Befehle aus `<verify>`): Web-Tests vollständig, API-Tests vollständig,
|
||||
`pnpm turbo run type-check lint`, Biome-Warnungen Web höchstens 53 (Stand vorher: 53), Stilprüfung
|
||||
der neuen Dateien und der neuen Texte, Tailwind-Probelauf: ein Wegwerf-Skript im Scratchpad, das
|
||||
im Ordner `apps/web` `@tailwindcss/postcss` mit `@import "tailwindcss" source(none);` und `@source`
|
||||
auf `proxmox-widget.tsx` laufen lässt und prüft, dass die Ausgabe `@container (width < 15rem)`,
|
||||
`@container (width < 8rem)` und `@container (max-height:7.5rem)` enthält; das Skript danach löschen,
|
||||
nichts davon committen. Keine neuen `any`, `!`-Nicht-null-Behauptungen oder Biome-Ausnahmen in den
|
||||
neuen Dateien. Docker wird nicht neu gebaut — den Browser-Nachweis macht der Orchestrator.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${W:-0}" -le 53 && test -z "$(grep -nE 'uppercase|tracking-widest|·|→|dangerouslySetInnerHTML' apps/web/src/components/dashboard/widgets/proxmox-widget.tsx apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts apps/web/src/components/proxmox/proxmox-server-picker.tsx apps/web/src/components/settings/proxmox-widget-config-form.tsx)" && test "$(grep -vE '^\s*(//|\*|/\*|\{/\*)' apps/web/src/components/dashboard/widgets/proxmox-widget.tsx apps/web/src/components/proxmox/proxmox-server-picker.tsx apps/web/src/components/settings/proxmox-widget-config-form.tsx | grep -c 'pollServer')" -eq 0 && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json"); const s = JSON.stringify(m.widgets.proxmox); for (const k of ["name","description","allOk","empty","guests","noGuests","load","backupAgo","mailIn","selectionGone","titleLabel","titlePlaceholder","serversLabel","serversHint","chooseServers"]) if (!(k in m.widgets.proxmox)) throw new Error(f + ": fehlt widgets.proxmox." + k); if (/·|→/.test(s)) throw new Error(f + ": verbotenes Zeichen"); }' && grep -q "Proxmox" CHANGELOG.md && grep -q "| Proxmox |" docs/anleitung-anwender.md && grep -q "components/proxmox" docs/anleitung-entwicklung.md</automated>
|
||||
</verify>
|
||||
<done>Titel und Serverauswahl lassen sich an der Kachel im Bearbeitungsmodus (jeder Reiter) und unter Einstellungen > Dashboard setzen; keine Auswahl = alle Server; Kennungen gelöschter Server räumen sich bei der nächsten Änderung selbst auf. Anwender- und Entwicklerdoku sowie CHANGELOG ergänzt. Web-Tests vollständig, API-Tests vollständig, type-check und lint grün, Biome-Warnungen Web ≤ 53, Tailwind erzeugt alle drei Container-Stufen, Stil- und Abfrage-Grep leer. Commits je Aufgabe mit `feat(260924-i8v)`/`test(260924-i8v)`/`docs(260924-i8v)`; `.planning/**` wird vom Executor nicht committet.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → API `GET /modules/proxmox/servers` | Kachel und Formular lesen Serverdaten; Zugriff entscheidet `@UseModule('proxmox')` serverseitig |
|
||||
| Browser → API `PATCH /dashboard/widgets/:id` (updateWidgetConfig) | `config.title`/`config.serverIds` kommen vom Client und sind nicht vertrauenswürdig |
|
||||
| API → Proxmox-Hosts | Abfragen laufen nur über Zeitplaner bzw. Admin-Knopf der Modulseite; die Kachel darf diese Grenze nie auslösen |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-I8V-01 | Information Disclosure | Proxmox-Kachel für Benutzer ohne Modulzugriff | high | mitigate | `WIDGET_MODULE_SLUGS.proxmox = 'proxmox'` → `DashboardService.getWidgets` filtert fail-closed; `GET servers` bleibt hinter `@UseModule('proxmox')`; Katalogfilter nur Komfort (T-M1H-01). API-Spec prüft die Zuordnung. |
|
||||
| T-I8V-02 | Denial of Service | Kachel/Formular lösen Live-Abfragen bei Proxmox aus (je Kachel und Benutzer alle 60 s) | high | mitigate | Nur `listServers` (Zwischenlager) importiert; Test mit Spion: manuelle Abfragefunktion nie aufgerufen; Grep-Tor in Aufgabe 2 und 3; Takt pausiert bei verborgenem Tab. |
|
||||
| T-I8V-03 | Tampering | `config.serverIds` / `config.title` vom Client | low | mitigate | Nur als clientseitiger Anzeigefilter über die vom Server gelieferte Liste genutzt; `resolveProxmoxWidgetConfig` verwirft Nicht-Strings; kein Zugriff auf Server außerhalb der eigenen Liste möglich. |
|
||||
| T-I8V-04 | Tampering (XSS) | Servername und Titel in der Kachel | medium | mitigate | Ausgabe nur als React-Text, kein Roh-HTML-Einschub (Grep-Tor in Aufgabe 3). |
|
||||
| T-I8V-05 | Information Disclosure | Fehlerdetails auf gemeinsam sichtbaren Dashboards | low | mitigate | Die Kachel zeigt bei down nur „nicht erreichbar“, weder `errorDetail` noch `rawSample` noch Adresse; Details bleiben auf der Modulseite. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Executor (in Aufgabe 3 gebündelt):
|
||||
- `pnpm --filter @tessera/web exec vitest run` — vollständig grün
|
||||
- `pnpm --filter @tessera/api exec vitest run` — vollständig grün (API-Spec wurde angefasst)
|
||||
- `pnpm turbo run type-check lint` — grün; Biome-Warnungen Web ≤ 53
|
||||
- Stil- und Abfrage-Grep leer; Tailwind-Probelauf erzeugt alle drei Container-Stufen
|
||||
|
||||
**Browser-Nachweis — führt der Orchestrator durch, kein Executor-Task** (Playwright, lokaler Stack
|
||||
mit `--build`, hell UND dunkel, 1400 px Breite):
|
||||
1. Admin: „Widget hinzufügen“ zeigt „Proxmox“ als zehnte Kachel; anlegen → 8×8, kein 400.
|
||||
2. Kachel zeigt 6-px-Balken und „Alles in Ordnung“ (grün) bzw. die Zusammenfassung in der Farbe des
|
||||
schlimmsten Zustands; Zeilen in der Reihenfolge down, warn, ok, idle, orphan, Kennzahlen rechts.
|
||||
3. Klick auf eine Zeile öffnet `/modules/proxmox`; im Bearbeitungsmodus nicht, die Kachel lässt sich
|
||||
über den Zeilen ziehen und vergrößern.
|
||||
4. Größenstufen: auf 4 Spalten verkleinern → nur Punkte und Namen; auf 3×4 → nur Balken und
|
||||
Zusammenfassung.
|
||||
5. Bearbeitungsmodus: Titel setzen, „Server auswählen“ → einen Server abwählen → Kachel filtert,
|
||||
nach Neuladen bleibt es so; Einstellungen > Dashboard zeigt dieselbe Auswahl.
|
||||
6. Netzwerk über gut 60 s: nur `GET /modules/proxmox/servers` im Minutentakt, **kein**
|
||||
`POST …/poll`; Tab verbergen → kein Abruf.
|
||||
7. Benutzer ohne Proxmox-Zugriff: Katalog ohne „Proxmox“, vorhandene Kachel erscheint nicht.
|
||||
8. Englisch umschalten: keine rohen Schlüssel.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Die Kachel „Proxmox“ ist im Katalog für berechtigte Benutzer anlegbar und zeigt den Zustand in der
|
||||
Statussprache der Modulseite (Balken, Zusammenfassung, sortierte Liste, eine Kennzahl je Server).
|
||||
- Gemeinsame Proxmox-Teile liegen einmal unter `apps/web/src/components/proxmox/`; keine kopierte
|
||||
Statuslogik, kein doppeltes Zahlformat.
|
||||
- Kein Aufruf der manuellen Abfrage aus Kachel, Auswahl oder Formular; minütliches Nachladen nur aus
|
||||
dem Zwischenlager, pausiert bei verborgenem Tab.
|
||||
- Titel und Serverauswahl an der Kachel und in den Einstellungen; unbekannte Werte heißen „unbekannt“.
|
||||
- Alle Tore grün, Biome-Warnungen Web ≤ 53, Doku und CHANGELOG in Nutzersprache ergänzt.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260924-i8v-proxmox-kachel-fuers-dashboard/260924-i8v-SUMMARY.md` when done
|
||||
(deutsch, Muster der h7x-Summary: Was gebaut wurde, Abweichungen, Tore mit gemessenen Zahlen,
|
||||
Hinweise für den Browser-Nachweis, „Bewusst offen“: Einstellungsseite zeigt weiterhin nur den ersten
|
||||
Reiter).
|
||||
</output>
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
quick_id: 260924-i8v
|
||||
phase: quick
|
||||
plan: 260924-i8v
|
||||
subsystem: web / dashboard, proxmox-modul; shared (Kacheltypen); api (Kachel-Modul-Zuordnung)
|
||||
status: complete
|
||||
tags: [proxmox, dashboard, modul-kachel, container-query, statusfarben, a11y]
|
||||
requires: [quick-260922-m1h (Modul bringt Kachel mit), quick-260924-h7x (Statussprache Proxmox), quick-260923-dhh (Proxmox-Modul)]
|
||||
provides:
|
||||
- Kacheltyp proxmox (WIDGET_TYPES zuletzt, WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' })
|
||||
- ProxmoxWidget mit Balken, Zusammenfassung, Serverliste, Minutentakt, Bearbeitungsmodus
|
||||
- gemeinsamer Ordner apps/web/src/components/proxmox/ (Statuslogik, HealthBar compact, Stile, Zahlformat, Serverauswahl)
|
||||
- ProxmoxWidgetConfigForm fuer Einstellungen > Dashboard
|
||||
affects:
|
||||
- apps/web/src/app/(portal)/modules/proxmox/* (Importpfade, Zahlformat aus gemeinsamer Datei)
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx, (portal)/page.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/api/src/dashboard/widget-module-map.* (Zuordnung jetzt nicht mehr leer)
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Modul-Kachel liest nur das Zwischenlager des Moduls, nie eine Live-Abfrage (Grep-Tor + Spion im Test)
|
||||
- Zeilen im Ansichtsmodus Links, im Bearbeitungsmodus schlichte Elemente (Abbruch-Selektor des Rasters)
|
||||
- Groessenstufen per Container-Query am Wrapper-Rumpf, kein innerer Container
|
||||
- Zeitgeber-Tests faelschen nur setInterval bzw. setTimeout, damit die Warte-Helfer der Testing Library laufen
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts
|
||||
- apps/web/src/components/proxmox/proxmox-server-picker.tsx
|
||||
- apps/web/src/components/proxmox/proxmox-server-picker.test.tsx
|
||||
- apps/web/src/components/settings/proxmox-widget-config-form.tsx
|
||||
- apps/web/src/components/settings/proxmox-widget-config-form.test.tsx
|
||||
moved:
|
||||
- apps/web/src/components/proxmox/proxmox-status.ts (git mv aus app/(portal)/modules/proxmox/components/)
|
||||
- apps/web/src/components/proxmox/proxmox-status.test.ts (git mv)
|
||||
- apps/web/src/components/proxmox/HealthBar.tsx (git mv)
|
||||
- apps/web/src/components/proxmox/status-styles.ts (git mv)
|
||||
modified:
|
||||
- packages/shared/src/index.ts
|
||||
- apps/api/src/dashboard/widget-module-map.ts
|
||||
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/app/(portal)/page.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- Die Kachel zeigt im Zustand „ausgewählte Server gibt es nicht mehr“ bei offener Auswahl direkt die Auswahl statt des Satzes, damit sich der Zustand an Ort und Stelle beheben lässt
|
||||
- „Server auswählen“ erscheint nur, wenn eine Serverliste geladen und nicht leer ist
|
||||
- Speichern aus der Kachel (Titel, Auswahl) fängt Fehler still ab; der nächste Ladevorgang zeigt den gespeicherten Stand
|
||||
- Die Kennzahl bei PVE-Warnung fällt auf die Gästezahl zurück, falls kein einziger Auslastungswert bekannt ist
|
||||
metrics:
|
||||
duration: 22min
|
||||
completed: 2026-09-24
|
||||
tasks: 3
|
||||
files: 29
|
||||
plan_head_before: 8bfa4fc46737b91ed6860089721a67f17014c3d1
|
||||
actuals:
|
||||
tokens: 29800
|
||||
tasks: 3
|
||||
commits: 6
|
||||
---
|
||||
|
||||
# Quick 260924-i8v: Proxmox-Kachel fürs Dashboard
|
||||
|
||||
Die erste echte Modul-Kachel: „Proxmox“ zeigt oben einen 6 px hohen Gesundheitsbalken und darunter in Worten „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar, 1 mit Warnung“ in der Farbe des schlimmsten Zustands. Darunter stehen die Server nach Dringlichkeit, jeder mit Statuspunkt, Name und genau einer Kennzahl. Ein Klick führt zur Modulseite. Die Kachel liest nur das Zwischenlager, frischt sich minütlich auf, lässt sich auf Titel und Serverauswahl einstellen und stuft sich per Container-Query ab.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Aufgabe 1: Durchstich (a906c67)**
|
||||
- Die vier gemeinsamen Dateien (`proxmox-status.ts` samt Test, `HealthBar.tsx`, `status-styles.ts`) liegen jetzt per `git mv` unter `apps/web/src/components/proxmox/`. Die Git-Historie bleibt erhalten. Modulseite und `ServerCard` importieren sie über `@/components/proxmox/...`. An der Logik hat sich nichts geändert.
|
||||
- `HealthBar` hat eine neue Variante `variant="compact"`: 6 px hoch (`h-1.5`), ohne Legende und mit `aria-hidden`. Die Variante `full` ist Standard, die Modulseite sieht unverändert aus. Beide tragen `data-variant`.
|
||||
- `packages/shared`: `'proxmox'` steht am Ende von `WIDGET_TYPES`, dazu `WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }`. Der Kommentar „bewusst leer“ ist in beiden Dateien korrigiert, in `shared` und in `widget-module-map.ts`.
|
||||
- Registry: 3/4/8/8 mit Rechenkommentar, Inline-Symbol mit Server-Einschüben, `moduleSlug: WIDGET_MODULE_SLUGS.proxmox`.
|
||||
- `registerWidget('proxmox', ProxmoxWidget)` steht in `(portal)/page.tsx`, das passende `vi.mock` im Seitentest.
|
||||
- Die bestehenden Tests sind nachgezogen:
|
||||
- Die Registry kennt zehn Typen. Nur proxmox trägt einen Modul-Slug. Die Tests für einen unbekannten Typ nutzen jetzt `'gibt-es-nicht'`.
|
||||
- Der Katalog zeigt zehn Kacheln bei `['proxmox']`, neun bei `[]`. Mit `null` fehlt Proxmox, und zwar an der echten Kachel statt an der vorher nur vorübergehend umgebauten Uhr.
|
||||
- Die API-Spec prüft `getModuleSlugForWidgetType('proxmox') === 'proxmox'`.
|
||||
|
||||
**Aufgabe 2: Serverliste (RED 92bf130, GREEN a217d60)**
|
||||
- `formatPercent` und `formatCount` sind unverändert aus `ServerCard.tsx` nach `proxmox-status.ts` gezogen. ServerCard importiert sie von dort, damit das Zahlformat nicht doppelt existiert.
|
||||
- Die reinen Funktionen in `proxmox-widget-model.ts`:
|
||||
- `resolveProxmoxWidgetConfig` liest die Konfiguration abwehrend: Nicht-Strings und leere Kennungen fallen weg, doppelte zählen einmal.
|
||||
- `selectServers`: leere Auswahl bedeutet alle Server; bleiben nur gelöschte Kennungen, wird `selectionGone` gesetzt.
|
||||
- `widgetKeyFigure` wählt die eine Kennzahl je Zeile:
|
||||
- Status bei down, idle und orphan
|
||||
- Gäste bei PVE im Normalzustand, bei PVE-Warnung der höchste bekannte Anteil aus CPU, RAM und Speicher
|
||||
- bei PBS die älteste bekannte Sicherung samt Hinweis, ob sie veraltet ist
|
||||
- bei PMG `countIn`
|
||||
- sonst `unknown`, nie 0
|
||||
- Kachel:
|
||||
- Die gefilterte Liste speist Balken, Zusammenfassung und Zeilen, sortiert nach `sortServersByHealth`.
|
||||
- Je Zeile ein Punkt, der Name (bei orphan nur der Name mit `opacity-70`) und das Zustandswort als `sr-only`.
|
||||
- Die Kennzahl steht rechts mit `@max-[15rem]:hidden`. Wiederholt sie nur den Zustand, ist sie `aria-hidden`.
|
||||
- Die Liste trägt `[@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden`.
|
||||
- Im Ansichtsmodus ist jede Zeile ein `next/link` auf `/modules/proxmox`. Im Bearbeitungsmodus ist sie ein `div` ohne Tabstopp, das gilt auch für den Admin-Link im Leerzustand.
|
||||
- Takt: `REFRESH_MS = 60_000`, bei verborgenem Tab kein Abruf, `visibilitychange` zurück auf sichtbar lädt sofort. Beim Aushängen wird aufgeräumt.
|
||||
- Scheitert ein späteres Nachladen, bleibt die zuletzt geladene Liste stehen.
|
||||
|
||||
**Aufgabe 3: Einstellen, Doku, Tore (RED 377b6e3, GREEN 586da44, Doku 602a45c)**
|
||||
- `ProxmoxServerPicker`:
|
||||
- ein `fieldset` mit der Legende „Angezeigte Server“ und dem sichtbaren Hinweis „Ohne Auswahl zeigt die Kachel alle Server.“
|
||||
- je Server ein Kästchen, sortiert nach `position`, Name plus Produktwort, eindeutige Kennungen per `useId`
|
||||
- `onChange` liefert die Kennungen in Listenreihenfolge und nur für vorhandene Server. Kennungen gelöschter Server räumen sich dabei selbst auf.
|
||||
- Kachel im Bearbeitungsmodus:
|
||||
- `pt-5` unter der Griffleiste. Die Kopfzeile erscheint immer, mit Titelfeld (`widgetNoDrag`, 1500 ms entprellt, `updateWidgetConfig({ title })`) und dem Textknopf „Server auswählen“ (`aria-expanded`, `data-no-drag`).
|
||||
- Die offene Auswahl ersetzt den Listenbereich in einer Hülle mit `widgetNoDrag`. Sie bekommt die ungefilterte, schon geladene Liste; ein zusätzlicher Abruf findet nicht statt.
|
||||
- Eine Änderung filtert sofort und speichert `{ serverIds }`. Beim Verlassen des Bearbeitungsmodus schließt die Auswahl.
|
||||
- `ProxmoxWidgetConfigForm` (Titel `proxmox-widget-title` plus Auswahl) ist unter Einstellungen > Dashboard eingehängt. Die Instanz-Kopfzeile zeigt „Proxmox #1 — Titel“.
|
||||
- Doku:
|
||||
- Anwenderdoku: Tabellenzeile „Proxmox“, Aufzählung der zusätzlichen Einstellungen, Absatz „Dashboard > Widgets“, Absatz im Abschnitt „### Proxmox“.
|
||||
- Entwicklerdoku: Proxmox als erstes Beispiel unter „Eine Kachel zum Modul“, mit dem Ort `components/proxmox/`.
|
||||
- CHANGELOG: ein Punkt unter „Unveröffentlicht > Neu“.
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
1. **[Rule 1, Kommentar] `apps/api/src/dashboard/widget-module-map.ts`** stand nicht in der Dateiliste. Sein Kopfkommentar behauptete aber weiterhin „Die Tabelle ist bewusst leer“. Korrigiert ist nur der Kommentar, der Code ist unverändert (a906c67).
|
||||
2. **Zeitgeber-Tests:** Statt `vi.useFakeTimers()` ohne Einschränkung werden nur `setInterval`/`clearInterval` gefälscht, beim Entprell-Test nur `setTimeout`/`clearTimeout`. Grund: `findBy`/`waitFor` der Testing Library laufen über `setTimeout` und blieben mit vollständig gefälschten Zeitgebern unter Vitest hängen. Die geforderten Nachweise sind unverändert erbracht (60 s, verborgen, sichtbar, Aushängen).
|
||||
3. **Auswahl im Zustand „Server gibt es nicht mehr“:** Ist die Auswahl im Bearbeitungsmodus offen, zeigt die Kachel dort die Auswahl statt des Satzes. Der Satz selbst fordert dazu auf, im Bearbeitungsmodus andere Server zu wählen, und das ist so an Ort und Stelle möglich.
|
||||
4. **Biome:** Importreihenfolge in `ServerCard.tsx` und Formatierung der neuen Dateien mit `biome check --write` angeglichen. Die Reihenfolge verschob sich durch den neuen `@/components`-Pfad. Keine Logikänderung.
|
||||
|
||||
Keine Architekturfragen, keine neuen Pakete, keine Anmelde-Sperren.
|
||||
|
||||
## Tore (gemessen)
|
||||
|
||||
| Tor | Ergebnis |
|
||||
|---|---|
|
||||
| Web-Tests vollständig (`vitest run`) | 91 Dateien, 864 Tests grün |
|
||||
| API-Tests vollständig (`vitest run`) | 84 Dateien, 1370 Tests grün, darunter `src/prisma/rls-access-inventory.spec.ts` (30 Tests, einzeln nachgeprüft) |
|
||||
| `pnpm turbo run type-check lint` | 9 von 9 Aufgaben erfolgreich |
|
||||
| Biome-Warnungen Web | 53 (Grenze 53); die neuen Dateien sind warnungsfrei |
|
||||
| Stil-Grep (uppercase, tracking-widest, Mittelpunkt, Pfeil, dangerouslySetInnerHTML) | leer |
|
||||
| Abfrage-Grep `pollServer` in Kachel, Modell, Auswahl, Formular | 0 Treffer außerhalb von Kommentaren |
|
||||
| Übersetzungen `widgets.proxmox.*` (15 Schlüssel) in de und en | vollständig; die Umlaut-Wache ist grün |
|
||||
| Tailwind-Probelauf (Wegwerfskript im Scratchpad, danach gelöscht) | `@container (width < 15rem)`, `@container (width < 8rem)` und `@container (max-height:7.5rem)` werden erzeugt |
|
||||
| `any`, `!`, `biome-ignore` in neuen Dateien | keine |
|
||||
|
||||
**TDD:** Die RED-Phase ist im Verlauf belegt: Test-Commit 92bf130 vor a217d60 und Test-Commit 377b6e3 vor 586da44. Die Tests schlugen aus dem richtigen Grund fehl: fehlende Exporte, Zeilen und Module.
|
||||
|
||||
## Hinweise für den Browser-Nachweis
|
||||
|
||||
- Die Kachel braucht einen neu gebauten Web-Container, also `--build`. Docker wurde vom Executor nicht angefasst.
|
||||
- Zeilen tragen `data-testid="proxmox-row"` und `data-server-name`, die Kennzahl `data-testid="proxmox-key-figure"`, die Liste `data-testid="proxmox-list"`, die Zusammenfassung `data-testid="proxmox-summary"`, der Balken `data-testid="health-bar"` mit `data-variant="compact"`.
|
||||
- Größenstufen: bei 4 Spalten (rund 216 px, also unter 15rem = 240 px) nur Punkte und Namen. Bei 3×4 (104 px hoch, unter 7.5rem = 120 px) nur Balken und Zusammenfassung.
|
||||
- Netzwerk: im Minutentakt nur `GET /modules/proxmox/servers`. Beim Öffnen von „Server auswählen“ gibt es keinen weiteren Abruf.
|
||||
- Im Bearbeitungsmodus sind Titelfeld, Knopf und Auswahlhülle vom Ziehen ausgenommen. Die Zeilen sind keine Links, die Kachel bleibt über ihnen ziehbar.
|
||||
|
||||
## Bewusst offen
|
||||
|
||||
- Einstellungen > Dashboard zeigt weiterhin nur die Kacheln des ersten Reiters (bekannte Grenze aus quick-260923-ad9). Deshalb gibt es Titel und Serverauswahl zusätzlich direkt an der Kachel.
|
||||
- Lokaler Titel- und Auswahlzustand der Kachel wird wie bei den Favoriten nur beim Einhängen aus `config` gelesen. Eine Änderung unter Einstellungen > Dashboard wirkt auf eine gleichzeitig offene Dashboard-Seite erst nach dem Neuladen.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Alle angelegten Dateien vorhanden: proxmox-widget.tsx, proxmox-widget-model.ts, beide Tests, proxmox-server-picker.tsx samt Test, proxmox-widget-config-form.tsx samt Test, die vier verschobenen Dateien unter components/proxmox/.
|
||||
- Alle Commits vorhanden: a906c67, 92bf130, a217d60, 377b6e3, 586da44, 602a45c.
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
---
|
||||
quick_id: 260924-m4n
|
||||
type: quick
|
||||
wave: 1
|
||||
autonomous: true
|
||||
---
|
||||
|
||||
# Quick 260924-m4n — Flackernden Test entschaerfen; DashboardImage Stufe 2 (Spalte `data` entfernen)
|
||||
|
||||
Nutzerfreigabe 24.09.: beide offenen Punkte erledigen. Keine Freigabe/kein Tag.
|
||||
|
||||
## Task 1 — Flackernder Test `TenantContextSelector`
|
||||
|
||||
Todo: `.planning/todos/pending/2026-09-23-flackernder-test-tenant-selector-zeitueberschreitung.md`
|
||||
(lesen, dort steht die Analyse). Test: `apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx:100`.
|
||||
|
||||
- Ursache ansehen (warum nahe 5 s?). Den Doppelfall (SUPER_ADMIN + ADMIN in EINEM `it`) in zwei
|
||||
`it` auftrennen; langsame Stellen (unnoetige echte Wartezeiten, schwere Importe je Test)
|
||||
beseitigen. KEIN globales Hochsetzen von `testTimeout`.
|
||||
- Messen: `vitest run --reporter=verbose` fuer die ganze Web-Suite, die 10 langsamsten Tests
|
||||
auflisten (Dauer). Jeder Test ueber 2 s wird in der SUMMARY genannt; wenn eine Ursache offensichtlich
|
||||
und klein ist, gleich beheben, sonst nur auflisten.
|
||||
- Nebenbei (klein, gleiche Datei-Gruppe erlaubt): die `act(...)`-Warnungen aus
|
||||
`apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx` beseitigen (auf das Ende der
|
||||
Zustandsaenderung warten statt sie ins Leere laufen zu lassen) — sie blaehen das CI-Protokoll auf.
|
||||
- Todo-Datei nach `.planning/todos/done/` verschieben (git mv), mit kurzem Nachtrag „erledigt in 260924-m4n“.
|
||||
|
||||
## Task 2 — DashboardImage Stufe 2
|
||||
|
||||
Todo: `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md` — die dort
|
||||
genannten Schritte 2–4 umsetzen. Vorbedingung geprueft vom Orchestrator: alpha
|
||||
`count(storagePath IS NULL) = 0` (3 Zeilen). Live ist von hier nicht pruefbar (Live laeuft 1.3.1, die den
|
||||
Bootstrap-Umzug enthaelt).
|
||||
|
||||
- Neue Migration mit aktuellem Zeitstempel (NACH allen vorhandenen, `ls apps/api/prisma/migrations`),
|
||||
Name `..._dashboard_image_drop_data`. ZUERST ein Schutz, der den Datenverlust ausschliesst:
|
||||
|
||||
```sql
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM "DashboardImage" WHERE "storagePath" IS NULL) THEN
|
||||
RAISE EXCEPTION 'DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug (quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut deployen';
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
|
||||
ALTER TABLE "DashboardImage" DROP COLUMN "data";
|
||||
```
|
||||
|
||||
Achtung RLS: die Migration laeuft als Eigentuemer; pruefen, dass der `EXISTS`-Check nicht von einer
|
||||
Zeilenregel auf 0 gefiltert wird (FORCE ROW LEVEL SECURITY?). Wenn ja, den Check so formulieren, dass
|
||||
er alle Zeilen sieht (z. B. `SET LOCAL row_security = off` falls als Eigentuemer erlaubt, oder ueber die
|
||||
vorhandene `system_read_policy`). Das Ergebnis der Pruefung in die SUMMARY.
|
||||
- Schema, Dienst (Bootstrap-Umzug + `forSystem()` raus), `FORSYSTEM_ALLOWED_CALL_SITES`, Tests
|
||||
18/21/22/23, Zugriffsklassifikation (Zahlen per Gate-Schleife neu messen, nicht abschreiben),
|
||||
`system_read_policy` auf `DashboardImage` per `DROP POLICY IF EXISTS` in derselben Migration entfernen
|
||||
und die Klassifikation/Aufzaehlung nachziehen.
|
||||
- Lokal anwenden (DB ohne Host-Port: Container-IP, `tessera:tessera_dev`), vorher/nachher
|
||||
`pg_total_relation_size` messen, danach `VACUUM FULL "DashboardImage";` lokal.
|
||||
- Negativtest der Schutzklausel lokal nachweisen: in einer Wegwerf-Datenbank oder Transaktion eine Zeile
|
||||
mit `storagePath NULL` anlegen → Migration bricht mit der Meldung ab (Protokoll in die SUMMARY).
|
||||
- CHANGELOG „Unveröffentlicht“ nur, wenn für Nutzer sichtbar (eher nicht) — sonst weglassen.
|
||||
- `docs/anleitung-betrieb.md`: kurzer Hinweis im Abschnitt Aktualisieren/Freigabe, dass die nächste
|
||||
Version die alte Bildspalte entfernt und bei Abbruch mit der Meldung zuerst 1.3.1 laufen muss.
|
||||
- Todo-Datei nach `.planning/todos/done/` verschieben.
|
||||
|
||||
## Tore
|
||||
|
||||
- API-Tests KOMPLETT (inkl. `src/prisma/rls-access-inventory.spec.ts`), Web-Tests komplett,
|
||||
`pnpm turbo run type-check lint` gruen, Biome-Warnungen web ≤ 53, api ≤ 82.
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
---
|
||||
quick_id: 260924-m4n
|
||||
type: quick
|
||||
status: complete
|
||||
subsystem: apps/web-tests, apps/api/dashboard, apps/api/prisma
|
||||
tags: [flake, vitest, act, prisma-migration, rls, bilderrahmen]
|
||||
requires: [quick-260922-hk4]
|
||||
provides:
|
||||
- Marktplatz-Tests ohne dynamischen Import im Test (Flake der Freigabe 1.3.1 entschärft)
|
||||
- Proxmox-Kachel-Tests ohne act-Warnungen
|
||||
- Migration 20260924120000_dashboard_image_drop_data (Stufe 2, mit Schutzprüfung)
|
||||
affects: [Freigabe der nächsten Version nach 1.3.1, docs/anleitung-betrieb.md Kap. 4]
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260924120000_dashboard_image_drop_data/migration.sql
|
||||
modified:
|
||||
- apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx
|
||||
- apps/web/src/app/(portal)/marketplace/marketplace.test.tsx
|
||||
- apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- apps/api/src/proxmox/proxmox.service.ts
|
||||
- docs/anleitung-betrieb.md
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
decisions:
|
||||
- "Flake-Ursache: Komponenten wurden per await import() IM Test geladen, das Laden zählte in die 5-s-Frist; Lösung statische Importe + Doppelfall aufgetrennt, kein globales testTimeout"
|
||||
- "Migration heißt 20260924120000_dashboard_image_drop_data statt der im Todo vorgemerkten 20260922120100, weil sie hinter allen vorhandenen Migrationen liegen muss"
|
||||
- "Schutzprüfung der Migration schaltet row_security für die Transaktion ab: ein Eigentümer ohne BYPASSRLS würde sonst still 0 Zeilen sehen, jetzt scheitert er laut"
|
||||
- "Upload vergibt die UUID selbst (randomUUID) und legt die Zeile gleich mit storagePath an, weil storagePath Pflicht ist"
|
||||
- "system_read_policy auf DashboardImage in derselben Migration entfernt (kein Leser mehr)"
|
||||
metrics:
|
||||
duration: ~45 min
|
||||
completed: 2026-09-24
|
||||
actuals:
|
||||
tokens: 21900
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: dd09c0831142b7068f060f09ece471eb1e27560b
|
||||
---
|
||||
|
||||
# Quick 260924-m4n: Flackernden Test entschärft, alte Bildspalte des Bilderrahmens entfernt – Zusammenfassung
|
||||
|
||||
Der Marktplatz-Test, der die Freigabe 1.3.1 blockiert hat, lädt seine Komponenten jetzt beim Einlesen der Testdatei statt im Test selbst und ist in zwei Einzelfälle aufgeteilt. Die Proxmox-Kachel-Tests erzeugen keine act-Warnungen mehr (81 weniger im CI-Protokoll). Außerdem ist Stufe 2 der Bilderrahmen-Umstellung umgesetzt: Die Spalte `data` ist weg, `storagePath` ist Pflicht. Eine Schutzprüfung bricht die Migration ab, bevor Daten verloren gehen könnten.
|
||||
|
||||
## Aufgabe 1 – Flackernder Test `TenantContextSelector` (Commit b10734f)
|
||||
|
||||
**Ursache:** `tenant-selector.test.tsx` hat `TenantContextSelector` und die Marktplatzseite per `await import(...)` **innerhalb** der Tests geladen. Der erste Test einer Datei hat damit das Laden und Umwandeln der Module in seiner 5-s-Frist mitbezahlt. Lokal waren das nur rund 115 ms. Bei jeder Freigabe laufen aber drei Pipelines gleichzeitig, und unter dieser Last reißt der Test die Frist. `waitFor` selbst war nicht der Grund: Es bricht schon nach 1 s mit einer eigenen Meldung ab, nicht erst nach 5 s.
|
||||
|
||||
**Behoben:**
|
||||
- Statische Importe: `vi.mock` wird über die Importe gehoben, das Laden fällt damit in die Einlesephase der Datei.
|
||||
- Der Doppelfall steckt jetzt in zwei `it`: „renders tenant options for SUPER_ADMIN“ sowie „renders nothing for ADMIN and does not load the tenant list“. Der ADMIN-Fall prüft zusätzlich, dass kein Abruf stattfindet.
|
||||
- Die gleiche Umstellung gilt für `marketplace.test.tsx` und `marketplace-filters.test.tsx`, die aus demselben Ordner stammen. In `marketplace-filters` ist `userEvent` jetzt per `userEvent.setup({ advanceTimers: vi.advanceTimersByTime })` an die simulierte Uhr gekoppelt. Vorher hat jede Eingabe auf das langsame Nachschieben in Echtzeit durch `shouldAdvanceTime` gewartet. Der Test „typing in search … after debounce“ läuft allein jetzt in 358 ms statt vorher rund 1,1 s in der Gesamtsuite.
|
||||
- `testTimeout` wurde nicht global erhöht.
|
||||
|
||||
**Proxmox-Kachel (`proxmox-widget.test.tsx`):** `renderWidget()` rendert jetzt innerhalb von `await act(async () => …)`. Dadurch laufen die drei Zustandsänderungen nach dem ersten Laden (`setServers`, `setLoadFailed`, `setNow`) innerhalb von act() und nicht mehr danach ins Leere. Den Test „Verlassen des Bearbeitungsmodus“ habe ich genauso umgestellt, und die Komponente wird ebenfalls statisch importiert.
|
||||
- act-Warnungen in der ganzen Web-Suite: **vorher 102, davon 81 von ProxmoxWidget. Nachher 21, davon 0 von ProxmoxWidget.** Die restlichen 21 stammen aus VehicleTable (8), WidgetSettingsPanel (8), DashboardPage (3), CalendarWidget (1) und Header (1). Sie lagen außerhalb des Auftrags und sind nicht angefasst.
|
||||
|
||||
**Messung:** `vitest run --reporter=verbose --reporter=json` über die ganze Web-Suite mit 91 Dateien und 865 Tests, alle grün.
|
||||
|
||||
Die 10 langsamsten Tests nach der Änderung im normalen Lauf mit 12 Kernen:
|
||||
|
||||
| # | Dauer | Datei | Test |
|
||||
|---|---|---|---|
|
||||
| 1 | 1136 ms | components/settings/smtp-settings-form.test.tsx | Test 2: PUT-Payload trägt den Wert; leeres Feld -> null |
|
||||
| 2 | 1044 ms | components/bug-report/bug-report-button.test.tsx | Test 1: Bild VOR dem Dialog … |
|
||||
| 3 | 882 ms | components/settings/proxmox-widget-config-form.test.tsx | lädt die Server einmal und zeigt die Auswahl … |
|
||||
| 4 | 839 ms | app/(portal)/marketplace/marketplace-filters.test.tsx | typing in search filters the grid … after debounce |
|
||||
| 5 | 799 ms | components/proxmox/proxmox-server-picker.test.tsx | zeigt je Server ein Kästchen … |
|
||||
| 6 | 798 ms | modules/tender-radar/settings/components/SourceConfigForm.test.tsx | editing the interval and clicking Speichern … |
|
||||
| 7 | 749 ms | modules/dkv-fleet/settings/components/VehicleTable.test.tsx | clicking trash icon opens confirm dialog … |
|
||||
| 8 | 746 ms | components/settings/picture-frame-config-form.test.tsx | Test 4: Pfeile … |
|
||||
| 9 | 742 ms | app/(portal)/admin/users/user-access-modal.test.tsx | rolls back the direct checkbox … |
|
||||
| 10 | 741 ms | app/(portal)/admin/users/users-page.test.tsx | zeigt den Servertext im offenen Löschdialog … |
|
||||
|
||||
Zusätzlich lief die ganze Suite gedrosselt auf 2 Kerne (`taskset -c 0-1`, 6 Worker), um die Last beim Freigeben nachzustellen. Alle 865 Tests waren grün, der langsamste brauchte 1302 ms (smtp-settings-form Test 2). Die Tests von `tenant-selector` lagen dort bei höchstens 950 ms.
|
||||
|
||||
**Kein Test lag über 2 s**, weder im normalen noch im gedrosselten Lauf. Damit gibt es nichts weiter zu beheben oder aufzulisten.
|
||||
|
||||
**Beobachtung, nicht behoben:** Das Muster „Komponente per `await import()` im Test laden“ steckt noch in 37 weiteren Web-Testdateien. Es macht jeweils den ersten Test einer Datei unter Last anfälliger. Keiner dieser Tests liegt heute nahe am Limit. Umgestellt sind nur der Marktplatz-Ordner und die Proxmox-Kachel.
|
||||
|
||||
## Aufgabe 2 – DashboardImage Stufe 2 (Commit dd54ec5)
|
||||
|
||||
**Migration `20260924120000_dashboard_image_drop_data`:**
|
||||
1. Eine Schutzprüfung im `DO`-Block: Existiert noch eine Zeile mit `storagePath IS NULL`, bricht die Migration mit `RAISE EXCEPTION` ab, und zwar mit der Meldung aus dem Plan.
|
||||
2. `ALTER COLUMN "storagePath" SET NOT NULL`, danach `DROP COLUMN "data"`.
|
||||
3. `DROP POLICY IF EXISTS system_read_policy ON "DashboardImage"`.
|
||||
|
||||
Die Migration musste einen anderen Namen bekommen als im Todo vorgemerkt (`20260922120100`), weil sie hinter allen vorhandenen Migrationen liegen muss (die letzte war `20260923160000`).
|
||||
|
||||
**Prüfung des Zeilenschutzes (RLS), Ergebnis:**
|
||||
- `DashboardImage` hat `FORCE ROW LEVEL SECURITY`, das gilt auch für den Eigentümer. Heute läuft `migrate deploy` als `tessera`. Die Rolle ist lokal gemessen Superuser mit BYPASSRLS und Eigentümerin der Tabelle, sieht also alle Zeilen.
|
||||
- **Die Falle ist real, lokal nachgewiesen:** Ein Eigentümer **ohne** BYPASSRLS bekommt bei `SELECT EXISTS (… storagePath IS NULL)` das Ergebnis `f`, obwohl eine solche Zeile existiert. Die Schutzprüfung wäre dann stumm wirkungslos. Getestet habe ich das mit einer Wegwerf-Rolle `m4n_owner` in einer Transaktion, die danach zurückgerollt wurde.
|
||||
- **Lösung:** Die Prüfung setzt `set_config('row_security', 'off', true)`. Sie sieht damit entweder alle Zeilen, oder PostgreSQL bricht laut ab. Die Meldung „ERROR: query would be affected by row-level security policy for table "DashboardImage"“ ist unter derselben Wegwerf-Rolle nachgewiesen. Als `tessera` greift die Schutzprüfung wie gewollt, auch das ist nachgewiesen. Die Datenbank sieht also nie „0 Zeilen, weiter“.
|
||||
|
||||
**Negativtest der Schutzprüfung** in einer Wegwerf-Datenbank `tessera_m4n_neg`, die danach gelöscht wurde. Alle Migrationen bis `20260923160000` wurden angewendet, dann eine Zeile mit Pfad und eine ohne Pfad angelegt:
|
||||
```
|
||||
Applying migration `20260924120000_dashboard_image_drop_data`
|
||||
Error: P3018
|
||||
Database error code: P0001
|
||||
ERROR: DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug (quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut deployen
|
||||
```
|
||||
Danach war nichts geändert: `data` und `storagePath` waren weiterhin NULLbar, und `system_read_policy` stand noch.
|
||||
|
||||
**Nebenbefund, im Betriebshandbuch beschrieben:** Nach dem Abbruch verweigert auch 1.3.1 den Start mit `P3009` („failed migrations in the target database“). Den Ausweg habe ich vollständig durchgespielt. Zuerst wird der fehlgeschlagene Eintrag in `_prisma_migrations` als zurückgenommen markiert: `UPDATE … SET rolled_back_at = now() WHERE migration_name = '…' AND finished_at IS NULL`. Danach meldet der Stand von 1.3.1 „No pending migrations“. Anschließend habe ich den Umzug nachgestellt, und die neue Version lief sauber durch: NOT NULL gesetzt, Spalte entfernt, nur noch `tenant_isolation_policy` vorhanden. Der Ablauf steht in drei Schritten in `docs/anleitung-betrieb.md` Kapitel 4.
|
||||
|
||||
**Lokal angewendet** über die Container-IP 172.19.0.2 mit `tessera:tessera_dev`:
|
||||
|
||||
| Zeitpunkt | `pg_total_relation_size` | Zeilen |
|
||||
|---|---|---|
|
||||
| vorher (0 ohne Pfad, 1 Zeile mit 502 Bytes in `data`) | 65536 (64 kB) | 2 |
|
||||
| nach DROP | 65536 (64 kB) | 2 |
|
||||
| nach `VACUUM FULL "DashboardImage"` | 65536 (64 kB) | 2 |
|
||||
|
||||
Lokal ist keine Verkleinerung messbar. In `data` standen nur 502 Bytes, und die Tabelle belegt schon mit Tabelle, TOAST und zwei Indizes die Mindestgröße ganzer 8-kB-Seiten. Auf alpha und live mit echten Bildern ist der Gewinn größer. Dort gilt ebenfalls, dass PostgreSQL den Platz erst nach `VACUUM FULL` an das Dateisystem zurückgibt. Ob man das dort ausführt, entscheidest du; ich habe keinen Server angefasst.
|
||||
|
||||
**Dienst (`dashboard-images.service.ts`):**
|
||||
- `onApplicationBootstrap()` samt `forSystem()` ist entfernt, ebenso die Selbstheilung aus `data` in `getBytes`.
|
||||
- `upload` vergibt die UUID selbst mit `randomUUID()` und legt die Zeile gleich mit `storagePath` an, weil das Feld jetzt Pflicht ist. Das nachträgliche `update` entfällt. Das Verhalten bei halb fertigen Zuständen bleibt gleich: erst die Zeile, dann die Datei, und scheitert das Schreiben, wird die Zeile wieder gelöscht und 500 zurückgegeben.
|
||||
- Schema: `data Bytes?` ist gestrichen, `storagePath String?` wird zu `storagePath String`.
|
||||
|
||||
**Tests:** Die Fälle 18 und 21–23 entfallen wie geplant. Dazu fallen **10b und 10c** weg, weil sie die Selbstheilung aus `data` geprüft haben, die es nicht mehr gibt. Test 16 liest jetzt eine JPEG-Datei statt „Datei schlägt Zeilenbytes“, Test 13 prüft die UUID und den Pfad im `create`, und Test 12 prüft die neue Aufrufreihenfolge ohne `update` und ohne `forSystem`. Die Dienst-Spec hat damit 19 statt 25 Tests.
|
||||
|
||||
**Erlaubnisliste und Klassifikation:**
|
||||
- `FORSYSTEM_ALLOWED_CALL_SITES`: Der Eintrag `dashboard-images.service.ts` ist entfernt, jetzt 5 Dateien mit 6 Aufrufen.
|
||||
- `docs/mandantentrennung-zugriffsklassifikation.md`: Der Stand des Paars `dashboard-images.service.ts`/`dashboardImage` geht von `system-gebunden` zurück auf `gebunden`. Die Zahlen habe ich mit der Gate-Schleife (`for d in apps/api/src/*/`) **neu gemessen**:
|
||||
- Bereich `dashboard` geht auf **1/29/0**. Im Dokument stand 1/28/1, gemessen waren vor dieser Änderung aber schon 1/31/1, weil quick-260923-lrr zwei Treffer (favoriteLink) hinzugefügt hatte, ohne die Zeile nachzuziehen. Diese Änderung selbst zieht 2 gebundene Treffer und 1 System-Treffer ab.
|
||||
- Bereich `favorites` geht auf **0/12/0**. Im Dokument stand 0/8/0, auch hier war die Zeile nach quick-260923-lrr nicht nachgezogen worden.
|
||||
- Die Summe geht auf **61/213/6**, vorher 61/208/7.
|
||||
- Die Aufzählung der Tabellen mit `system_read_policy` ist korrigiert. Es sind jetzt sechs Tabellen (lokal gemessen), `ProxmoxServer` fehlte bisher in der Aufzählung, und `DashboardImage` ist nur noch als vorübergehender Fall erwähnt.
|
||||
- Gegenprobe: Den Stand habe ich absichtlich auf `system-gebunden` zurückgedreht. `rls-access-inventory.spec.ts` wurde rot („dokumentiert=system-gebunden, gemessen=gebunden“), danach habe ich die Datei zurückgesetzt.
|
||||
- Einen veralteten Kommentarverweis auf `DashboardImagesService` als Vorbild habe ich in `proxmox.service.ts` entfernt.
|
||||
|
||||
**CHANGELOG:** bewusst nicht ergänzt, weil die Änderung für Nutzer nicht sichtbar ist.
|
||||
**Betriebshandbuch:** Kapitel 4 hat einen Hinweis für die erste Version nach 1.3.1 bekommen: was die Meldung bedeutet und die drei Schritte zur Wiederherstellung.
|
||||
|
||||
## Tore
|
||||
|
||||
- API-Tests komplett: **84 Dateien, 1364 Tests, grün**, einschließlich `rls-access-inventory.spec.ts` mit 30 Tests.
|
||||
- Web-Tests komplett: **91 Dateien, 865 Tests, grün**, auch gedrosselt auf 2 Kerne.
|
||||
- `pnpm turbo run type-check lint`: **9/9 Aufgaben erfolgreich**.
|
||||
- Biome-Warnungen: **web 53** (Grenze 53), **api 82** (Grenze 82).
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
1. **[Rule 3 – blockierend] Todo-Ordner:** Den Ordner `.planning/todos/done/` aus dem Plan gibt es nicht, die Ablage im Projekt heißt `.planning/todos/completed/`. Beide Todos liegen deshalb dort, jeweils mit einem Nachtrag „Erledigt in quick-260924-m4n“.
|
||||
2. **[Rule 1 – Bug] Weg zurück nach einem Abbruch:** Die Meldung der Schutzprüfung rät, zuerst 1.3.1 laufen zu lassen. Allein das hätte aber nicht funktioniert, weil 1.3.1 danach mit P3009 abbricht. Der fehlende Zwischenschritt steht jetzt im Betriebshandbuch und im Kopfkommentar der Migration, und ich habe ihn durchgespielt.
|
||||
3. **[Rule 1 – Bug] Upload-Ablauf:** Mit `storagePath` als Pflichtfeld hätte der bisherige Ablauf (Zeile ohne Pfad anlegen, Pfad nachtragen) an der Datenbank scheitern müssen. Deshalb vergibt der Dienst die UUID jetzt selbst.
|
||||
4. **Zusätzliche Tests entfallen:** Neben 18 und 21–23 sind auch 10b und 10c weg (siehe oben).
|
||||
5. **Mehr Marktplatz-Dateien umgestellt:** Neben `tenant-selector` sind auch `marketplace.test.tsx` und `marketplace-filters.test.tsx` umgestellt. Es ist dasselbe Muster im selben Ordner.
|
||||
6. **Drift in der Klassifikation nachgeholt:** Die Zeilen `favorites` und `dashboard` stimmten schon vorher nicht mit der Messung überein. Beides ist jetzt nachgeholt und im Dokument begründet.
|
||||
|
||||
## Offen / für dich
|
||||
|
||||
- Live konnte ich von hier aus nicht prüfen. Die nächste Freigabe nach 1.3.1 setzt voraus, dass live einmal auf 1.3.1 gelaufen ist. Wenn nicht, bricht die Migration mit einer klaren Meldung ab, der Weg zurück steht in Kapitel 4.
|
||||
- Optional nach dem Einspielen auf alpha und live: `VACUUM FULL "DashboardImage";`, damit PostgreSQL den Platz der alten Bilddaten tatsächlich an das Dateisystem zurückgibt.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: apps/api/prisma/migrations/20260924120000_dashboard_image_drop_data/migration.sql
|
||||
- FOUND: .planning/todos/completed/2026-09-23-flackernder-test-tenant-selector-zeitueberschreitung.md
|
||||
- FOUND: .planning/todos/completed/2026-09-22-dashboard-image-data-spalte-entfernen.md
|
||||
- FOUND: b10734f, dd54ec5 (gemessen: `git rev-list --count dd09c08..HEAD` = 2)
|
||||
+18
@@ -80,3 +80,21 @@ nichts. Der Gewinn der Umstellung (kleiner `pg_dump`) ist bereits da, weil
|
||||
neue Uploads keine Bytes mehr in die Zeile schreiben. Nur die Bytes der
|
||||
ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei,
|
||||
einmal in der Spalte.
|
||||
|
||||
## Erledigt in quick-260924-m4n (24.09.2026)
|
||||
|
||||
- Migration heißt `20260924120000_dashboard_image_drop_data` (nicht
|
||||
`20260922120100`: sie muss hinter allen vorhandenen Migrationen liegen).
|
||||
Sie prüft zuerst, dass keine Zeile ohne `storagePath` existiert, und bricht
|
||||
sonst mit Meldung ab, bevor sie etwas ändert; `row_security` ist für die
|
||||
Prüfung aus, damit ein Eigentümer ohne BYPASSRLS nicht still 0 Zeilen sieht
|
||||
(lokal nachgewiesen). Danach `storagePath` NOT NULL, `DROP COLUMN "data"`,
|
||||
`DROP POLICY IF EXISTS system_read_policy ON "DashboardImage"`.
|
||||
- Dienst: Bootstrap-Umzug, `forSystem()` und die Selbstheilung aus `data`
|
||||
entfernt; der Upload vergibt die UUID selbst und legt die Zeile gleich mit
|
||||
Pfad an. Erlaubnisliste, Tests (10b, 10c, 18, 21–23 entfallen) und
|
||||
Zugriffsklassifikation nachgezogen (Zahlen mit der Gate-Schleife gemessen).
|
||||
- Wiederherstellungsweg nach einem Abbruch (fehlgeschlagene Migration als
|
||||
zurückgenommen vermerken, 1.3.1 laufen lassen, erneut einspielen) in
|
||||
`docs/anleitung-betrieb.md` Kapitel 4, in einer Wegwerf-Datenbank
|
||||
durchgespielt.
|
||||
+14
@@ -49,3 +49,17 @@ auslöst. Der Fall wiederholt sich also, nicht zufällig.
|
||||
Die drei parallelen Pipelines abschalten: der Lauf auf `main` und der auf dem
|
||||
Tag prüfen unterschiedliche Dinge, und der `live`-Lauf ist die Absicherung,
|
||||
dass der Zweig für sich genommen grün ist.
|
||||
|
||||
## Erledigt in quick-260924-m4n (24.09.2026)
|
||||
|
||||
- Ursache: `TenantContextSelector` und die Marktplatzseite wurden per
|
||||
`await import(...)` INNERHALB der Tests geladen. Das Laden und Umwandeln der
|
||||
Module zählte damit in die 5-s-Frist des ersten Tests — unter Läuferlast
|
||||
(drei Pipelines je Freigabe) reicht das, um die Frist zu reißen.
|
||||
- Behoben: statische Importe (vi.mock wird darüber gehoben), der Doppelfall
|
||||
SUPER_ADMIN/ADMIN in zwei `it` aufgetrennt (der ADMIN-Fall prüft zusätzlich,
|
||||
dass kein Abruf passiert). Dieselbe Umstellung in `marketplace.test.tsx` und
|
||||
`marketplace-filters.test.tsx`; dort ist userEvent zusätzlich an die falsche
|
||||
Uhr gekoppelt (`advanceTimers`). Kein globales `testTimeout`.
|
||||
- Messung (ganze Web-Suite, lokal und auf 2 Kerne gedrosselt): kein Test über
|
||||
2 s; der langsamste lag gedrosselt bei rund 1,3 s.
|
||||
@@ -4,9 +4,25 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
## 1.4.0 – 2026-09-25
|
||||
|
||||
### Neu
|
||||
|
||||
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
|
||||
- Neues Modul „Proxmox“: zeigt Zustand und Auslastung Ihrer Proxmox-Server (Virtualisierung, Datensicherung und Mail-Gateway) – nur lesend, Tessera ändert dort nichts. Die Server trägt ein Administrator in den Einstellungen ein.
|
||||
- Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig
|
||||
- Proxmox-Seite neu gestaltet: ein farbiger Balken oben zeigt auf einen Blick, wie viele Server in Ordnung, mit Warnung, nicht erreichbar, noch nicht abgefragt oder offline sind; jede Karte trägt ihren Zustand in Farbe und als Wort, auffällige Server stehen vorn; Auslastung als Balken mit Prozentzahl (ab 80 % Warnung), letzte Sicherung und letzte Abfrage als „vor 5 Std.“; ein deaktivierter Server erscheint als „Offline & verwaist“ ohne veraltete Messwerte
|
||||
- Dashboard-Kachel „Proxmox“: ein farbiger Balken mit „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar“, darunter die Server, auffällige zuerst, mit je einer Kennzahl (laufende Gäste, letzte Sicherung, eingehende Mails); ein Klick öffnet die Proxmox-Seite; eigener Titel und Auswahl einzelner Server, im Bearbeitungsmodus direkt an der Kachel oder unter Einstellungen > Dashboard; aktualisiert sich jede Minute, ohne die Server neu abzufragen; nur für Benutzer mit Zugriff auf das Modul
|
||||
|
||||
### Geändert
|
||||
|
||||
- Dashboard: die Reiter sitzen jetzt als kompakter Umschalter in der Mitte der Kopfzeile statt in einer eigenen Zeile über den Kacheln – das Dashboard gewinnt dadurch Platz nach oben; viele Reiter lassen sich waagrecht durchblättern, mit den Pfeiltasten wechseln Sie zwischen ihnen; Anlegen, Umbenennen, Löschen und Umsortieren per Ziehen funktionieren wie bisher
|
||||
|
||||
### Behoben
|
||||
|
||||
- Fehler melden: das Häkchen „Bildschirmfoto beifügen“ war in manchen Fällen gesperrt, weil die Aufnahme an einem einzelnen Bild einer fremden Website scheiterte; die Aufnahme gelingt jetzt trotzdem
|
||||
- Favoriten-Widget: lässt sich jetzt bis auf eine Spalte schmal ziehen – bei kurzen Linknamen bleibt rechts kein leerer Platz mehr
|
||||
- Favoriten-Widget: nach dem Ändern der Logo-Adresse erscheint das neue Symbol jetzt sofort, statt erst nach einem Tag; ist das Bild unter der Adresse nicht abrufbar – etwa wegen einer Cloudflare-Prüfung – meldet das Formular das jetzt beim Speichern, statt die Adresse still zu übernehmen
|
||||
|
||||
## 1.3.1 – 2026-09-23
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- quick-260923-lrr — Favoriten: eigenes Symbol hochladen, Zwischenspeicher
|
||||
-- nach Aenderung erneuern.
|
||||
--
|
||||
-- Zwei neue Spalten auf "FavoriteLink":
|
||||
--
|
||||
-- "uploadedIconMime" TEXT NULL — der an den Bytes ERKANNTE Typ eines
|
||||
-- hochgeladenen eigenen Symbols (PNG/JPEG/GIF/WebP/ICO/SVG); NULL, wenn
|
||||
-- kein eigenes Symbol hochgeladen wurde. Es wird KEIN Pfad gespeichert:
|
||||
-- die Datei liegt vollstaendig ableitbar unter
|
||||
-- "user-files/favorite-icons/<userId>/<id>.<ext>" (Endung aus dem
|
||||
-- erkannten Typ) — damit gelangt auch bei einer vollstaendigen
|
||||
-- Zeilenauslieferung (Prisma liefert die ganze Zeile an den Client) kein
|
||||
-- Serverpfad in eine API-Antwort. Vorrang vor "iconUrl": ist der Wert
|
||||
-- gesetzt, liefert GET /favorites/:id/icon die hochgeladene Datei statt
|
||||
-- die gespeicherte Logo-Adresse abzurufen.
|
||||
--
|
||||
-- "iconVersion" INTEGER NOT NULL DEFAULT 0 — Zaehler fuer die ausgelieferte
|
||||
-- Symbol-Adresse (?v=<iconVersion>). Steigt genau dann (Prisma
|
||||
-- { increment: 1 }), wenn sich die angezeigte Symbolquelle aendert: eine
|
||||
-- gespeicherte "iconUrl" weicht vom alten Wert ab, ein Symbol wird
|
||||
-- hochgeladen, oder ein hochgeladenes Symbol wird entfernt. NICHT bei
|
||||
-- Titel-, Link-Adress- oder Reihenfolgeaenderung ohne Symbolwechsel.
|
||||
-- Bestandszeilen starten bei 0 — das unterscheidet sich von der vorher
|
||||
-- unversionierten Adresse, ein bereits 24 Stunden im Browser
|
||||
-- zwischengespeichertes Symbol wird dadurch beim naechsten Laden sofort
|
||||
-- ungueltig.
|
||||
--
|
||||
-- Die bestehende RLS-Regel "tenant_isolation_policy" auf "FavoriteLink"
|
||||
-- (Migration 20260911120000, zeilenbezogen ueber "tenantId"/"userId")
|
||||
-- braucht fuer zwei zusaetzliche Spalten KEINE Anpassung — sie schuetzt
|
||||
-- Zeilen, nicht Spalten. Kein CREATE/DROP POLICY in dieser Migration.
|
||||
--
|
||||
-- "migrate deploy" wendet diese Migration beim Start an
|
||||
-- (apps/api/scripts/migrate-and-start.sh) — kein manueller Schritt.
|
||||
|
||||
ALTER TABLE "FavoriteLink" ADD COLUMN "uploadedIconMime" TEXT;
|
||||
ALTER TABLE "FavoriteLink" ADD COLUMN "iconVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- quick-260924-m4n — Stufe 2 der Umstellung aus quick-260922-hk4: die alte
|
||||
-- Bildspalte "data" faellt, "storagePath" wird Pflicht.
|
||||
--
|
||||
-- Stufe 1 (20260922120000_dashboard_image_to_disk) hat "storagePath"
|
||||
-- angelegt und "data" nur NULLbar gemacht, weil `prisma migrate deploy` VOR
|
||||
-- dem Anwendungsstart laeuft: ein sofortiges DROP haette die Bytes
|
||||
-- vernichtet, bevor der Bootstrap-Umzug (DashboardImagesService,
|
||||
-- Version 1.3.1) sie auf die Platte schreiben konnte (T-HK4-03). Dieser
|
||||
-- Umzug ist mit dieser Version aus dem Code entfernt.
|
||||
--
|
||||
-- SCHUTZ VOR DATENVERLUST: gibt es noch eine Zeile ohne "storagePath", hat
|
||||
-- der Umzug auf diesem Server nie gearbeitet (der Server hat eine Version
|
||||
-- < 1.3.1 uebersprungen). Dann bricht die Migration mit einer Meldung ab,
|
||||
-- BEVOR irgendetwas geaendert wird (die Pruefung steht vor jeder Aenderung),
|
||||
-- `migrate deploy` stoppt, die API startet nicht. Abhilfe
|
||||
-- (docs/anleitung-betrieb.md, Kapitel 4, gemessen in einer Wegwerf-DB):
|
||||
-- den fehlgeschlagenen Eintrag in "_prisma_migrations" als zurueckgenommen
|
||||
-- vermerken (sonst verweigert auch 1.3.1 den Start mit P3009), dann eine
|
||||
-- Version >= 1.3.1 einmal starten lassen (der Umzug laeuft beim Start von
|
||||
-- selbst), danach erneut auf diese Version gehen.
|
||||
--
|
||||
-- ZEILENSCHUTZ (RLS) UND DIE PRUEFUNG: "DashboardImage" hat FORCE ROW LEVEL
|
||||
-- SECURITY (20260921120000). FORCE wirkt auch auf den Tabelleneigentuemer —
|
||||
-- ohne Sitzungsvariablen wuerde die Mandantenregel dem EXISTS jede Zeile
|
||||
-- wegfiltern, die Pruefung saehe 0 Zeilen und der Schutz waere stumm
|
||||
-- wirkungslos. Heute laeuft die Migration als `tessera` (Superuser mit
|
||||
-- BYPASSRLS, gemessen 24.09.2026 lokal) und sieht alles. Fuer den Fall, dass
|
||||
-- sie spaeter ueber TESSERA_MIGRATE_DATABASE_URL als Eigentuemer OHNE
|
||||
-- BYPASSRLS laeuft (docs/mandantentrennung-datenbankrolle.md), schaltet die
|
||||
-- Pruefung `row_security` fuer diese Transaktion ab: PostgreSQL filtert dann
|
||||
-- NICHT still, sondern bricht mit "query would be affected by row-level
|
||||
-- security policy" ab. Die Pruefung sieht also entweder alle Zeilen oder
|
||||
-- scheitert laut — nie "0 gesehen, weiter".
|
||||
DO $$
|
||||
BEGIN
|
||||
PERFORM set_config('row_security', 'off', true);
|
||||
IF EXISTS (SELECT 1 FROM "DashboardImage" WHERE "storagePath" IS NULL) THEN
|
||||
RAISE EXCEPTION 'DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug (quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut deployen';
|
||||
END IF;
|
||||
PERFORM set_config('row_security', 'on', true);
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
|
||||
ALTER TABLE "DashboardImage" DROP COLUMN "data";
|
||||
|
||||
-- Die Systemkontext-Leseregel aus 20260922120000 hatte genau einen Zweck:
|
||||
-- den Bootstrap-Umzug, der ueber ALLE Mandanten las (`forSystem()`). Der
|
||||
-- Umzug ist entfernt, niemand liest "DashboardImage" mehr systemgebunden —
|
||||
-- eine offene Leseregel ohne Leser waere nur Angriffsflaeche. Es bleibt
|
||||
-- allein "tenant_isolation_policy" (Mandant UND Benutzer). IF EXISTS, damit
|
||||
-- die Migration auch auf einer Datenbank durchlaeuft, auf der die Regel von
|
||||
-- Hand entfernt wurde.
|
||||
DROP POLICY IF EXISTS system_read_policy ON "DashboardImage";
|
||||
@@ -265,14 +265,11 @@ model DashboardImage {
|
||||
originalName String
|
||||
mimeType String
|
||||
size Int
|
||||
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
|
||||
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
|
||||
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
|
||||
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
|
||||
data Bytes?
|
||||
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
|
||||
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
|
||||
storagePath String?
|
||||
// Relativ zur Monorepo-Wurzel, z. B.
|
||||
// "user-files/dashboard-images/<userId>/<id>.png". Die Bytes liegen seit
|
||||
// quick-260922-hk4 im Dateibereich; die alte Spalte `data` ist mit Stufe 2
|
||||
// (Migration 20260924120000_dashboard_image_drop_data) entfernt.
|
||||
storagePath String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@ -419,6 +416,16 @@ model FavoriteLink {
|
||||
title String
|
||||
url String
|
||||
iconUrl String?
|
||||
// quick-260923-lrr: Typ eines hochgeladenen eigenen Symbols (erkannt an
|
||||
// den Bytes, NIE aus der Anfrage uebernommen); null = kein hochgeladenes
|
||||
// Symbol. Kein Pfad gespeichert — die Datei liegt ableitbar unter
|
||||
// user-files/favorite-icons/<userId>/<id>.<ext>.
|
||||
uploadedIconMime String?
|
||||
// quick-260923-lrr: Zaehler fuer die ausgelieferte Symbol-Adresse
|
||||
// (?v=<iconVersion>), steigt bei jeder Aenderung der Symbolquelle
|
||||
// (neue iconUrl, Upload, Entfernen) — macht den 24-h-Browser-Zwischenspeicher
|
||||
// nach einer Aenderung sofort ungueltig.
|
||||
iconVersion Int @default(0)
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -4,21 +4,21 @@ import * as path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
||||
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
||||
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
||||
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
||||
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
||||
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
||||
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
||||
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
||||
* unterscheidet beide ueber `via`.
|
||||
* Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
|
||||
* (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
|
||||
* unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
|
||||
* welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
|
||||
* damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
|
||||
* `forSystem` steht als Spion daneben: seit Stufe 2 (quick-260924-m4n,
|
||||
* Bootstrap-Umzug entfernt) darf der Dienst ihn nie mehr rufen.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||
prisma.__makeBoundClient(tenantId, userId),
|
||||
),
|
||||
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
||||
forSystem: vi.fn(() => {
|
||||
throw new Error('forSystem darf der Bilderdienst seit Stufe 2 nicht mehr rufen');
|
||||
}),
|
||||
}));
|
||||
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
@@ -39,15 +39,18 @@ import { DashboardImagesService } from './dashboard-images.service';
|
||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||
* Argument aufruft.
|
||||
*
|
||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-20): KEIN
|
||||
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
||||
* desktop.service.spec.ts) ueber den Testschalter
|
||||
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
||||
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
||||
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
||||
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
||||
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
||||
* Umzug beim Start (T-HK4-03).
|
||||
* bei fehlender Datei und das Mitloeschen der Datei.
|
||||
*
|
||||
* Stufe 2 (quick-260924-m4n): die Spalte `data` ist weg, `storagePath` ist
|
||||
* Pflicht. Mit ihr entfallen die Faelle 10b/10c (Selbstheilung aus `data`),
|
||||
* 18 (Zeile ohne `storagePath`) und 21-23 (Bootstrap-Umzug).
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
@@ -57,13 +60,11 @@ interface ImageRow {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
data: Uint8Array | null;
|
||||
storagePath: string | null;
|
||||
storagePath: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
via: 'tenant' | 'system';
|
||||
tenantId: string;
|
||||
userId: string | undefined;
|
||||
model: string;
|
||||
@@ -77,7 +78,6 @@ interface FakePrisma {
|
||||
__rows: ImageRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeSystemClient(): { dashboardImage: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
@@ -102,16 +102,21 @@ afterAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Eine Zeile OHNE Datei auf der Platte; der Pfad folgt der Form, die der
|
||||
* Dienst selbst vergibt (`storagePath` ist seit Stufe 2 Pflicht).
|
||||
*/
|
||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
const id = overrides.id ?? 'img-1';
|
||||
const userId = overrides.userId ?? 'user-1';
|
||||
return {
|
||||
id: overrides.id ?? 'img-1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
id,
|
||||
userId,
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
originalName: overrides.originalName ?? 'foto.png',
|
||||
mimeType: overrides.mimeType ?? 'image/png',
|
||||
size: overrides.size ?? PNG.length,
|
||||
data: overrides.data === undefined ? null : overrides.data,
|
||||
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
||||
storagePath: overrides.storagePath ?? `user-files/dashboard-images/${userId}/${id}.png`,
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
@@ -123,11 +128,10 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
*/
|
||||
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
||||
const row = makeRow(overrides);
|
||||
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
||||
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, bytes);
|
||||
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
||||
return row;
|
||||
}
|
||||
|
||||
function storedFile(userId: string, id: string, ext = 'png'): string {
|
||||
@@ -148,7 +152,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
||||
where: { tenantId?: string; userId?: string };
|
||||
select?: Record<string, boolean>;
|
||||
};
|
||||
const where = args.where ?? {};
|
||||
@@ -156,9 +160,6 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
.filter((r) => {
|
||||
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice()
|
||||
@@ -195,11 +196,11 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
||||
function wrap(tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
@@ -211,10 +212,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
__rows: rows,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
return wrap('tenant', tenantId, userId);
|
||||
},
|
||||
__makeSystemClient() {
|
||||
return wrap('system', '', undefined);
|
||||
return wrap(tenantId, userId);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
@@ -340,37 +338,6 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
|
||||
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
|
||||
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
|
||||
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
|
||||
const row = makeRow({
|
||||
id: 'img-alt',
|
||||
data: PNG,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
|
||||
|
||||
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
|
||||
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
|
||||
const row = makeRow({
|
||||
id: 'img-weg',
|
||||
data: null,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeStoredRow({ id: 'eigen' }),
|
||||
@@ -399,13 +366,13 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(call[1]).toBe('tenant-1');
|
||||
expect(call[2]).toBe('user-1');
|
||||
}
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
||||
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
||||
// UUID der Zeile erst nach `create` feststeht (hk4).
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. Seit
|
||||
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
||||
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.via).toBe('tenant');
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
expect(c.userId).toBe('user-1');
|
||||
}
|
||||
@@ -421,10 +388,15 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
||||
expect(prisma.__rows[0].data).toBeNull();
|
||||
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
||||
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
||||
// die Zeile.
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(createArgs.data.data).toBeUndefined();
|
||||
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
expect(createArgs.data.id).toBe(result.id);
|
||||
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(createArgs.data).not.toHaveProperty('data');
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
||||
@@ -435,7 +407,7 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
|
||||
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
const stored = prisma.__rows[0].storagePath ?? '';
|
||||
const stored = prisma.__rows[0].storagePath;
|
||||
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
||||
expect(stored).not.toContain('passwd');
|
||||
expect(stored).not.toContain('..');
|
||||
@@ -462,10 +434,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
it('Test 16: getBytes liest den Dateiinhalt unter dem Pfad aus der Zeile', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-16' }, JPEG)]);
|
||||
const result = await makeService(prisma).getBytes('img-16', 'user-1', 'tenant-1');
|
||||
expect(Buffer.from(result.data).equals(JPEG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
||||
@@ -479,11 +451,6 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
@@ -504,69 +471,3 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
||||
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
||||
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
||||
const fremderMandant = makeRow({
|
||||
id: 'alt-2',
|
||||
userId: 'user-9',
|
||||
tenantId: 'tenant-2',
|
||||
mimeType: 'image/jpeg',
|
||||
data: Uint8Array.from(JPEG),
|
||||
});
|
||||
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
||||
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
||||
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
||||
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
||||
|
||||
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
||||
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
||||
// gebunden ist.
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
||||
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
||||
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
||||
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(findManyArgs.where.storagePath).toBeNull();
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
||||
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
||||
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
||||
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
||||
|
||||
// Die bereits umgezogene Zeile wird nicht angefasst.
|
||||
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
||||
});
|
||||
|
||||
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
||||
const service = makeService(prisma);
|
||||
await service.onApplicationBootstrap();
|
||||
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
type OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
@@ -46,13 +46,22 @@ import {
|
||||
* (T-HK4-02); das Volume haengt in keinem Webserver.
|
||||
*
|
||||
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
|
||||
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert
|
||||
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim
|
||||
* die Zeile (mit der vom Dienst vergebenen UUID und dem daraus gebildeten
|
||||
* Pfad), dann die Datei; scheitert das Schreiben, wird die Zeile wieder
|
||||
* geloescht und 500 geworfen. Beim
|
||||
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
|
||||
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
|
||||
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
|
||||
* 404 und die Kachel zeigt „Bild nicht verfügbar".
|
||||
*
|
||||
* STUFE 2 DER UMSTELLUNG (quick-260924-m4n, Migration
|
||||
* 20260924120000_dashboard_image_drop_data): die alte Spalte `data` ist
|
||||
* weg, `storagePath` ist Pflicht. Mit ihr sind der Bootstrap-Umzug
|
||||
* (`onApplicationBootstrap()` mit `forSystem()`) und die Selbstheilung aus
|
||||
* `data` in `getBytes` entfallen — der Umzug hatte auf allen Servern seine
|
||||
* Arbeit getan, die Migration bricht ab, falls doch noch eine Zeile ohne
|
||||
* Pfad existiert.
|
||||
*
|
||||
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
|
||||
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
|
||||
@@ -145,6 +154,33 @@ function relativeStoragePath(userId: string, id: string, extension: string): str
|
||||
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Servergenerierter relativer Pfad fuer ein neues Bild: UUID der Zeile plus
|
||||
* Endung aus dem ERKANNTEN Typ (T-HK4-01).
|
||||
*/
|
||||
function storagePathFor(userId: string, id: string, mimeType: string): string {
|
||||
const extension = extensionFor(mimeType);
|
||||
if (extension === null) {
|
||||
// detectImageMime liefert nur die vier bekannten Typen; ein anderer
|
||||
// Wert hier waere ein Programmierfehler, kein Benutzerfehler.
|
||||
throw new InternalServerErrorException(`Unbekannter Bildtyp '${mimeType}'`);
|
||||
}
|
||||
return relativeStoragePath(userId, id, extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Bytes an den servergenerierten Ort. Der Ordner je Benutzer
|
||||
* entsteht dabei (`recursive: true`).
|
||||
*/
|
||||
async function writeImageFile(storagePath: string, bytes: Uint8Array): Promise<void> {
|
||||
const absolute = absoluteImagePath(storagePath);
|
||||
if (absolute === null) {
|
||||
throw new Error(`Ungueltiger Ablageort '${storagePath}'`);
|
||||
}
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
|
||||
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
|
||||
@@ -162,69 +198,11 @@ function absoluteImagePath(storagePath: string): string | null {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
export class DashboardImagesService {
|
||||
private readonly logger = new Logger(DashboardImagesService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der
|
||||
* Betreiber nichts von Hand ausfuehren muss.
|
||||
*
|
||||
* ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die
|
||||
* SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data`
|
||||
* NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges
|
||||
* DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte.
|
||||
* Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live
|
||||
* einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in
|
||||
* `.planning/todos/pending/`).
|
||||
*
|
||||
* GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster
|
||||
* `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle
|
||||
* Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben
|
||||
* wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer
|
||||
* dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet
|
||||
* (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in
|
||||
* 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim
|
||||
* DKV-Planer.
|
||||
*
|
||||
* Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein
|
||||
* zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile
|
||||
* wird protokolliert und haelt den Start nicht auf.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const pending = await systemPrisma.dashboardImage.findMany({
|
||||
where: { storagePath: null },
|
||||
select: { id: true, userId: true, tenantId: true, mimeType: true, data: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
let moved = 0;
|
||||
for (const row of pending) {
|
||||
if (row.data === null) continue;
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: row.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
moved += 1;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (moved > 0) {
|
||||
this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
||||
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -240,10 +218,12 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
||||
* die Zeile haelt den Pfad.
|
||||
*
|
||||
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der
|
||||
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des
|
||||
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine
|
||||
* Zeile ohne Datei.
|
||||
* Reihenfolge (T-HK4-04): die UUID vergibt der Dienst selbst
|
||||
* (`randomUUID()`, dieselbe Form wie Prismas `@default(uuid())`), damit
|
||||
* die Zeile ihren Pfad gleich beim Anlegen traegt — `storagePath` ist seit
|
||||
* Stufe 2 Pflicht. Zeile zuerst, dann die Datei; scheitert das Schreiben,
|
||||
* wird die Zeile wieder geloescht — lieber gar kein Bild als eine Zeile
|
||||
* ohne Datei.
|
||||
*/
|
||||
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
||||
if (!file) {
|
||||
@@ -265,8 +245,12 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const storagePath = storagePathFor(user.id, id, mimeType);
|
||||
const created = await tenantPrisma.dashboardImage.create({
|
||||
data: {
|
||||
id,
|
||||
storagePath,
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
||||
@@ -277,11 +261,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
});
|
||||
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: created.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
await writeImageFile(storagePath, file.buffer);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
|
||||
@@ -297,8 +277,8 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
|
||||
/**
|
||||
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
|
||||
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad
|
||||
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404.
|
||||
* 404. Gelesen wird die Datei; ein ungueltiger Pfad und eine fehlende
|
||||
* Datei ergeben denselben 404.
|
||||
*/
|
||||
async getBytes(
|
||||
id: string,
|
||||
@@ -311,7 +291,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
const absolute = absoluteImagePath(row.storagePath);
|
||||
if (absolute === null) {
|
||||
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
@@ -321,26 +301,6 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
const data = await fs.readFile(absolute);
|
||||
return { mimeType: row.mimeType, data };
|
||||
} catch (error) {
|
||||
// Selbstheilung waehrend der Umstellung (T-HK4-03): fehlt die Datei,
|
||||
// steckt aber noch die alte Spalte `data` in der Zeile, wird die Datei
|
||||
// daraus neu geschrieben und ausgeliefert. Der Fall ist real: ein
|
||||
// `pg_dump` aus der Zeit vor dem Umzug traegt die Bytes noch, das
|
||||
// Volume `user-files` wird getrennt gesichert — wer nur den Abzug
|
||||
// zurueckspielt, haette sonst Zeilen ohne Datei. Nach dem Entfernen der
|
||||
// Spalte (eigenes Todo) faellt dieser Zweig ersatzlos weg.
|
||||
if (row.data !== null) {
|
||||
try {
|
||||
await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
this.logger.log(`Bilderrahmen-Bild ${id} aus der Datenbank wiederhergestellt`);
|
||||
return { mimeType: row.mimeType, data: row.data };
|
||||
} catch (writeError) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${id} konnte nicht wiederhergestellt werden: ${
|
||||
writeError instanceof Error ? writeError.message : String(writeError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.warn(
|
||||
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -363,7 +323,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
const absolute = absoluteImagePath(row.storagePath);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
@@ -378,31 +338,4 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Bytes an den servergenerierten Ort und liefert den
|
||||
* relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht
|
||||
* dabei (`recursive: true`).
|
||||
*/
|
||||
private async writeImageFile(
|
||||
userId: string,
|
||||
id: string,
|
||||
mimeType: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<string> {
|
||||
const extension = extensionFor(mimeType);
|
||||
if (extension === null) {
|
||||
throw new Error(`Unbekannter Bildtyp '${mimeType}'`);
|
||||
}
|
||||
|
||||
const storagePath = relativeStoragePath(userId, id, extension);
|
||||
const absolute = absoluteImagePath(storagePath);
|
||||
if (absolute === null) {
|
||||
throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, bytes);
|
||||
return storagePath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* WIDGET_MODULE_MAP wird je Testfall über eine gemeinsame, gemockte
|
||||
@@ -45,6 +48,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { favoriteIconAbsolutePath } from '../favorites/favorite-icon-files';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@@ -52,8 +56,16 @@ import { DashboardService } from './dashboard.service';
|
||||
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
||||
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
|
||||
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
|
||||
* quick-260923-ad9 (Task 1). */
|
||||
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
|
||||
* quick-260923-ad9 (Task 1). `favoriteLink` ergaenzt seit quick-260923-lrr
|
||||
* (T-LRR-07: `removeWidget`/`deleteDashboard` lesen vor der Kaskade die
|
||||
* betroffenen Favoriten mit hochgeladenem Symbol). */
|
||||
const BOUND_MODEL_NAMES = [
|
||||
'dashboard',
|
||||
'dashboardLayout',
|
||||
'widgetInstance',
|
||||
'searchProvider',
|
||||
'favoriteLink',
|
||||
];
|
||||
|
||||
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
|
||||
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
|
||||
@@ -125,6 +137,27 @@ function makeSearchProvider(
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimale FavoriteLink-Fixture fuer T-LRR-07 (quick-260923-lrr) — nur die
|
||||
* Felder, die `cleanUpFavoriteIconFiles` und die vorbereitenden `findMany`-
|
||||
* Aufrufe in `removeWidget`/`deleteDashboard` lesen. */
|
||||
function makeFavorite(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
widgetId: string;
|
||||
uploadedIconMime: string | null;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? 'fav-1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
widgetId: overrides.widgetId ?? 'w1',
|
||||
uploadedIconMime: overrides.uploadedIconMime === undefined ? null : overrides.uploadedIconMime,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePrisma(
|
||||
opts: {
|
||||
widgets?: ReturnType<typeof makeWidget>[];
|
||||
@@ -132,12 +165,14 @@ function makeFakePrisma(
|
||||
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
||||
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
||||
dashboards?: ReturnType<typeof makeDashboard>[];
|
||||
favorites?: ReturnType<typeof makeFavorite>[];
|
||||
} = {},
|
||||
) {
|
||||
const widgets = opts.widgets ?? [];
|
||||
const modules = opts.modules ?? [];
|
||||
let layoutRow = opts.layout ?? null;
|
||||
const searchProviders = opts.searchProviders ?? [];
|
||||
const favorites = opts.favorites ?? [];
|
||||
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
|
||||
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
|
||||
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
|
||||
@@ -256,6 +291,24 @@ function makeFakePrisma(
|
||||
return removed;
|
||||
}),
|
||||
},
|
||||
// T-LRR-07 (quick-260923-lrr): nur `findMany` — `removeWidget`/
|
||||
// `deleteDashboard` LESEN die betroffenen Favoriten vor der Kaskade,
|
||||
// sie schreiben nie auf `favoriteLink` (das Loeschen selbst passiert
|
||||
// ueber die Datenbank-Kaskade auf `widgetInstance`, nicht hier).
|
||||
favoriteLink: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
let rows = favorites.filter((f) => f.userId === where.userId);
|
||||
if (where.widgetId?.in) {
|
||||
rows = rows.filter((f) => where.widgetId.in.includes(f.widgetId));
|
||||
} else if (where.widgetId) {
|
||||
rows = rows.filter((f) => f.widgetId === where.widgetId);
|
||||
}
|
||||
if (where.uploadedIconMime?.not === null) {
|
||||
rows = rows.filter((f) => f.uploadedIconMime !== null);
|
||||
}
|
||||
return rows;
|
||||
}),
|
||||
},
|
||||
module: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
const slugs: string[] = where.slug.in;
|
||||
@@ -667,6 +720,69 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
);
|
||||
});
|
||||
|
||||
describe('removeWidget — T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung', () => {
|
||||
let iconsDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-'));
|
||||
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it('entfernt die Symboldatei eines Favoriten, dessen Zeile die Datenbank-Kaskade mitloescht', async () => {
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const favorite = makeFavorite({
|
||||
id: 'fav-1',
|
||||
userId: 'user-1',
|
||||
widgetId: 'w1',
|
||||
uploadedIconMime: 'image/png',
|
||||
});
|
||||
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, Buffer.from([1, 2, 3]));
|
||||
|
||||
await service.removeWidget('w1', 'user-1', 'tenant-1');
|
||||
|
||||
expect(fs.existsSync(absolute)).toBe(false);
|
||||
});
|
||||
|
||||
it('fehlende Datei wird geschluckt — removeWidget scheitert nicht daran', async () => {
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const favorite = makeFavorite({
|
||||
id: 'fav-1',
|
||||
userId: 'user-1',
|
||||
widgetId: 'w1',
|
||||
uploadedIconMime: 'image/png',
|
||||
});
|
||||
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('Favoriten OHNE hochgeladenes Symbol loesen keinen Dateizugriff aus', async () => {
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const favorite = makeFavorite({ id: 'fav-1', userId: 'user-1', widgetId: 'w1', uploadedIconMime: null });
|
||||
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const prisma = makeFakePrisma({
|
||||
@@ -1234,6 +1350,63 @@ describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
describe('T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung eines ganzen Reiters', () => {
|
||||
let iconsDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-tab-'));
|
||||
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it('entfernt die Symboldateien ALLER Favoriten der Widgets dieses Reiters', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
|
||||
widgets: [
|
||||
makeWidget({ id: 'w1', dashboardId: 'd1' }),
|
||||
makeWidget({ id: 'w2', dashboardId: 'd1' }),
|
||||
],
|
||||
favorites: [
|
||||
makeFavorite({ id: 'fav-1', widgetId: 'w1', uploadedIconMime: 'image/png' }),
|
||||
makeFavorite({ id: 'fav-2', widgetId: 'w2', uploadedIconMime: 'image/svg+xml' }),
|
||||
makeFavorite({ id: 'fav-3', widgetId: 'w2', uploadedIconMime: null }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const abs1 = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
|
||||
const abs2 = favoriteIconAbsolutePath('user-1', 'fav-2', 'image/svg+xml') as string;
|
||||
for (const absolute of [abs1, abs2]) {
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, Buffer.from([1]));
|
||||
}
|
||||
|
||||
await service.deleteDashboard('d1', 'user-1', 'tenant-1');
|
||||
|
||||
expect(fs.existsSync(abs1)).toBe(false);
|
||||
expect(fs.existsSync(abs2)).toBe(false);
|
||||
});
|
||||
|
||||
it('ein Reiter ohne Widgets loest keinen Dateizugriff aus', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'd1' });
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
|
||||
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, Role } from '@prisma/client';
|
||||
import { removeFavoriteIconFileBestEffort } from '../favorites/favorite-icon-files';
|
||||
import { ModuleAccessService } from '../module-registry/module-access.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -91,11 +93,40 @@ const DEFAULT_SEARCH_PROVIDERS = [
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
private readonly logger = new Logger(DashboardService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly moduleAccessService: ModuleAccessService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* T-LRR-07 (quick-260923-lrr, Restrisiko aus dem Favoriten-Plan
|
||||
* geschlossen): loescht ein Widget seine `FavoriteLink`-Zeilen ueber die
|
||||
* Datenbank-Kaskade (`onDelete: Cascade` auf `FavoriteLink.widgetId`),
|
||||
* OHNE `FavoritesService` zu durchlaufen — dessen Datei-Aufraeumung in
|
||||
* `remove()` greift hier also nicht. Diese Hilfsfunktion entfernt die
|
||||
* Symboldateien der betroffenen Favoriten NACHTRAEGLICH, best effort
|
||||
* (Muster T-HK4-04): ein Dateifehler wird protokolliert und geschluckt,
|
||||
* er darf das Loeschen des Widgets/Reiters nie verhindern oder
|
||||
* zuruecknehmen — deshalb laeuft dieser Aufruf immer NACH der
|
||||
* erfolgreichen Datenbankoperation, nie innerhalb ihrer Transaktion.
|
||||
*/
|
||||
private async cleanUpFavoriteIconFiles(
|
||||
userId: string,
|
||||
rows: Array<{ id: string; uploadedIconMime: string | null }>,
|
||||
): Promise<void> {
|
||||
for (const row of rows) {
|
||||
if (row.uploadedIconMime === null) continue;
|
||||
const removed = await removeFavoriteIconFileBestEffort(userId, row.id, row.uploadedIconMime);
|
||||
if (!removed) {
|
||||
this.logger.warn(
|
||||
`Symboldatei des kaskadiert geloeschten Favoriten ${row.id} konnte nicht entfernt werden (T-LRR-07)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
|
||||
* Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
|
||||
@@ -243,7 +274,25 @@ export class DashboardService {
|
||||
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
// T-LRR-07: VOR der Kaskade merken, welche Favoriten dieses Reiters ein
|
||||
// eigenes hochgeladenes Symbol tragen — siehe `cleanUpFavoriteIconFiles`.
|
||||
// Nur ein Lesezugriff, kein Schreiben; laeuft ausserhalb der Transaktion
|
||||
// unten, weil die Dateiraeumung selbst NICHT transaktional sein muss
|
||||
// (und best effort niemals einen Rollback ausloesen darf).
|
||||
const widgetsOnTab = await tenantPrisma.widgetInstance.findMany({
|
||||
where: { dashboardId: id, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
const widgetIds = widgetsOnTab.map((w: { id: string }) => w.id);
|
||||
const iconRows =
|
||||
widgetIds.length === 0
|
||||
? []
|
||||
: await tenantPrisma.favoriteLink.findMany({
|
||||
where: { widgetId: { in: widgetIds }, userId, uploadedIconMime: { not: null } },
|
||||
select: { id: true, uploadedIconMime: true },
|
||||
});
|
||||
|
||||
const result = await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboard.deleteMany({ where: { id, userId } });
|
||||
@@ -262,6 +311,10 @@ export class DashboardService {
|
||||
|
||||
return { id };
|
||||
});
|
||||
|
||||
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -505,6 +558,11 @@ export class DashboardService {
|
||||
* Verifies ownership by userId before deleting (T-05-01) — same real
|
||||
* ownership check as `updateWidgetConfig` above, same reasoning: both
|
||||
* queries run over the SAME bound client and tenant id.
|
||||
*
|
||||
* T-LRR-07 (quick-260923-lrr): dieselbe Kaskade wie in `deleteDashboard`
|
||||
* trifft hier ein einzelnes Widget — vor dem Loeschen werden dessen
|
||||
* Favoriten mit hochgeladenem Symbol gemerkt, danach werden ihre Dateien
|
||||
* best effort entfernt (siehe `cleanUpFavoriteIconFiles`).
|
||||
*/
|
||||
async removeWidget(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -518,9 +576,18 @@ export class DashboardService {
|
||||
);
|
||||
}
|
||||
|
||||
return tenantPrisma.widgetInstance.delete({
|
||||
const iconRows = await tenantPrisma.favoriteLink.findMany({
|
||||
where: { widgetId: id, userId, uploadedIconMime: { not: null } },
|
||||
select: { id: true, uploadedIconMime: true },
|
||||
});
|
||||
|
||||
const result = await tenantPrisma.widgetInstance.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Search Providers (05-02, D-15) ---
|
||||
|
||||
@@ -21,12 +21,18 @@ describe('widget-module-map (quick-260922-m1h)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle
|
||||
// uebrigen bleiben Plattform-Kacheln ohne Modulbezug.
|
||||
it('nur proxmox traegt einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => {
|
||||
for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox')) {
|
||||
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("die Proxmox-Kachel gehoert zum Modul 'proxmox' (T-I8V-01)", () => {
|
||||
expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox');
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
|
||||
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -23,17 +23,16 @@ import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
|
||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
||||
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||
*
|
||||
* Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
|
||||
* (clock/search/calendar/note/calculator/favorites/stopwatch/
|
||||
* picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
|
||||
* modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
|
||||
* Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` →
|
||||
* `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/
|
||||
* calculator/favorites/stopwatch/picture-frame/xframe) sind
|
||||
* Plattform-Widgets ohne Modulbezug.
|
||||
*/
|
||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
||||
|
||||
/**
|
||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle neun
|
||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||
* der Typ kein Modul-Widget ist (alle Typen außer `proxmox`). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||
* damit Tests sie gezielt mocken können.
|
||||
*/
|
||||
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FAVORITE_ICON_MAX_BYTES,
|
||||
detectFavoriteIconMime,
|
||||
favoriteIconAbsolutePath,
|
||||
favoriteIconExtension,
|
||||
removeFavoriteIconFileBestEffort,
|
||||
resolveFavoriteIconsDir,
|
||||
} from './favorite-icon-files';
|
||||
|
||||
/**
|
||||
* favorite-icon-files.spec — NEU (quick-260923-lrr).
|
||||
*
|
||||
* Erkennung (Muster dashboard-image-rules.spec.ts): PNG/JPEG/GIF/WebP wie
|
||||
* `detectImageMime`, dazu ICO (Kopfstueck, kein CUR) und SVG (Praefix-Form,
|
||||
* kein `<html>` davor). Pfadbildung: Endung aus dem Typ, Segmente nur aus
|
||||
* Buchstaben/Ziffern/Bindestrich, Ergebnis muss im Symbolverzeichnis liegen
|
||||
* (T-LRR-01). `FAVORITE_ICONS_DIR` steuert das Verzeichnis in Tests, wie
|
||||
* `DASHBOARD_IMAGES_DIR` es fuer die Bilderrahmen-Bilder tut.
|
||||
*/
|
||||
function bytes(...parts: (number[] | string)[]): Uint8Array {
|
||||
const out: number[] = [];
|
||||
for (const p of parts) {
|
||||
if (typeof p === 'string') {
|
||||
for (const ch of p) out.push(ch.charCodeAt(0));
|
||||
} else {
|
||||
out.push(...p);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
describe('detectFavoriteIconMime (quick-260923-lrr)', () => {
|
||||
it('PNG/JPEG/GIF/WebP-Signaturen ergeben dieselben Typen wie detectImageMime', () => {
|
||||
expect(
|
||||
detectFavoriteIconMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13])),
|
||||
).toBe('image/png');
|
||||
expect(detectFavoriteIconMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg');
|
||||
expect(detectFavoriteIconMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif');
|
||||
expect(detectFavoriteIconMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe(
|
||||
'image/webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('Bytes 00 00 01 00 (mindestens 6 Bytes) ergeben image/x-icon', () => {
|
||||
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00, 0x01, 0x00]))).toBe('image/x-icon');
|
||||
});
|
||||
|
||||
it('zu kurzes ICO-Kopfstueck (weniger als 6 Bytes) ergibt null', () => {
|
||||
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00]))).toBeNull();
|
||||
});
|
||||
|
||||
it('00 00 02 00 (CUR-Cursor-Datei) ergibt null — nur Typ 1 (ICO) wird erkannt', () => {
|
||||
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull();
|
||||
});
|
||||
|
||||
it('gueltige SVG-Formen ergeben image/svg+xml', () => {
|
||||
expect(detectFavoriteIconMime(bytes('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).toBe(
|
||||
'image/svg+xml',
|
||||
);
|
||||
expect(
|
||||
detectFavoriteIconMime(bytes('<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>')),
|
||||
).toBe('image/svg+xml');
|
||||
// fuehrendes BOM
|
||||
expect(
|
||||
detectFavoriteIconMime(bytes([0xef, 0xbb, 0xbf], '<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
||||
).toBe('image/svg+xml');
|
||||
// fuehrendes Leerzeichen
|
||||
expect(detectFavoriteIconMime(bytes(' <svg></svg>'))).toBe('image/svg+xml');
|
||||
// Kommentar vor <svg
|
||||
expect(
|
||||
detectFavoriteIconMime(bytes('<!-- Kommentar -->\n<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
||||
).toBe('image/svg+xml');
|
||||
// DOCTYPE svg vor <svg
|
||||
expect(
|
||||
detectFavoriteIconMime(
|
||||
bytes(
|
||||
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg></svg>',
|
||||
),
|
||||
),
|
||||
).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('ungueltige Formen ergeben null', () => {
|
||||
expect(detectFavoriteIconMime(bytes('<html><svg></svg></html>'))).toBeNull();
|
||||
expect(detectFavoriteIconMime(bytes('<!DOCTYPE html>\n<html></html>'))).toBeNull();
|
||||
expect(detectFavoriteIconMime(new Uint8Array(0))).toBeNull();
|
||||
expect(detectFavoriteIconMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull();
|
||||
expect(detectFavoriteIconMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull();
|
||||
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('favoriteIconExtension (quick-260923-lrr)', () => {
|
||||
it('bildet die sechs bekannten Typen ab, unbekannter Typ ergibt null', () => {
|
||||
expect(favoriteIconExtension('image/png')).toBe('png');
|
||||
expect(favoriteIconExtension('image/jpeg')).toBe('jpg');
|
||||
expect(favoriteIconExtension('image/gif')).toBe('gif');
|
||||
expect(favoriteIconExtension('image/webp')).toBe('webp');
|
||||
expect(favoriteIconExtension('image/x-icon')).toBe('ico');
|
||||
expect(favoriteIconExtension('image/svg+xml')).toBe('svg');
|
||||
expect(favoriteIconExtension('application/pdf')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFavoriteIconsDir / favoriteIconAbsolutePath (quick-260923-lrr)', () => {
|
||||
let dir: string;
|
||||
const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-'));
|
||||
process.env.FAVORITE_ICONS_DIR = dir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (ORIGINAL_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolveFavoriteIconsDir beachtet FAVORITE_ICONS_DIR', () => {
|
||||
expect(resolveFavoriteIconsDir()).toBe(path.resolve(dir));
|
||||
});
|
||||
|
||||
it('liegt unter resolveFavoriteIconsDir()/<userId>/<id>.<ext>', () => {
|
||||
const result = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png');
|
||||
expect(result).toBe(path.join(resolveFavoriteIconsDir(), 'user-1', 'fav-1.png'));
|
||||
});
|
||||
|
||||
it('unbekannter Typ ergibt null', () => {
|
||||
expect(favoriteIconAbsolutePath('user-1', 'fav-1', 'application/pdf')).toBeNull();
|
||||
});
|
||||
|
||||
it('Segmente mit .., /, \\ oder leer ergeben null', () => {
|
||||
expect(favoriteIconAbsolutePath('..', 'fav-1', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('user-1', '../etc/passwd', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('a/b', 'fav-1', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('a\\b', 'fav-1', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('', 'fav-1', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('user-1', '', 'image/png')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFavoriteIconFileBestEffort (quick-260923-lrr, T-LRR-07)', () => {
|
||||
let dir: string;
|
||||
const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-rm-'));
|
||||
process.env.FAVORITE_ICONS_DIR = dir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (ORIGINAL_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(path.join(dir, 'user-1'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('entfernt eine vorhandene Datei und liefert true', async () => {
|
||||
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png');
|
||||
expect(absolute).not.toBeNull();
|
||||
fs.mkdirSync(path.dirname(absolute as string), { recursive: true });
|
||||
fs.writeFileSync(absolute as string, Buffer.from([1, 2, 3]));
|
||||
|
||||
const result = await removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'image/png');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.existsSync(absolute as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('fehlende Datei -> false, wirft nicht', async () => {
|
||||
await expect(removeFavoriteIconFileBestEffort('user-1', 'fehlt', 'image/png')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('unbekannter Typ -> false, wirft nicht', async () => {
|
||||
await expect(
|
||||
removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'application/pdf'),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grenzen (quick-260923-lrr)', () => {
|
||||
it('FAVORITE_ICON_MAX_BYTES ist 512 KiB', () => {
|
||||
expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { detectImageMime } from '../dashboard/dashboard-image-rules';
|
||||
|
||||
/**
|
||||
* favorite-icon-files — reine Regeln und Ablage-Hilfen fuer ein
|
||||
* hochgeladenes Favoriten-Symbol (quick-260923-lrr). Kein Nest, kein
|
||||
* Prisma: Grenzen, Erkennung und Pfadbildung, damit Dienst und Controller
|
||||
* dieselben Werte anwenden und die Erkennung direkt an den Bytes testbar
|
||||
* ist — Muster `dashboard-image-rules.ts` (quick-260921-pi9).
|
||||
*
|
||||
* Warum ZUSAETZLICH ICO und SVG (ueber die vier Typen aus
|
||||
* `detectImageMime` hinaus): Favicons liegen haeufig als `.ico` vor, und
|
||||
* ein selbst gezeichnetes Symbol oft als `.svg`. Beide Formate haben keine
|
||||
* fuehrende Signatur wie PNG/JPEG/GIF/WebP im klassischen Sinn — ICO traegt
|
||||
* nur ein vier Byte langes Kopfstueck (Typ-Feld `0x0001`, NICHT `0x0002` =
|
||||
* CUR-Cursor-Dateien, die deshalb bewusst NICHT erkannt werden), SVG ist
|
||||
* Text und wird ueber eine Praefix-Pruefung erkannt (XML-Deklaration,
|
||||
* Kommentare, ein optionales DOCTYPE mit Wurzel `svg`, dann `<svg` selbst).
|
||||
*
|
||||
* Wie bei `dashboard-image-rules.ts` (T-PI9-01/T-PI9-08): was hier NICHT
|
||||
* erkannt wird, kommt nicht auf die Platte — und der erkannte Typ ist
|
||||
* zugleich der Typ, mit dem `GET /favorites/:id/icon` spaeter antwortet.
|
||||
*
|
||||
* Bewusst KEIN `file-type`-Paket (Muster T-PI9-SC): sechs feste Regeln sind
|
||||
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
|
||||
*/
|
||||
|
||||
/** Hoechstgroesse je Datei: 512 KiB (multer `limits.fileSize` an der Route, zweites Netz im Dienst). */
|
||||
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
|
||||
|
||||
export type FavoriteIconMime =
|
||||
| 'image/png'
|
||||
| 'image/jpeg'
|
||||
| 'image/gif'
|
||||
| 'image/webp'
|
||||
| 'image/x-icon'
|
||||
| 'image/svg+xml';
|
||||
|
||||
/** ICO-Kopfstueck: Reserviert=0, Typ=1 (Icon). Typ=2 waere CUR (Cursor) — bewusst NICHT erkannt. */
|
||||
const ICO_HEADER = [0x00, 0x00, 0x01, 0x00];
|
||||
|
||||
/**
|
||||
* Praefix-Form eines SVG-Dokuments: optionales BOM/Leerraum, optionale
|
||||
* XML-Deklaration, beliebig viele Kommentare und/oder ein DOCTYPE mit
|
||||
* Wurzel `svg` (in beliebiger Reihenfolge/Wiederholung), danach `<svg`
|
||||
* direkt gefolgt von Leerraum, `>` oder `/`. Alles andere (z. B. `<html>`
|
||||
* vor `<svg>`, ein DOCTYPE auf `html`) ergibt kein Treffer.
|
||||
*/
|
||||
const SVG_PREFIX_RE =
|
||||
/^(?:<\?xml[^>]*\?>\s*)?(?:(?:<!--[\s\S]*?-->|<!DOCTYPE\s+svg\b[^>]*>)\s*)*<svg[\s>/]/i;
|
||||
|
||||
function startsWithIcoHeader(buffer: Uint8Array): boolean {
|
||||
if (buffer.length < 6) return false;
|
||||
for (let i = 0; i < ICO_HEADER.length; i++) {
|
||||
if (buffer[i] !== ICO_HEADER[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die ersten 4096 Bytes als UTF-8 gegen `SVG_PREFIX_RE`. Ein
|
||||
* fuehrendes BOM oder Leerraum vor der eigentlichen Deklaration wird
|
||||
* entfernt, bevor die Praefix-Form geprueft wird. Wirft nie — ein Puffer,
|
||||
* der sich nicht als UTF-8 lesen laesst, ist schlicht kein SVG.
|
||||
*/
|
||||
function looksLikeSvg(buffer: Uint8Array): boolean {
|
||||
let text: string;
|
||||
try {
|
||||
text = Buffer.from(buffer.subarray(0, 4096)).toString('utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
text = text.replace(/^/, '').replace(/^\s+/, '');
|
||||
return SVG_PREFIX_RE.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt PNG, JPEG, GIF, WebP (ueber `detectImageMime`), ICO und SVG an
|
||||
* den Bytes; alles andere ergibt `null`. Wirft nie.
|
||||
*/
|
||||
export function detectFavoriteIconMime(buffer: Uint8Array): FavoriteIconMime | null {
|
||||
const known = detectImageMime(buffer);
|
||||
if (known !== null) return known;
|
||||
if (startsWithIcoHeader(buffer)) return 'image/x-icon';
|
||||
if (looksLikeSvg(buffer)) return 'image/svg+xml';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
|
||||
export function favoriteIconExtension(mime: string): string | null {
|
||||
switch (mime) {
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
case 'image/x-icon':
|
||||
return 'ico';
|
||||
case 'image/svg+xml':
|
||||
return 'svg';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest das Symbolverzeichnis relativ zur Monorepo-Wurzel auf — Muster
|
||||
* `resolveDashboardImagesDir()` (dashboard-images.service.ts): zur Laufzeit
|
||||
* ist `__dirname` = apps/api/dist/favorites/, also vier Ebenen hoch.
|
||||
*
|
||||
* `FAVORITE_ICONS_DIR` ist ein Testschalter und im Betrieb nie gesetzt; die
|
||||
* Tests zeigen damit auf ein Wegwerfverzeichnis unter `os.tmpdir()`.
|
||||
*/
|
||||
export function resolveFavoriteIconsDir(): string {
|
||||
const override = process.env.FAVORITE_ICONS_DIR;
|
||||
if (override !== undefined && override !== '') {
|
||||
return path.resolve(override);
|
||||
}
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'favorite-icons');
|
||||
}
|
||||
|
||||
/** Nur Buchstaben, Ziffern und Bindestrich — kein Segment aus der Anfrage geht ungeprueft in einen Pfad. */
|
||||
const SAFE_SEGMENT_RE = /^[A-Za-z0-9-]+$/;
|
||||
|
||||
/**
|
||||
* Bildet den absoluten Ablagepfad `<resolveFavoriteIconsDir()>/<userId>/<id>.<ext>`.
|
||||
* `null`, wenn der Typ unbekannt ist, `userId`/`id` nicht ausschliesslich aus
|
||||
* Buchstaben/Ziffern/Bindestrich bestehen (schliesst `..`, `/`, `\`, leere
|
||||
* Segmente aus), oder das Ergebnis nicht unter dem Symbolverzeichnis liegt
|
||||
* (T-LRR-01). Kein Byte aus der Anfrage — insbesondere nicht `originalname`
|
||||
* — geht je in diesen Pfad ein: `id` ist die Zeilen-UUID, `ext` kommt aus
|
||||
* dem an den Bytes ERKANNTEN Typ.
|
||||
*/
|
||||
export function favoriteIconAbsolutePath(userId: string, id: string, mime: string): string | null {
|
||||
const ext = favoriteIconExtension(mime);
|
||||
if (ext === null) return null;
|
||||
if (!SAFE_SEGMENT_RE.test(userId) || !SAFE_SEGMENT_RE.test(id)) return null;
|
||||
|
||||
const base = resolveFavoriteIconsDir();
|
||||
const absolute = path.resolve(base, userId, `${id}.${ext}`);
|
||||
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt die Symboldatei eines hochgeladenen Favoriten-Symbols, falls sie
|
||||
* existiert — best effort, wirft NIE (Muster T-HK4-04: eine Dateileiche ist
|
||||
* harmloser als eine haengende Operation). Fuer Aufrufer ausserhalb von
|
||||
* `FavoritesService`, deren Vorgang (Loeschen ueber Datenbank-Kaskade,
|
||||
* T-LRR-07) nicht an einem Dateifehler scheitern darf: `DashboardService`
|
||||
* beim Loeschen eines Widgets oder eines ganzen Reiters, siehe dortigen
|
||||
* Kommentar. Liefert `true`, wenn eine Datei tatsaechlich entfernt wurde
|
||||
* (fuer eine Protokollzeile beim Aufrufer), sonst `false` — auch das ist
|
||||
* kein Fehlerzustand: die Datei kann bereits gefehlt haben.
|
||||
*/
|
||||
export async function removeFavoriteIconFileBestEffort(
|
||||
userId: string,
|
||||
id: string,
|
||||
mime: string,
|
||||
): Promise<boolean> {
|
||||
const absolute = favoriteIconAbsolutePath(userId, id, mime);
|
||||
if (absolute === null) return false;
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'reflect-metadata';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen der
|
||||
* Upload-Route (T-LRR-04) am AUFRUF pruefbar sind — Muster
|
||||
* `dashboard-images.controller.spec.ts` (quick-260921-pi9).
|
||||
*/
|
||||
const { fileInterceptorMock } = vi.hoisted(() => ({
|
||||
fileInterceptorMock: vi.fn(() => class FakeInterceptor {}),
|
||||
}));
|
||||
vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock }));
|
||||
|
||||
import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files';
|
||||
import { FavoritesController } from './favorites.controller';
|
||||
|
||||
/**
|
||||
* favorites.controller.spec — NEU (quick-260923-lrr).
|
||||
*
|
||||
* Fuenf Bereiche: Interceptor-Grenzen der Upload-Route (Feld `icon`, 512 KB,
|
||||
* genau eine Datei), die Header der Symbol-Antwort (jetzt `private` statt
|
||||
* `public`, 260923-lrr), Weitergabe von Mandant/Benutzer ausschliesslich aus
|
||||
* dem Sitzungsnachweis (`req.tenantId` VOR `req.user.tenantId`, Muster
|
||||
* `extractContext`), Abweisung ohne Mandantenkontext, und die
|
||||
* Routen-Metadaten der zwei neuen Wege.
|
||||
*/
|
||||
function makeService() {
|
||||
return {
|
||||
list: vi.fn(async () => []),
|
||||
create: vi.fn(async () => ({ id: 'new' })),
|
||||
reorder: vi.fn(async () => []),
|
||||
getIconBytes: vi.fn(async () => ({
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
})),
|
||||
uploadIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({
|
||||
id,
|
||||
uploadedIconMime: 'image/png',
|
||||
iconVersion: 1,
|
||||
})),
|
||||
removeUploadedIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({
|
||||
id,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 2,
|
||||
})),
|
||||
update: vi.fn(async () => ({})),
|
||||
remove: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const headers: Record<string, string> = {};
|
||||
return {
|
||||
headers,
|
||||
setHeader: vi.fn((name: string, value: string) => {
|
||||
headers[name] = value;
|
||||
}),
|
||||
send: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeReq(overrides: Partial<{ tenantId: string | null; user: any }> = {}) {
|
||||
return {
|
||||
tenantId: overrides.tenantId,
|
||||
user: overrides.user ?? { id: 'user-1', tenantId: 'tenant-from-user' },
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('FavoritesController (quick-260923-lrr)', () => {
|
||||
it('Test 1: FileInterceptor wird mit dem Feld icon und { limits: { fileSize: 512 * 1024, files: 1 } } aufgerufen', () => {
|
||||
expect(fileInterceptorMock).toHaveBeenCalledWith('icon', {
|
||||
limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 },
|
||||
});
|
||||
expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024);
|
||||
});
|
||||
|
||||
it('Test 2: getIcon setzt Content-Type aus dem Dienst, Cache-Control private, nosniff, CSP sandbox', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
const res = makeRes();
|
||||
|
||||
await controller.getIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), res as never);
|
||||
|
||||
expect(service.getIconBytes).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1');
|
||||
expect(res.headers['Content-Type']).toBe('image/png');
|
||||
expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
|
||||
expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox");
|
||||
});
|
||||
|
||||
it('Test 3: uploadIcon reicht tenantId aus req.tenantId (VOR req.user.tenantId) und userId aus req.user.id an den Dienst', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||
|
||||
await controller.uploadIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), file);
|
||||
|
||||
expect(service.uploadIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1', file);
|
||||
});
|
||||
|
||||
it('Test 4: uploadIcon faellt auf req.user.tenantId zurueck, wenn req.tenantId fehlt', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||
|
||||
await controller.uploadIcon('fav-1', makeReq({ tenantId: undefined }), file);
|
||||
|
||||
expect(service.uploadIcon).toHaveBeenCalledWith('tenant-from-user', 'fav-1', 'user-1', file);
|
||||
});
|
||||
|
||||
it('Test 5: removeUploadedIcon reicht tenantId/userId ebenso weiter', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
|
||||
await controller.removeUploadedIcon('fav-1', makeReq({ tenantId: 'tenant-1' }));
|
||||
|
||||
expect(service.removeUploadedIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1');
|
||||
});
|
||||
|
||||
it('Test 6: ohne Mandantenkontext -> ForbiddenException, Dienst wird NICHT aufgerufen', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||
|
||||
await expect(
|
||||
controller.uploadIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } }), file),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(service.uploadIcon).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
controller.removeUploadedIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } })),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(service.removeUploadedIcon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 7: POST :id/icon und DELETE :id/icon sind als Routen-Metadaten vorhanden', () => {
|
||||
const proto = FavoritesController.prototype;
|
||||
expect(Reflect.getMetadata('path', proto.uploadIcon)).toBe(':id/icon');
|
||||
expect(Reflect.getMetadata('method', proto.uploadIcon)).toBe(1); // RequestMethod.POST
|
||||
expect(Reflect.getMetadata('path', proto.removeUploadedIcon)).toBe(':id/icon');
|
||||
expect(Reflect.getMetadata('method', proto.removeUploadedIcon)).toBe(3); // RequestMethod.DELETE
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,16 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
|
||||
/**
|
||||
@@ -39,7 +43,10 @@ import { FavoritesService } from './favorites.service';
|
||||
* - GET /favorites?widgetId= — list favorites for a widget instance
|
||||
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
||||
* - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd)
|
||||
* - GET /favorites/:id/icon — stream a favorite's stored icon bytes
|
||||
* - GET /favorites/:id/icon — stream a favorite's stored icon bytes (append `?v=<iconVersion>`
|
||||
* client-side to bust the 24h cache after any change to the icon source, 260923-lrr)
|
||||
* - POST /favorites/:id/icon — upload a custom icon (multipart field `icon`, ≤512 KB, 260923-lrr)
|
||||
* - DELETE /favorites/:id/icon — remove a previously uploaded icon (260923-lrr)
|
||||
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
|
||||
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
|
||||
*/
|
||||
@@ -122,7 +129,12 @@ export class FavoritesController {
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
// 260923-lrr: private statt public — kein gemeinsamer Zwischenspeicher
|
||||
// (Nginx Proxy Manager) haelt benutzerbezogene Symbole vor. Die Adresse
|
||||
// traegt clientseitig `?v=<iconVersion>` (T-LRR-05), damit der lange
|
||||
// 24h-Browser-Zwischenspeicher nach einer Aenderung trotzdem sofort
|
||||
// ungueltig wird.
|
||||
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||
// 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges
|
||||
// Zertifikat. Als <img>-Unterressource ignoriert der Browser diese
|
||||
// Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
|
||||
@@ -132,6 +144,42 @@ export class FavoritesController {
|
||||
res.send(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /favorites/:id/icon — laedt ein eigenes Symbol fuer einen
|
||||
* Favoriten hoch (260923-lrr). Groessengrenze JE ROUTE (Muster
|
||||
* `dashboard-images.controller.ts` T-PI9-02): `FileInterceptor` nimmt
|
||||
* genau eine Datei bis 512 KB; multers `LIMIT_FILE_SIZE` bildet Nest auf
|
||||
* 413 ab. Typ und Besitzpruefung laufen im Dienst (T-LRR-01/T-LRR-03).
|
||||
*/
|
||||
@Post(':id/icon')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 } }),
|
||||
)
|
||||
async uploadIcon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file?: UploadedFileLike,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.uploadIcon(tenantId, id, userId, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /favorites/:id/icon — entfernt ein zuvor hochgeladenes Symbol
|
||||
* wieder; die Kachel faellt danach auf `iconUrl` bzw. automatische
|
||||
* Erkennung zurueck (260923-lrr).
|
||||
*/
|
||||
@Delete(':id/icon')
|
||||
async removeUploadedIcon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.removeUploadedIcon(tenantId, id, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
@@ -39,6 +48,9 @@ interface FakeFavoriteRow {
|
||||
position: number;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
/** 260923-lrr — Bestandszeilen im Fake bekommen die Vorgabe null/0. */
|
||||
uploadedIconMime?: string | null;
|
||||
iconVersion?: number;
|
||||
}
|
||||
|
||||
interface FakeWidgetRow {
|
||||
@@ -71,7 +83,9 @@ function throwP2025(action: 'update' | 'delete'): never {
|
||||
* deshalb strukturell nie.
|
||||
*/
|
||||
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
|
||||
const favorites = new Map(favoriteRows.map((f) => [f.id, { ...f }]));
|
||||
const favorites = new Map(
|
||||
favoriteRows.map((f) => [f.id, { uploadedIconMime: null, iconVersion: 0, ...f }]),
|
||||
);
|
||||
const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
let autoId = favoriteRows.length;
|
||||
@@ -107,7 +121,16 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
||||
const id = data.id ?? `fav-${++autoId}`;
|
||||
const now = new Date();
|
||||
const record = { iconUrl: null, position: 0, createdAt: now, updatedAt: now, ...data, id };
|
||||
const record = {
|
||||
iconUrl: null,
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
id,
|
||||
};
|
||||
favorites.set(id, record);
|
||||
return record;
|
||||
},
|
||||
@@ -115,7 +138,12 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
||||
const row = favorites.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) throwP2025('update');
|
||||
const updated = { ...row, ...data, updatedAt: new Date() };
|
||||
const updated: any = { ...row, ...data, updatedAt: new Date() };
|
||||
// 260923-lrr: `iconVersion: { increment: n }` — Prisma's atomic
|
||||
// increment form, angewendet auf den bisherigen Zaehlerstand.
|
||||
if (data.iconVersion && typeof data.iconVersion === 'object' && 'increment' in data.iconVersion) {
|
||||
updated.iconVersion = (row.iconVersion ?? 0) + data.iconVersion.increment;
|
||||
}
|
||||
favorites.set(where.id, updated);
|
||||
return updated;
|
||||
},
|
||||
@@ -625,4 +653,323 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- 260923-lrr: eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe ---
|
||||
|
||||
describe('uploadIcon/removeUploadedIcon/getIconBytes — eigenes Symbol (260923-lrr)', () => {
|
||||
let iconsDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||
|
||||
beforeEach(() => {
|
||||
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-svc-'));
|
||||
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/icon.png',
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
};
|
||||
|
||||
function fileFor(userId: string, id: string, ext: string): string {
|
||||
return path.join(iconsDir, userId, `${id}.${ext}`);
|
||||
}
|
||||
|
||||
describe('uploadIcon', () => {
|
||||
it('PNG: Datei liegt unter <dir>/<userId>/<id>.png mit genau den Bytes, Zeile hat uploadedIconMime image/png und iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
|
||||
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
expect(updated.uploadedIconMime).toBe('image/png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
const written = fs.readFileSync(fileFor('user-a1', 'f1', 'png'));
|
||||
expect(written.equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne Datei -> BadRequestException', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', undefined)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: TEXT, originalname: 'x.txt', mimetype: 'text/plain', size: TEXT.length };
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
|
||||
expect(prisma.__favorites.get('f1').uploadedIconMime).toBeNull();
|
||||
});
|
||||
|
||||
it('Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(513 * 1024)]);
|
||||
const file = { buffer: big, originalname: 'x.png', mimetype: 'image/png', size: big.length };
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
|
||||
PayloadTooLargeException,
|
||||
);
|
||||
});
|
||||
|
||||
it('fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben', async () => {
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
|
||||
const prismaForeignUser = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||
const serviceForeignUser = new FavoritesService(
|
||||
prismaForeignUser as any,
|
||||
makeIconDiscovery() as any,
|
||||
);
|
||||
await expect(
|
||||
serviceForeignUser.uploadIcon('t1', 'f1', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
const prismaForeignTenant = makeFakePrisma([baseRow]);
|
||||
const serviceForeignTenant = new FavoritesService(
|
||||
prismaForeignTenant as any,
|
||||
makeIconDiscovery() as any,
|
||||
);
|
||||
await expect(
|
||||
serviceForeignTenant.uploadIcon('t2', 'f1', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
const prismaUnknown = makeFakePrisma([]);
|
||||
const serviceUnknown = new FavoritesService(prismaUnknown as any, makeIconDiscovery() as any);
|
||||
await expect(
|
||||
serviceUnknown.uploadIcon('t1', 'fehlt', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a2'))).toBe(false);
|
||||
});
|
||||
|
||||
it('erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const pngFile = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
const svgFile = { buffer: SVG, originalname: 'x.svg', mimetype: 'image/svg+xml', size: SVG.length };
|
||||
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', pngFile);
|
||||
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', svgFile);
|
||||
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'svg'))).toBe(true);
|
||||
expect(updated.uploadedIconMime).toBe('image/svg+xml');
|
||||
expect(updated.iconVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIconBytes — Vorrang des hochgeladenen Symbols', () => {
|
||||
it('hochgeladenes Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(result.contentType).toBe('image/png');
|
||||
expect((result.body as Buffer).equals(PNG)).toBe(true);
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Typ gesetzt, aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, uploadedIconMime: 'image/png' }]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl);
|
||||
expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') });
|
||||
});
|
||||
|
||||
it('Typ gesetzt, Datei fehlt, KEINE iconUrl -> NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{ ...baseRow, iconUrl: null, uploadedIconMime: 'image/png' },
|
||||
]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(
|
||||
'FavoriteLink not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUploadedIcon', () => {
|
||||
it('Datei weg, uploadedIconMime null, iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(updated.uploadedIconMime).toBeNull();
|
||||
expect(updated.iconVersion).toBe(2);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('ohne vorhandenen Upload -> Zeile unveraendert, keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
expect(updated.uploadedIconMime).toBeNull();
|
||||
});
|
||||
|
||||
it('fremder Benutzer -> NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.removeUploadedIcon('t1', 'f1', 'user-a1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove() mit hochgeladenem Symbol', () => {
|
||||
it('Zeile und Datei weg', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
await service.remove('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('Fehler beim Datei-Entfernen wird geschluckt — das Loeschen der Zeile gelingt trotzdem', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
// Datei vorab entfernen, damit fs.unlink() im Dienst scheitert.
|
||||
fs.unlinkSync(fileFor('user-a1', 'f1', 'png'));
|
||||
|
||||
await expect(service.remove('t1', 'f1', 'user-a1')).resolves.toBeUndefined();
|
||||
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => {
|
||||
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(
|
||||
service.create('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
} as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
|
||||
expect(prisma.__favorites.size).toBe(0);
|
||||
});
|
||||
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'Alt',
|
||||
url: 'https://alt.invalid',
|
||||
iconUrl: 'https://alt.invalid/icon.png',
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
};
|
||||
|
||||
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(
|
||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
||||
});
|
||||
|
||||
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: baseRow.iconUrl } as any);
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('update nur Titel: keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu' } as any);
|
||||
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||
iconUrl: 'https://neu.invalid/icon.png',
|
||||
} as any);
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import {
|
||||
FAVORITE_ICON_MAX_BYTES,
|
||||
detectFavoriteIconMime,
|
||||
favoriteIconAbsolutePath,
|
||||
} from './favorite-icon-files';
|
||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
|
||||
/**
|
||||
@@ -50,9 +62,31 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
|
||||
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
||||
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
|
||||
*
|
||||
* 260923-lrr — eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe:
|
||||
* - Ablage nach dem Muster `dashboard-images.service.ts` (quick-260922-hk4):
|
||||
* `user-files/favorite-icons/<userId>/<id>.<ext>`, Dateiname IMMER aus
|
||||
* Zeilen-UUID und ERKANNTEM Typ, nie aus der Anfrage (T-LRR-01).
|
||||
* - Vorrang: `getIconBytes` liefert bei gesetztem `uploadedIconMime` immer
|
||||
* die Datei, nie `fetchIconBytes` — fehlt die Datei trotz gesetztem Typ,
|
||||
* wird protokolliert und auf `iconUrl` zurueckgefallen.
|
||||
* - `iconVersion` steigt (Prisma `{ increment: 1 }`) genau dann, wenn sich
|
||||
* die angezeigte Quelle aendert (neue, abweichende `iconUrl`; Upload;
|
||||
* Entfernen des Uploads) — nicht bei Titel/Position/unveraenderter URL.
|
||||
* - Halbe Zustaende (T-LRR-08, Muster T-HK4-04): Upload schreibt zuerst die
|
||||
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
|
||||
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
||||
* Dateifehler wird protokolliert und geschluckt.
|
||||
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
|
||||
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
|
||||
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
|
||||
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
|
||||
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FavoritesService {
|
||||
private readonly logger = new Logger(FavoritesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly iconDiscovery: IconDiscoveryService,
|
||||
@@ -72,11 +106,31 @@ export class FavoritesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
|
||||
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
|
||||
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
|
||||
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
|
||||
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
|
||||
* sich etwas ueber die gepruefte Adresse ablesen liesse.
|
||||
*/
|
||||
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
|
||||
try {
|
||||
await this.iconDiscovery.fetchIconBytes(iconUrl);
|
||||
} catch {
|
||||
throw new UnprocessableEntityException(
|
||||
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new favorite link.
|
||||
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||
* discovery network call (T-GWH-05).
|
||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||
* If iconUrl IS provided (260923-lrr), it must load successfully or the
|
||||
* create is rejected with 422 — nothing is written on a failed probe.
|
||||
*/
|
||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -99,8 +153,11 @@ export class FavoritesService {
|
||||
const url = normalizeUrl(dto.url);
|
||||
let iconUrl = dto.iconUrl ?? null;
|
||||
|
||||
if (iconUrl) {
|
||||
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
|
||||
await this.assertIconUrlLoadable(iconUrl);
|
||||
} else {
|
||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||
if (!iconUrl) {
|
||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||
}
|
||||
|
||||
@@ -121,6 +178,11 @@ export class FavoritesService {
|
||||
* Updates an existing favorite.
|
||||
* Verifies userId ownership before applying changes (T-08-06).
|
||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||
*
|
||||
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
|
||||
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
|
||||
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
|
||||
*/
|
||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -142,6 +204,10 @@ export class FavoritesService {
|
||||
|
||||
if ('iconUrl' in dto) {
|
||||
if (dto.iconUrl) {
|
||||
if (dto.iconUrl !== link.iconUrl) {
|
||||
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
|
||||
await this.assertIconUrlLoadable(dto.iconUrl);
|
||||
}
|
||||
// Explicit icon URL supplied — respect it as-is.
|
||||
data.iconUrl = dto.iconUrl;
|
||||
} else {
|
||||
@@ -153,6 +219,10 @@ export class FavoritesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
||||
data.iconVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
return tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data,
|
||||
@@ -162,6 +232,10 @@ export class FavoritesService {
|
||||
/**
|
||||
* Deletes a favorite link.
|
||||
* Verifies userId ownership before deleting (T-08-06).
|
||||
* 260923-lrr: hat die Zeile ein hochgeladenes Symbol, wird dessen Datei
|
||||
* NACH dem Loeschen der Zeile entfernt — ein Dateifehler wird
|
||||
* protokolliert und geschluckt (Muster T-HK4-04), das Loeschen der Zeile
|
||||
* gelingt in jedem Fall.
|
||||
*/
|
||||
async remove(tenantId: string, id: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -172,6 +246,10 @@ export class FavoritesService {
|
||||
}
|
||||
|
||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||
|
||||
if (link.uploadedIconMime !== null) {
|
||||
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,16 +320,148 @@ export class FavoritesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt ein eigenes Symbol fuer einen Favoriten an (260923-lrr). Reihenfolge
|
||||
* (Muster T-HK4-04): Groesse/Typ zuerst (kein DB-Zugriff bei offensichtlich
|
||||
* ungueltiger Datei), dann Besitzpruefung, dann Datei, dann Zeile —
|
||||
* scheitert die Zeile, wird eine neu geschriebene Datei zurueckgenommen.
|
||||
* Hatte der Favorit vorher ein Symbol MIT ANDERER Endung, wird die alte
|
||||
* Datei danach entfernt (Fehler protokolliert und geschluckt).
|
||||
*/
|
||||
async uploadIcon(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
userId: string,
|
||||
file: UploadedFileLike | undefined,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
|
||||
}
|
||||
if (file.buffer.length > FAVORITE_ICON_MAX_BYTES) {
|
||||
// Zweites Netz — multer (`limits.fileSize` an der Route) faengt das
|
||||
// in der Regel bereits vorher ab.
|
||||
throw new PayloadTooLargeException(
|
||||
'Die Datei ist zu groß – erlaubt sind höchstens 512 KB.',
|
||||
);
|
||||
}
|
||||
|
||||
const mime = detectFavoriteIconMime(file.buffer);
|
||||
if (mime === null) {
|
||||
throw new BadRequestException(
|
||||
'Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.',
|
||||
);
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
const absolute = favoriteIconAbsolutePath(link.userId, link.id, mime);
|
||||
if (absolute === null) {
|
||||
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, file.buffer);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Symbol des Favoriten ${id} konnte nicht gespeichert werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
const previousMime = link.uploadedIconMime;
|
||||
let updated: typeof link;
|
||||
try {
|
||||
updated = await tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data: { uploadedIconMime: mime, iconVersion: { increment: 1 } },
|
||||
});
|
||||
} catch (error) {
|
||||
// Ruecknahme (T-LRR-08): die neu geschriebene Datei nur entfernen,
|
||||
// wenn sie einen ANDEREN Pfad als eine vorhandene alte Datei traegt —
|
||||
// sonst wuerde ein fehlgeschlagenes Update auf demselben Typ die
|
||||
// weiterhin gueltige alte Datei loeschen.
|
||||
if (previousMime !== mime) {
|
||||
await fs.unlink(absolute).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (previousMime !== null && previousMime !== mime) {
|
||||
await this.removeIconFile(id, link.userId, previousMime, 'alte');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein hochgeladenes Symbol wieder (260923-lrr). Ohne gesetztes
|
||||
* `uploadedIconMime` liefert die Methode die Zeile unveraendert — kein
|
||||
* unnoetiger Versionssprung. Die Datei wird NACH dem Update entfernt,
|
||||
* ein Fehler dabei wird protokolliert und geschluckt (Muster T-HK4-04).
|
||||
*/
|
||||
async removeUploadedIcon(tenantId: string, id: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
if (link.uploadedIconMime === null) {
|
||||
return link;
|
||||
}
|
||||
|
||||
const previousMime = link.uploadedIconMime;
|
||||
const updated = await tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data: { uploadedIconMime: null, iconVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await this.removeIconFile(id, link.userId, previousMime, 'entfernte');
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Best-effort-Entfernung einer Symboldatei — protokolliert, wirft nie (Muster T-HK4-04). */
|
||||
private async removeIconFile(
|
||||
favoriteId: string,
|
||||
userId: string,
|
||||
mime: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const absolute = favoriteIconAbsolutePath(userId, favoriteId, mime);
|
||||
if (absolute === null) return;
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`${label} Symboldatei des Favoriten ${favoriteId} konnte nicht entfernt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
||||
* requesting user (T-08-06 — same ownership check as update/remove).
|
||||
* Never accepts a client-supplied URL — only the stored iconUrl on a
|
||||
* row the caller owns is fetched (T-QFIP-01).
|
||||
*
|
||||
* 260923-lrr: ein hochgeladenes Symbol hat VORRANG vor `iconUrl` — fehlt
|
||||
* die Datei trotz gesetztem Typ (sollte praktisch nie vorkommen), wird
|
||||
* protokolliert und auf `iconUrl` zurueckgefallen, statt 404 zu werfen.
|
||||
*
|
||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||
* by the caller, or has no icon on record. Throws a 502 HttpException
|
||||
* if the upstream fetch fails (unreachable, timeout, non-image, or
|
||||
* SSRF-blocked) -- never returns a placeholder image.
|
||||
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
||||
*/
|
||||
async getIconBytes(
|
||||
tenantId: string,
|
||||
@@ -261,7 +471,29 @@ export class FavoritesService {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId || !link.iconUrl) {
|
||||
if (!link || link.userId !== userId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
if (link.uploadedIconMime !== null) {
|
||||
const absolute = favoriteIconAbsolutePath(link.userId, link.id, link.uploadedIconMime);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
const body = await fs.readFile(absolute);
|
||||
return { contentType: link.uploadedIconMime, body };
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Hochgeladenes Symbol des Favoriten ${id} fehlt im Dateibereich, falle auf iconUrl zurueck: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Hochgeladenes Symbol des Favoriten ${id} hat keinen gueltigen Ablageort`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!link.iconUrl) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
|
||||
@@ -173,9 +173,16 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
||||
* `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen
|
||||
* Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber
|
||||
* `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe.
|
||||
*
|
||||
* quick-260924-m4n: der SIEBTE FALL ist wieder ENTFERNT. Stufe 2 der
|
||||
* Bilderrahmen-Umstellung (Migration 20260924120000_dashboard_image_drop_data)
|
||||
* loescht die Spalte `data`; der Bootstrap-Umzug in
|
||||
* `dashboard-images.service.ts` hat damit nichts mehr zu lesen und ist samt
|
||||
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
|
||||
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
||||
* 5 Dateien, 6 Aufrufe.
|
||||
*/
|
||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
||||
|
||||
@@ -623,7 +623,7 @@ export class ProxmoxService {
|
||||
* `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE
|
||||
* `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst
|
||||
* keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden
|
||||
* (Muster `DkvSchedulerService`/`DashboardImagesService`, einmal lesen,
|
||||
* (Muster `DkvSchedulerService`, einmal lesen,
|
||||
* viele bedienen).
|
||||
*/
|
||||
async loadActiveServersForScheduler(): Promise<
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// Statisch statt je Test dynamisch importiert: Laden/Umwandeln der Seite faellt
|
||||
// in die Sammelphase, nicht in die 5-s-Frist eines Tests (quick-260924-m4n).
|
||||
import Page from './page';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||
@@ -110,14 +113,14 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function importPage() {
|
||||
const mod = await import('./page');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
describe('Marketplace Filters', () => {
|
||||
// userEvent an die falsche Uhr koppeln: sonst wartet jede Eingabe auf das
|
||||
// langsame Echtzeit-Nachschieben von shouldAdvanceTime (quick-260924-m4n).
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
mockAuthStore.mockImplementation(
|
||||
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
||||
selector({
|
||||
@@ -132,7 +135,6 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
it('typing in search filters the grid to only matching modules after debounce', async () => {
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -142,7 +144,7 @@ describe('Marketplace Filters', () => {
|
||||
expect(screen.getByText('Email Tool')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Module suchen...');
|
||||
await userEvent.type(searchInput, 'Domain');
|
||||
await user.type(searchInput, 'Domain');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
@@ -155,7 +157,6 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
it('selecting the Aktiviert status tab shows only activated modules', async () => {
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -163,7 +164,7 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
const activeTab = screen.getByRole('tab', { name: /Aktiviert/i });
|
||||
await userEvent.click(activeTab);
|
||||
await user.click(activeTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
||||
@@ -173,7 +174,6 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
it('selecting a category chip shows only modules of that category; Alle shows all', async () => {
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -181,7 +181,7 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
const utilitiesChip = screen.getByRole('button', { name: 'Utilities' });
|
||||
await userEvent.click(utilitiesChip);
|
||||
await user.click(utilitiesChip);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Converter')).toBeInTheDocument();
|
||||
@@ -189,7 +189,7 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
const allChip = screen.getByRole('button', { name: 'Alle' });
|
||||
await userEvent.click(allChip);
|
||||
await user.click(allChip);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
||||
@@ -198,7 +198,6 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
it('search + status + category filters compose together (AND)', async () => {
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -207,11 +206,11 @@ describe('Marketplace Filters', () => {
|
||||
|
||||
// Filter to Domain-Tools category
|
||||
const domainChip = screen.getByRole('button', { name: 'Domain-Tools' });
|
||||
await userEvent.click(domainChip);
|
||||
await user.click(domainChip);
|
||||
|
||||
// Filter to available only
|
||||
const availableTab = screen.getByRole('tab', { name: /Verfuegbar/i });
|
||||
await userEvent.click(availableTab);
|
||||
await user.click(availableTab);
|
||||
|
||||
// Domain-Tools + Available = only Email Tool (Domaincheck is active)
|
||||
await waitFor(() => {
|
||||
@@ -222,7 +221,6 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
it('renders filtered-empty heading when filters produce zero results', async () => {
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -230,7 +228,7 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Module suchen...');
|
||||
await userEvent.type(searchInput, 'zzz-nonexistent');
|
||||
await user.type(searchInput, 'zzz-nonexistent');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// Statisch statt je Test dynamisch importiert: Laden/Umwandeln der Seite faellt
|
||||
// in die Sammelphase, nicht in die 5-s-Frist eines Tests (quick-260924-m4n).
|
||||
import Page from './page';
|
||||
|
||||
// Mock next-intl
|
||||
vi.mock('next-intl', () => ({
|
||||
@@ -79,12 +82,6 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Lazy import after mocks
|
||||
async function importPage() {
|
||||
const mod = await import('./page');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
describe('MarketplacePage', () => {
|
||||
beforeEach(() => {
|
||||
// Default: admin user
|
||||
@@ -106,7 +103,6 @@ describe('MarketplacePage', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -126,7 +122,6 @@ describe('MarketplacePage', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -156,7 +151,6 @@ describe('MarketplacePage', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -176,7 +170,6 @@ describe('MarketplacePage', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// Komponenten statisch importiert (vi.mock wird ueber die Importe gehoben): das
|
||||
// Laden und Umwandeln faellt so in die Sammelphase der Datei und nicht in die
|
||||
// 5-s-Frist des ersten Tests. Frueher lag der dynamische Import im Test und hat
|
||||
// unter Laeuferlast (drei parallele Pipelines bei jeder Freigabe) die Frist
|
||||
// gerissen (Todo 2026-09-23, quick-260924-m4n).
|
||||
import { TenantContextSelector } from './components/TenantContextSelector';
|
||||
import Page from './page';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||
@@ -86,19 +93,8 @@ afterEach(() => {
|
||||
mockBumpSidebarRefresh.mockClear();
|
||||
});
|
||||
|
||||
async function importPage() {
|
||||
const mod = await import('./page');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
async function importTenantSelector() {
|
||||
const mod = await import('./components/TenantContextSelector');
|
||||
return mod.TenantContextSelector;
|
||||
}
|
||||
|
||||
describe('TenantContextSelector', () => {
|
||||
it('renders tenant options for SUPER_ADMIN; renders nothing for ADMIN', async () => {
|
||||
// SUPER_ADMIN case
|
||||
it('renders tenant options for SUPER_ADMIN', async () => {
|
||||
mockAuthStore.mockImplementation(
|
||||
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
||||
selector({
|
||||
@@ -116,16 +112,13 @@ describe('TenantContextSelector', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const TenantContextSelector = await importTenantSelector();
|
||||
const { unmount } = render(<TenantContextSelector />);
|
||||
render(<TenantContextSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tenant Alpha')).toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByText('Tenant Alpha')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tenant Beta')).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
// ADMIN case
|
||||
it('renders nothing for ADMIN and does not load the tenant list', () => {
|
||||
mockAuthStore.mockImplementation(
|
||||
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
||||
selector({
|
||||
@@ -133,8 +126,12 @@ describe('TenantContextSelector', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { container } = render(<TenantContextSelector />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setSelectedTenantId when selector value changes', async () => {
|
||||
@@ -155,7 +152,6 @@ describe('TenantContextSelector', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const TenantContextSelector = await importTenantSelector();
|
||||
render(<TenantContextSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -190,7 +186,6 @@ describe('ActivationDialog', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -224,7 +219,6 @@ describe('ActivationDialog', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const Page = await importPage();
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,51 +1,56 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import type { ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'card.unknownValue': 'unbekannt',
|
||||
'card.lastPolledLabel': 'Letzte Abfrage',
|
||||
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
|
||||
'card.notPolledYetAutomatic':
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
'card.refresh': 'Jetzt aktualisieren',
|
||||
'card.lastOkLabel': 'Letzte erfolgreiche Messung',
|
||||
'card.pve.nodeCount': 'Knoten',
|
||||
'card.pve.guests': '{running} laufend / {stopped} gestoppt',
|
||||
'card.pve.cpu': 'Prozessorlast',
|
||||
'card.pve.mem': 'Speicher',
|
||||
'card.pbs.used': 'Belegt',
|
||||
'card.pbs.lastBackup': 'Letzte Sicherung',
|
||||
'card.pbs.noBackupYet': 'noch keine Sicherung',
|
||||
'card.pbs.verifyState': 'Letzte Prüfung',
|
||||
'card.pmg.countIn': 'Eingehend',
|
||||
'card.pmg.countOut': 'Ausgehend',
|
||||
'card.pmg.spamCount': 'Spam',
|
||||
'card.pmg.virusCount': 'Viren',
|
||||
'errors.netz': 'Der Server ist nicht erreichbar.',
|
||||
'errors.zugang': 'Der Zugang wurde abgelehnt.',
|
||||
'errors.rechte': 'Die Rechte reichen nicht aus.',
|
||||
'errors.zertifikat': 'Das Zertifikat wurde abgelehnt.',
|
||||
'errors.antwortform': 'Unerwartete Antwortform.',
|
||||
'errors.server': 'Serverfehler.',
|
||||
'errors.unbekannt': 'Unerwarteter Fehler.',
|
||||
};
|
||||
let result = translations[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* ServerCard (quick-260924-h7x) — mit dem ECHTEN next-intl-Provider und den
|
||||
* echten deutschen Texten, damit ICU-Plural und Zahlformate mitgeprueft
|
||||
* werden.
|
||||
*/
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/** Fester Bezugszeitpunkt: 24.09.2026, 12:00 UTC. */
|
||||
const NOW = Date.parse('2026-09-24T12:00:00.000Z');
|
||||
const NOW_S = NOW / 1000;
|
||||
|
||||
function renderDe(ui: ReactElement) {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function makeStatus(overrides: Partial<ProxmoxServerStatus> = {}): ProxmoxServerStatus {
|
||||
return {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-24T11:56:00.000Z',
|
||||
lastOkAt: '2026-09-24T11:56:00.000Z',
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-24T11:56:00.000Z',
|
||||
metrics: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||
return {
|
||||
id: 'srv-1',
|
||||
@@ -67,290 +72,397 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||
};
|
||||
}
|
||||
|
||||
describe('ServerCard', () => {
|
||||
it('PVE-Server zeigt Knotenzahl, laufende/gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung', async () => {
|
||||
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
|
||||
|
||||
async function card(server: ProxmoxServer, isAdmin?: boolean) {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />);
|
||||
return screen.getByTestId('server-card');
|
||||
}
|
||||
|
||||
describe('ServerCard — Kopf und Status', () => {
|
||||
it('zeigt Name, ausgeschriebenen Produktnamen und die Adresse unveraendert (keine Grossschrift)', async () => {
|
||||
const el = await card(makeServer({ baseUrl: 'https://172.21.0.1:8006', status: makeStatus() }));
|
||||
|
||||
expect(within(el).getByText('pve-1')).toBeInTheDocument();
|
||||
expect(within(el).getByText('Virtualisierung')).toBeInTheDocument();
|
||||
const address = within(el).getByText('https://172.21.0.1:8006');
|
||||
expect(address).not.toHaveClass('uppercase');
|
||||
expect(el.querySelector('.uppercase')).toBeNull();
|
||||
});
|
||||
|
||||
it('PBS und PMG tragen ihre ausgeschriebenen Produktnamen', async () => {
|
||||
await card(makeServer({ id: 'b', productType: 'pbs', status: makeStatus() }));
|
||||
expect(screen.getByText('Datensicherung')).toBeInTheDocument();
|
||||
cleanup();
|
||||
await card(makeServer({ id: 'm', productType: 'pmg', status: makeStatus() }));
|
||||
expect(screen.getByText('Mail-Gateway')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('erreichbar ohne Auffaelligkeiten: Pille „In Ordnung“, data-health ok', async () => {
|
||||
const el = await card(makeServer({ status: makeStatus() }));
|
||||
expect(el).toHaveAttribute('data-health', 'ok');
|
||||
expect(within(el).getByTestId('status-pill')).toHaveTextContent('In Ordnung');
|
||||
});
|
||||
|
||||
it('Fuss: „Letzte Abfrage vor 4 Min.“ relativ, exakter Zeitpunkt im title', async () => {
|
||||
const el = await card(makeServer({ status: makeStatus() }));
|
||||
const footer = within(el).getByTestId('last-polled');
|
||||
expect(footer).toHaveTextContent('Letzte Abfrage vor 4 Min.');
|
||||
expect(footer.getAttribute('title')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerCard — PVE', () => {
|
||||
it('laufende und gestoppte Gaeste, Knotenzahl, je Knoten Prozessor und Arbeitsspeicher mit deutschem Zahlformat', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pve',
|
||||
nodeCount: 2,
|
||||
guestsRunning: 3,
|
||||
guestsStopped: 1,
|
||||
nodes: [
|
||||
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_294_967_296, maxmem: 8_589_934_592 },
|
||||
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 12 * 1024 ** 3, maxmem: 64 * 1024 ** 3 },
|
||||
],
|
||||
storages: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(within(el).getByTestId('guests-running')).toHaveTextContent('3');
|
||||
expect(within(el).getByText('Gäste laufen')).toBeInTheDocument();
|
||||
expect(within(el).getByTestId('guests-stopped')).toHaveTextContent('1');
|
||||
expect(within(el).getByText('2 Knoten')).toBeInTheDocument();
|
||||
|
||||
const node = within(el).getByTestId('pve-node-pve1');
|
||||
const [cpu, mem] = within(node).getAllByTestId('meter');
|
||||
expect(cpu).toHaveTextContent('Prozessor');
|
||||
expect(cpu).toHaveTextContent(/25\s%/);
|
||||
expect(cpu).toHaveAttribute('data-level', 'ok');
|
||||
expect(mem).toHaveTextContent('Arbeitsspeicher');
|
||||
expect(mem).toHaveTextContent(/19\s%/);
|
||||
expect(mem).toHaveTextContent('12,0 / 64,0 GB');
|
||||
});
|
||||
|
||||
it('Auslastung ab 80 % faerbt den Balken als Warnung, ab 92 % kritisch, und die Karte wird „Warnung“', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pve',
|
||||
nodeCount: 1,
|
||||
guestsRunning: 1,
|
||||
guestsStopped: 0,
|
||||
nodes: [{ node: 'pve1', cpu: 0.85, maxcpu: 8, mem: 95, maxmem: 100 }],
|
||||
storages: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(el).toHaveAttribute('data-health', 'warn');
|
||||
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Warnung');
|
||||
const [cpu, mem] = within(el).getAllByTestId('meter');
|
||||
expect(cpu).toHaveAttribute('data-level', 'warn');
|
||||
expect(mem).toHaveAttribute('data-level', 'crit');
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
it('unbekannte Knotenwerte erscheinen als „unbekannt“ — nie 0 %, nie NaN', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pve',
|
||||
nodeCount: 1,
|
||||
guestsRunning: 0,
|
||||
guestsStopped: 0,
|
||||
nodes: [{ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null }],
|
||||
storages: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Knoten:/)).toHaveTextContent('Knoten: 2');
|
||||
expect(screen.getByText(/laufend/)).toHaveTextContent('3 laufend / 1 gestoppt');
|
||||
expect(screen.getByText(/Prozessorlast/)).toHaveTextContent('Prozessorlast: 25%');
|
||||
expect(screen.getByText(/Speicher:/)).toHaveTextContent('4.0 GB / 8.0 GB');
|
||||
const [cpu, mem] = within(el).getAllByTestId('meter');
|
||||
expect(cpu).toHaveTextContent('unbekannt');
|
||||
expect(mem).toHaveTextContent('unbekannt');
|
||||
expect(cpu).toHaveAttribute('data-level', 'unknown');
|
||||
expect(el).not.toHaveTextContent(/0\s%/);
|
||||
expect(el).not.toHaveTextContent('NaN');
|
||||
expect(el).toHaveAttribute('data-health', 'ok');
|
||||
});
|
||||
});
|
||||
|
||||
it('PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
describe('ServerCard — PBS', () => {
|
||||
it('je Datenspeicher Fuellstand, letzte Sicherung relativ und Pruefpille', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
productType: 'pbs',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pbs',
|
||||
datastores: [
|
||||
{ name: 'backup-store', total: 1000, used: 400, free: 600, lastBackupAt: 1758000000, lastVerifyState: 'ok' },
|
||||
{
|
||||
name: 'backup-store',
|
||||
total: 4 * 1024 ** 4,
|
||||
used: 1.2 * 1024 ** 4,
|
||||
free: 2.8 * 1024 ** 4,
|
||||
lastBackupAt: NOW_S - 5 * 3600,
|
||||
lastVerifyState: 'ok',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const ds = within(el).getByTestId('pbs-datastore-backup-store');
|
||||
expect(within(ds).getByText('backup-store')).toBeInTheDocument();
|
||||
expect(within(ds).getByTestId('meter')).toHaveTextContent('1,2 / 4,0 TB');
|
||||
expect(within(ds).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 5 Std.');
|
||||
expect(within(ds).getByTestId('verify-pill')).toHaveTextContent('Prüfung in Ordnung');
|
||||
expect(el).toHaveAttribute('data-health', 'ok');
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText('backup-store')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Belegt:/)).toHaveTextContent('400 B / 1000 B');
|
||||
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('ok');
|
||||
});
|
||||
|
||||
it('PBS-Datenspeicher ohne Sicherung zeigt "noch keine Sicherung"', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
it('Sicherung aelter als 26 h und fehlgeschlagene Pruefung -> Warnung', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
productType: 'pbs',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: null,
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pbs',
|
||||
datastores: [
|
||||
{ name: 'leer', total: 10, used: 0, free: 10, lastBackupAt: null, lastVerifyState: null },
|
||||
{
|
||||
name: 'alt',
|
||||
total: 100,
|
||||
used: 10,
|
||||
free: 90,
|
||||
lastBackupAt: NOW_S - 30 * 3600,
|
||||
lastVerifyState: 'failed',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(el).toHaveAttribute('data-health', 'warn');
|
||||
expect(within(el).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 1 Tag');
|
||||
expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung fehlgeschlagen');
|
||||
expect(within(el).getByTestId('verify-pill')).toHaveAttribute('title', 'failed');
|
||||
});
|
||||
|
||||
it('ohne Sicherung „noch keine Sicherung“, Pruefung null „Prüfung unbekannt“, keine Warnung', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
productType: 'pbs',
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pbs',
|
||||
datastores: [
|
||||
{
|
||||
name: 'leer',
|
||||
total: 10,
|
||||
used: 0,
|
||||
free: 10,
|
||||
lastBackupAt: null,
|
||||
lastVerifyState: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(within(el).getByText('noch keine Sicherung')).toBeInTheDocument();
|
||||
expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung unbekannt');
|
||||
expect(el).toHaveAttribute('data-health', 'ok');
|
||||
});
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung');
|
||||
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt');
|
||||
});
|
||||
|
||||
it('PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
describe('ServerCard — PMG', () => {
|
||||
it('vier Zahlfelder: eingehend, ausgehend, Spam, Viren', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
productType: 'pmg',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: { productType: 'pmg', countIn: 120, countOut: 45, spamCount: 12, virusCount: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('120');
|
||||
expect(screen.getByText(/Ausgehend:/)).toHaveTextContent('45');
|
||||
expect(screen.getByText(/Spam:/)).toHaveTextContent('12');
|
||||
expect(screen.getByText(/Viren:/)).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('ein Messwert, der null ist, erscheint als "unbekannt" — nie als 0, leer oder NaN', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pmg',
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: null,
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null },
|
||||
countIn: 1200,
|
||||
countOut: 45,
|
||||
spamCount: 12,
|
||||
virusCount: 0,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(within(el).getByTestId('pmg-in')).toHaveTextContent('Eingehend1.200');
|
||||
expect(within(el).getByTestId('pmg-out')).toHaveTextContent('Ausgehend45');
|
||||
expect(within(el).getByTestId('pmg-spam')).toHaveTextContent('Spam12');
|
||||
expect(within(el).getByTestId('pmg-virus')).toHaveTextContent('Viren0');
|
||||
expect(el).toHaveAttribute('data-health', 'ok');
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
it('Viren > 0 erscheinen in der Fehlerfarbe und machen die Karte zur Warnung', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
productType: 'pmg',
|
||||
status: makeStatus({
|
||||
metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 1, virusCount: 3 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('unbekannt');
|
||||
expect(screen.queryByText(/Eingehend: 0/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
|
||||
expect(el).toHaveAttribute('data-health', 'warn');
|
||||
expect(within(within(el).getByTestId('pmg-virus')).getByText('3')).toHaveClass(
|
||||
'text-status-down-fg',
|
||||
);
|
||||
});
|
||||
|
||||
it('ein Server mit reachable:false zeigt den Klartext der Ursache und den Zeitpunkt der letzten erfolgreichen Messung', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:05:00.000Z',
|
||||
lastOkAt: '2026-09-23T09:00:00.000Z',
|
||||
it('null-Werte erscheinen als „unbekannt“ — nie 0', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
productType: 'pmg',
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pmg',
|
||||
countIn: null,
|
||||
countOut: null,
|
||||
spamCount: null,
|
||||
virusCount: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(within(el).getByTestId('pmg-in')).toHaveTextContent('Eingehendunbekannt');
|
||||
expect(within(el).getByTestId('pmg-virus')).toHaveTextContent('Virenunbekannt');
|
||||
expect(el).not.toHaveTextContent('NaN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerCard — nicht erreichbar', () => {
|
||||
it('Klartext der Ursache, Detail in eigener Zeile (nicht in Klammern), „Zuletzt erreichbar vor 2 Tagen“', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
status: makeStatus({
|
||||
lastOkAt: '2026-09-22T12:00:00.000Z',
|
||||
reachable: false,
|
||||
errorKind: 'zugang',
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:05:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
errorDetail: 'HTTP 401',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(el).toHaveAttribute('data-health', 'down');
|
||||
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Nicht erreichbar');
|
||||
const notice = within(el).getByTestId('down-notice');
|
||||
expect(within(notice).getByText(/Der Zugang wurde abgelehnt\./)).toBeInTheDocument();
|
||||
expect(within(notice).getByText('HTTP 401')).toBeInTheDocument();
|
||||
expect(notice).not.toHaveTextContent('(HTTP 401)');
|
||||
expect(within(notice).getByText('Zuletzt erreichbar vor 2 Tagen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText('Der Zugang wurde abgelehnt.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Letzte erfolgreiche Messung/)).toBeInTheDocument();
|
||||
it('nie erreichbar gewesen: keine „Zuletzt erreichbar“-Zeile', async () => {
|
||||
const el = await card(
|
||||
makeServer({ status: makeStatus({ lastOkAt: null, reachable: false, errorKind: 'netz' }) }),
|
||||
);
|
||||
expect(within(el).queryByText(/Zuletzt erreichbar/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('der Zeitpunkt der letzten Abfrage steht bei jedem Server', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||
lastOkAt: null,
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
|
||||
expect(screen.getByText(/Letzte Abfrage/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 2: ein frisch angelegter, noch nie abgefragter Server zeigt Admins den ruhigen Hinweis mit Knopfverweis statt "Ein unerwarteter Fehler ist aufgetreten"', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
// Zustand direkt nach `createServer`: leere Zwischenlagerzeile, noch nie abgefragt.
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: null,
|
||||
lastOkAt: null,
|
||||
reachable: false,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} isAdmin />);
|
||||
describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
|
||||
it('Admin: ruhiger Hinweis mit Knopfverweis statt Fehlermeldung', async () => {
|
||||
const el = await card(makeServer({ status: UNPOLLED }), true);
|
||||
|
||||
expect(el).toHaveAttribute('data-health', 'idle');
|
||||
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Noch nicht abgefragt');
|
||||
expect(
|
||||
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
|
||||
within(el).getByText(
|
||||
'Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
// Ohne die Korrektur erschiene hier faelschlich die Sammelmeldung — roter Test.
|
||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(el).queryByText('Ein unerwarteter Fehler ist aufgetreten.'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('260923-le6: isAdmin={false}, noch nie abgefragt -> automatischer Hinweis ohne Knopfverweis', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: null,
|
||||
lastOkAt: null,
|
||||
reachable: false,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} isAdmin={false} />);
|
||||
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
|
||||
const el = await card(makeServer({ status: UNPOLLED }), false);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
within(el).getByText(
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
||||
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('260923-le6: isAdmin weggelassen, noch nie abgefragt -> verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({
|
||||
status: {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: null,
|
||||
lastOkAt: null,
|
||||
reachable: false,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||
metrics: null,
|
||||
it('isAdmin weggelassen: verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
|
||||
const el = await card(makeServer({ status: UNPOLLED }));
|
||||
|
||||
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||
expect(within(el).getByText(/automatischen Abfrage/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerCard — offline & verwaist', () => {
|
||||
const staleMetrics = makeStatus({
|
||||
metrics: {
|
||||
productType: 'pve',
|
||||
nodeCount: 1,
|
||||
guestsRunning: 7,
|
||||
guestsStopped: 2,
|
||||
nodes: [{ node: 'altknoten', cpu: 0.99, maxcpu: 8, mem: 99, maxmem: 100 }],
|
||||
storages: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
it('deaktiviert hat Vorrang: Pille „Offline & verwaist“, gestrichelter Rand, KEINE alten Messwerte', async () => {
|
||||
const el = await card(makeServer({ isActive: false, status: staleMetrics }));
|
||||
|
||||
expect(el).toHaveAttribute('data-health', 'orphan');
|
||||
expect(el).toHaveClass('border-dashed');
|
||||
// gedaempft wird nur der Name (Vordergrundfarbe haelt 7,5:1), die Adresse bleibt lesbar
|
||||
expect(within(el).getByText('pve-1')).toHaveClass('opacity-70', 'saturate-50');
|
||||
expect(within(el).getByText('https://pve.intern')).not.toHaveClass('opacity-70');
|
||||
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Offline & verwaist');
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
),
|
||||
within(el).getByText('Dieser Server ist deaktiviert und wird nicht mehr abgefragt.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
||||
expect(within(el).queryByTestId('guests-running')).not.toBeInTheDocument();
|
||||
expect(within(el).queryByText('altknoten')).not.toBeInTheDocument();
|
||||
expect(within(el).queryAllByTestId('meter')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Nachbesserung Befund 3: die Adresse bleibt unveraendert dargestellt, nur das Produktkuerzel ist grossgeschrieben', async () => {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
const server = makeServer({ baseUrl: 'https://172.21.0.1:8006' });
|
||||
it('deaktiviert und nicht erreichbar: keine Fehlermeldung, sondern der Verwaist-Hinweis', async () => {
|
||||
const el = await card(
|
||||
makeServer({ isActive: false, status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
|
||||
);
|
||||
expect(within(el).queryByTestId('down-notice')).not.toBeInTheDocument();
|
||||
expect(within(el).getByTestId('orphan-notice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
render(<ServerCard server={server} />);
|
||||
it('Admins bekommen den Hinweis auf die Einstellungen mit Verweis, andere nicht', async () => {
|
||||
const admin = await card(makeServer({ isActive: false, status: staleMetrics }), true);
|
||||
expect(
|
||||
within(admin).getByText('Sie können ihn in den Einstellungen wieder aktivieren.', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(within(admin).getByRole('link', { name: 'Zu den Einstellungen' })).toHaveAttribute(
|
||||
'href',
|
||||
'/modules/proxmox/settings',
|
||||
);
|
||||
cleanup();
|
||||
|
||||
const line = screen.getByText(/172\.21\.0\.1:8006/);
|
||||
expect(line).toHaveTextContent('pve — https://172.21.0.1:8006');
|
||||
const productSpan = line.querySelector('span');
|
||||
expect(productSpan).not.toBeNull();
|
||||
expect(productSpan).toHaveClass('uppercase');
|
||||
expect(line).not.toHaveClass('uppercase');
|
||||
const user = await card(makeServer({ isActive: false, status: staleMetrics }), false);
|
||||
expect(within(user).queryByRole('link')).not.toBeInTheDocument();
|
||||
expect(within(user).queryByText(/wieder aktivieren/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,88 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import {
|
||||
formatAge,
|
||||
formatCount,
|
||||
formatPercent,
|
||||
isBackupStale,
|
||||
meterLevel,
|
||||
ratio,
|
||||
type ServerHealth,
|
||||
serverHealth,
|
||||
toEpochMs,
|
||||
} from '@/components/proxmox/proxmox-status';
|
||||
import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from '@/components/proxmox/status-styles';
|
||||
import type {
|
||||
ProxmoxErrorKind,
|
||||
ProxmoxPbsMetrics,
|
||||
ProxmoxPmgMetrics,
|
||||
ProxmoxProductType,
|
||||
ProxmoxPveMetrics,
|
||||
ProxmoxServer,
|
||||
} from '@/lib/proxmox-api';
|
||||
|
||||
/**
|
||||
* Wandelt einen rohen Byte-Wert in eine lesbare Form (B/KB/MB/GB/TB).
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = -1;
|
||||
do {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
} while (value >= 1024 && unitIndex < units.length - 1);
|
||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
type Translator = ReturnType<typeof useTranslations>;
|
||||
|
||||
/** Prozentwert einer Auslastung (0..1) gerundet, z. B. `25%`. */
|
||||
function formatPercent(fraction: number): string {
|
||||
return `${Math.round(fraction * 100)}%`;
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
function isKnown(value: number | null | undefined): value is number {
|
||||
return typeof value === 'number' && !Number.isNaN(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die EINZIGE Stelle, die einen Messwert in Text verwandelt (Aufgabe 6):
|
||||
* ist der Wert `null`/`undefined`, erscheint der uebersetzte Text
|
||||
* "unbekannt" — sonst der Wert mit seiner Einheit. Dadurch kann kein Zweig
|
||||
* versehentlich eine `0` anzeigen, wo nichts gemessen wurde. Die Feldnamen
|
||||
* von PBS und PMG sind bis zur Pruefung am echten Server nur abgeleitet
|
||||
* (Recherche, Annahmen A2/A3/A5), und ein still falscher Wert waere
|
||||
* schlimmer als ein ehrliches "unbekannt".
|
||||
* „12,0 / 64,0 GB“ — beide Werte in DERSELBEN Einheit, gewaehlt nach der
|
||||
* Gesamtgroesse, Zahlformat nach Sprache (deutsch mit Komma). `null`, wenn
|
||||
* einer der Werte unbekannt ist — dann steht an der Stelle nichts statt
|
||||
* einer erfundenen Zahl.
|
||||
*/
|
||||
function formatMetric(
|
||||
value: number | string | null | undefined,
|
||||
unknownLabel: string,
|
||||
formatter?: (v: number) => string,
|
||||
): string {
|
||||
if (value === null || value === undefined) return unknownLabel;
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isNaN(value)) return unknownLabel;
|
||||
return formatter ? formatter(value) : String(value);
|
||||
}
|
||||
return value;
|
||||
function formatBytePair(used: number | null, total: number | null, locale: string): string | null {
|
||||
if (!isKnown(used) || !isKnown(total) || total <= 0) return null;
|
||||
const exponent = Math.min(
|
||||
BYTE_UNITS.length - 1,
|
||||
Math.max(0, Math.floor(Math.log(total) / Math.log(1024))),
|
||||
);
|
||||
const divisor = 1024 ** exponent;
|
||||
const digits = exponent === 0 ? 0 : 1;
|
||||
const nf = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return `${nf.format(used / divisor)} / ${nf.format(total / divisor)} ${BYTE_UNITS[exponent]}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | number | null | undefined, unknownLabel: string): string {
|
||||
if (value === null || value === undefined) return unknownLabel;
|
||||
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return unknownLabel;
|
||||
return date.toLocaleString();
|
||||
/** Exakter Zeitpunkt fuer das `title`-Attribut der relativen Angaben. */
|
||||
function exactTime(value: number | string | null | undefined, locale: string): string | undefined {
|
||||
const ms = toEpochMs(value);
|
||||
if (ms === null) return undefined;
|
||||
return new Date(ms).toLocaleString(locale);
|
||||
}
|
||||
|
||||
interface MetricsViewProps {
|
||||
metrics: ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
|
||||
unknown: string;
|
||||
}
|
||||
|
||||
function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unknown: string }) {
|
||||
const t = useTranslations('proxmox');
|
||||
/** Kleines Produktsymbol je Typ: Server-Einschuebe, Archivkiste, Briefumschlag. */
|
||||
function ProductIcon({ productType }: { productType: ProxmoxProductType }) {
|
||||
const common = {
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.75,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
} as const;
|
||||
if (productType === 'pve') {
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<svg aria-hidden="true" {...common}>
|
||||
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
|
||||
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
|
||||
<line x1="11" y1="7.5" x2="17" y2="7.5" />
|
||||
<line x1="11" y1="16.5" x2="17" y2="16.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (productType === 'pbs') {
|
||||
return (
|
||||
<svg aria-hidden="true" {...common}>
|
||||
<rect x="3" y="4" width="18" height="4" rx="1" />
|
||||
<path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8" />
|
||||
<line x1="10" y1="12" x2="14" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg aria-hidden="true" {...common}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<polyline points="3 7 12 13 21 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Statuspille: Punkt + Wort, Flaeche in Statusfarbe mit 12 %. Keine Daueranimation. */
|
||||
function StatusPill({ health, label }: { health: ServerHealth; label: string }) {
|
||||
const style = HEALTH_STYLE[health];
|
||||
return (
|
||||
<span
|
||||
data-testid="status-pill"
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium ${style.pill}`}
|
||||
>
|
||||
<span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${style.fill}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schmaler Balken mit Prozentzahl. Ein unbekannter Wert zeigt eine
|
||||
* schraffierte Spur und das Wort „unbekannt“ — NIE 0 %.
|
||||
*/
|
||||
function Meter({
|
||||
label,
|
||||
fraction,
|
||||
detail,
|
||||
unknownLabel,
|
||||
locale,
|
||||
}: {
|
||||
label: string;
|
||||
fraction: number | null;
|
||||
detail?: string | null;
|
||||
unknownLabel: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const level = meterLevel(fraction);
|
||||
const known = level !== 'unknown' && fraction !== null;
|
||||
const width = known ? `${Math.min(100, Math.max(0, fraction * 100))}%` : '100%';
|
||||
return (
|
||||
<div data-testid="meter" data-level={level} className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={`tabular-nums font-medium ${METER_TEXT[level]}`}>
|
||||
{known ? formatPercent(fraction, locale) : unknownLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div aria-hidden="true" className="mt-1 h-1.5 overflow-hidden rounded-full bg-foreground/10">
|
||||
{known ? (
|
||||
<div className={`h-full rounded-full ${METER_FILL[level]}`} style={{ width }} />
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(135deg, var(--border) 0 4px, transparent 4px 8px)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{detail && (
|
||||
<div className="mt-1 text-[11px] tabular-nums text-muted-foreground">{detail}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PveMetricsView({
|
||||
metrics,
|
||||
t,
|
||||
locale,
|
||||
}: {
|
||||
metrics: ProxmoxPveMetrics;
|
||||
t: Translator;
|
||||
locale: string;
|
||||
}) {
|
||||
const unknown = t('card.unknownValue');
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className={`${WELL} flex items-end justify-between gap-4 px-4 py-3`}>
|
||||
<div className="flex items-end gap-6">
|
||||
<div>
|
||||
{t('card.pve.nodeCount')}: {metrics.nodeCount}
|
||||
<div
|
||||
className="text-2xl font-semibold leading-none tabular-nums"
|
||||
data-testid="guests-running"
|
||||
>
|
||||
{formatCount(metrics.guestsRunning, locale)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t('card.pve.running', { count: metrics.guestsRunning })}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })}
|
||||
<div
|
||||
className="text-2xl font-semibold leading-none tabular-nums text-muted-foreground"
|
||||
data-testid="guests-stopped"
|
||||
>
|
||||
{formatCount(metrics.guestsStopped, locale)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t('card.pve.stopped')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('card.pve.nodes', { count: metrics.nodeCount })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metrics.nodes.map((node) => (
|
||||
<div key={node.node} className="rounded border border-border/60 px-2 py-1 text-xs">
|
||||
<div className="font-medium">{node.node}</div>
|
||||
<div>
|
||||
{t('card.pve.cpu')}: {formatMetric(node.cpu, unknown, formatPercent)}
|
||||
{' · '}
|
||||
{t('card.pve.mem')}: {formatMetric(node.mem, unknown, formatBytes)}
|
||||
{node.maxmem !== null && node.mem !== null
|
||||
? ` / ${formatMetric(node.maxmem, unknown, formatBytes)}`
|
||||
: ''}
|
||||
<div
|
||||
key={node.node}
|
||||
data-testid={`pve-node-${node.node}`}
|
||||
className={`${WELL} grid grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] items-start gap-4 px-4 py-3`}
|
||||
>
|
||||
<div className="truncate pt-0.5 text-sm font-medium" title={node.node}>
|
||||
{node.node}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Meter
|
||||
label={t('card.pve.cpu')}
|
||||
fraction={isKnown(node.cpu) ? node.cpu : null}
|
||||
unknownLabel={unknown}
|
||||
locale={locale}
|
||||
/>
|
||||
<Meter
|
||||
label={t('card.pve.mem')}
|
||||
fraction={ratio(node.mem, node.maxmem)}
|
||||
detail={formatBytePair(node.mem, node.maxmem, locale)}
|
||||
unknownLabel={unknown}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -90,124 +232,258 @@ function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unkn
|
||||
);
|
||||
}
|
||||
|
||||
function PbsMetricsView({ metrics, unknown }: { metrics: ProxmoxPbsMetrics; unknown: string }) {
|
||||
const t = useTranslations('proxmox');
|
||||
function PbsMetricsView({
|
||||
metrics,
|
||||
t,
|
||||
locale,
|
||||
now,
|
||||
}: {
|
||||
metrics: ProxmoxPbsMetrics;
|
||||
t: Translator;
|
||||
locale: string;
|
||||
now: number;
|
||||
}) {
|
||||
const unknown = t('card.unknownValue');
|
||||
if (metrics.datastores.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('card.pbs.noDatastores')}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
{metrics.datastores.map((ds) => (
|
||||
<div key={ds.name} className="rounded border border-border/60 px-2 py-1 text-xs">
|
||||
<div className="font-medium">{ds.name}</div>
|
||||
<div>
|
||||
{t('card.pbs.used')}: {formatMetric(ds.used, unknown, formatBytes)}
|
||||
{ds.total !== null && ds.used !== null ? ` / ${formatMetric(ds.total, unknown, formatBytes)}` : ''}
|
||||
<div className="space-y-2">
|
||||
{metrics.datastores.map((ds) => {
|
||||
const stale = isBackupStale(ds.lastBackupAt, now);
|
||||
const backupAge = formatAge(ds.lastBackupAt, now, locale);
|
||||
const verify =
|
||||
ds.lastVerifyState === null
|
||||
? { cls: HEALTH_STYLE.idle.pill, text: t('card.pbs.verifyUnknown') }
|
||||
: ds.lastVerifyState === 'ok'
|
||||
? { cls: HEALTH_STYLE.ok.pill, text: t('card.pbs.verifyOk') }
|
||||
: {
|
||||
cls: HEALTH_STYLE.warn.pill,
|
||||
text: t('card.pbs.verifyFailed', { state: ds.lastVerifyState }),
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={ds.name}
|
||||
data-testid={`pbs-datastore-${ds.name}`}
|
||||
className={`${WELL} space-y-2.5 px-4 py-3`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium" title={ds.name}>
|
||||
{ds.name}
|
||||
</span>
|
||||
<span
|
||||
data-testid="verify-pill"
|
||||
title={ds.lastVerifyState ?? undefined}
|
||||
className={`shrink-0 whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium ${verify.cls}`}
|
||||
>
|
||||
{verify.text}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pbs.lastBackup')}:{' '}
|
||||
{ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)}
|
||||
<Meter
|
||||
label={t('card.pbs.used')}
|
||||
fraction={ratio(ds.used, ds.total)}
|
||||
detail={formatBytePair(ds.used, ds.total, locale)}
|
||||
unknownLabel={unknown}
|
||||
locale={locale}
|
||||
/>
|
||||
{ds.lastBackupAt === null || backupAge === null ? (
|
||||
<p className="text-xs text-muted-foreground">{t('card.pbs.noBackupYet')}</p>
|
||||
) : (
|
||||
<p
|
||||
data-testid="last-backup"
|
||||
className={`text-xs ${stale ? `font-medium ${HEALTH_STYLE.warn.text}` : 'text-muted-foreground'}`}
|
||||
title={exactTime(ds.lastBackupAt, locale)}
|
||||
>
|
||||
{t('card.pbs.lastBackupAgo', { age: backupAge })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PmgMetricsView({
|
||||
metrics,
|
||||
t,
|
||||
locale,
|
||||
}: {
|
||||
metrics: ProxmoxPmgMetrics;
|
||||
t: Translator;
|
||||
locale: string;
|
||||
}) {
|
||||
const unknown = t('card.unknownValue');
|
||||
const tiles: Array<{ key: string; label: string; value: number | null; alarm: boolean }> = [
|
||||
{ key: 'in', label: t('card.pmg.countIn'), value: metrics.countIn, alarm: false },
|
||||
{ key: 'out', label: t('card.pmg.countOut'), value: metrics.countOut, alarm: false },
|
||||
{ key: 'spam', label: t('card.pmg.spamCount'), value: metrics.spamCount, alarm: false },
|
||||
{
|
||||
key: 'virus',
|
||||
label: t('card.pmg.virusCount'),
|
||||
value: metrics.virusCount,
|
||||
alarm: isKnown(metrics.virusCount) && metrics.virusCount > 0,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{tiles.map((tile) => (
|
||||
<div key={tile.key} data-testid={`pmg-${tile.key}`} className={`${WELL} px-4 py-3`}>
|
||||
<div className="text-xs text-muted-foreground">{tile.label}</div>
|
||||
{isKnown(tile.value) ? (
|
||||
<div
|
||||
className={`mt-1 text-xl font-semibold leading-tight tabular-nums ${tile.alarm ? HEALTH_STYLE.down.text : ''}`}
|
||||
>
|
||||
{formatCount(tile.value, locale)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-sm leading-7 text-muted-foreground">{unknown}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PmgMetricsView({ metrics, unknown }: { metrics: ProxmoxPmgMetrics; unknown: string }) {
|
||||
const t = useTranslations('proxmox');
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>
|
||||
{t('card.pmg.countIn')}: {formatMetric(metrics.countIn, unknown)}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pmg.countOut')}: {formatMetric(metrics.countOut, unknown)}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pmg.spamCount')}: {formatMetric(metrics.spamCount, unknown)}
|
||||
</div>
|
||||
<div>
|
||||
{t('card.pmg.virusCount')}: {formatMetric(metrics.virusCount, unknown)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsView({ metrics, unknown }: MetricsViewProps) {
|
||||
if (metrics.productType === 'pve') return <PveMetricsView metrics={metrics} unknown={unknown} />;
|
||||
if (metrics.productType === 'pbs') return <PbsMetricsView metrics={metrics} unknown={unknown} />;
|
||||
return <PmgMetricsView metrics={metrics} unknown={unknown} />;
|
||||
}
|
||||
|
||||
function errorMessage(t: ReturnType<typeof useTranslations>, kind: ProxmoxErrorKind | null): string {
|
||||
function errorMessage(t: Translator, kind: ProxmoxErrorKind | null): string {
|
||||
return t(`errors.${kind ?? 'unbekannt'}`);
|
||||
}
|
||||
|
||||
interface ServerCardProps {
|
||||
server: ProxmoxServer;
|
||||
isAdmin?: boolean;
|
||||
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeige EINES Servers (Aufgabe 6) — verzweigt ueber `productType` auf der
|
||||
* unterscheidbaren Union aus Aufgabe 3.
|
||||
* Anzeige EINES Servers (quick-260924-h7x). Die Tiefe entsteht ueber den
|
||||
* Status: Leiste links, im Statuston getoenter Schatten, Pille oben rechts.
|
||||
* Messwerte liegen in eingelassenen Feldern. Ein deaktivierter Server
|
||||
* („offline & verwaist“) zeigt KEINE alten Messwerte mehr — auch wenn das
|
||||
* Zwischenlager noch welche hat.
|
||||
*/
|
||||
export function ServerCard({ server, isAdmin = false }: ServerCardProps) {
|
||||
export function ServerCard({ server, isAdmin = false, now = Date.now() }: ServerCardProps) {
|
||||
const t = useTranslations('proxmox');
|
||||
const unknown = t('card.unknownValue');
|
||||
const locale = useLocale();
|
||||
const health = serverHealth(server, now);
|
||||
const style = HEALTH_STYLE[health];
|
||||
const status = server.status;
|
||||
const isOrphan = health === 'orphan';
|
||||
const polledAge = formatAge(status?.lastPolledAt ?? null, now, locale);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{server.name}</div>
|
||||
{/* Nachbesserung Befund 3: `uppercase` gilt nur dem Produktkuerzel, nicht der Adresse. */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="uppercase">{server.productType}</span> — {server.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{t('card.lastPolledLabel')}: {formatTimestamp(status?.lastPolledAt ?? null, unknown)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Nachbesserung Befund 2: ein Server, der noch nie abgefragt wurde
|
||||
(`lastPolledAt === null`), zeigt einen ruhigen Hinweis statt der
|
||||
Fehlermeldung — die leere Zwischenlagerzeile aus `createServer` hat
|
||||
`reachable: false` und `errorKind: null`, was sonst faelschlich als
|
||||
"unbekannter Fehler" erschien. Nicht-Admins sehen den Knopf nicht
|
||||
(der Poll-Endpunkt verlangt ADMIN/SUPER_ADMIN) und bekommen deshalb
|
||||
den Text ohne Knopfverweis (260923-le6).
|
||||
*/}
|
||||
{status && !status.lastPolledAt && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{isAdmin ? t('card.notPolledYet', { refreshLabel: t('card.refresh') }) : t('card.notPolledYetAutomatic')}
|
||||
let body: React.ReactNode = null;
|
||||
if (health === 'orphan') {
|
||||
body = (
|
||||
<div className="space-y-1 text-sm" data-testid="orphan-notice">
|
||||
<p className="text-foreground">{t('card.orphanText')}</p>
|
||||
{isAdmin && (
|
||||
<p className="text-muted-foreground">
|
||||
{t('card.orphanAdminHint')}{' '}
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
{t('card.settingsLink')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.lastPolledAt && !status.reachable && (
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p className="text-destructive">
|
||||
{errorMessage(t, status.errorKind)}
|
||||
{status.errorDetail ? ` (${status.errorDetail})` : ''}
|
||||
</p>
|
||||
{status.lastOkAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('card.lastOkLabel')}: {formatTimestamp(status.lastOkAt, unknown)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.reachable && status.metrics && (
|
||||
<div className="mt-3">
|
||||
<MetricsView metrics={status.metrics} unknown={unknown} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (health === 'idle') {
|
||||
// Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt
|
||||
// ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis
|
||||
// (260923-le6).
|
||||
body = (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isAdmin
|
||||
? t('card.notPolledYet', { refreshLabel: t('card.refresh') })
|
||||
: t('card.notPolledYetAutomatic')}
|
||||
</p>
|
||||
);
|
||||
} else if (health === 'down' && status) {
|
||||
const lastOkAge = formatAge(status.lastOkAt, now, locale);
|
||||
body = (
|
||||
<div
|
||||
data-testid="down-notice"
|
||||
className="space-y-1 rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3"
|
||||
>
|
||||
<p className="text-sm text-foreground">{errorMessage(t, status.errorKind)}</p>
|
||||
{status.errorDetail && (
|
||||
<p className="break-words text-xs text-muted-foreground">{status.errorDetail}</p>
|
||||
)}
|
||||
{lastOkAge && (
|
||||
<p className="text-xs text-muted-foreground" title={exactTime(status.lastOkAt, locale)}>
|
||||
{t('card.lastOkAgo', { age: lastOkAge })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (status?.metrics) {
|
||||
const metrics = status.metrics;
|
||||
body =
|
||||
metrics.productType === 'pve' ? (
|
||||
<PveMetricsView metrics={metrics} t={t} locale={locale} />
|
||||
) : metrics.productType === 'pbs' ? (
|
||||
<PbsMetricsView metrics={metrics} t={t} locale={locale} now={now} />
|
||||
) : (
|
||||
<PmgMetricsView metrics={metrics} t={t} locale={locale} />
|
||||
);
|
||||
}
|
||||
|
||||
// Gedaempft (`opacity-70 saturate-50`) wird bei „verwaist“ nur Symbol und
|
||||
// Name. Gedaempfte Schrift in `muted-foreground` fiele auf 2,75:1 (hell)
|
||||
// bzw. 3,0:1 (dunkel) — gemessen — und damit unter 4,5:1; Produktname,
|
||||
// Adresse, Fussangabe, Pille und Hinweistext bleiben deshalb voll lesbar.
|
||||
// Der Name in Vordergrundfarbe haelt gedaempft noch 7,5:1 bzw. 7,1:1.
|
||||
const dim = isOrphan ? 'opacity-70 saturate-50' : '';
|
||||
|
||||
return (
|
||||
<article
|
||||
data-testid="server-card"
|
||||
data-health={health}
|
||||
aria-label={`${server.name}: ${t(`health.${health}`)}`}
|
||||
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${
|
||||
isOrphan ? 'border-dashed border-border' : 'border-border'
|
||||
} ${style.shadow}`}
|
||||
>
|
||||
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 py-4 pl-5 pr-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className={`${WELL} flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground ${dim}`}
|
||||
>
|
||||
<ProductIcon productType={server.productType} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`truncate font-semibold leading-tight ${dim}`} title={server.name}>
|
||||
{server.name}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t(`card.product.${server.productType}`)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground" title={server.baseUrl}>
|
||||
{server.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill health={health} label={t(`health.${health}`)} />
|
||||
</div>
|
||||
|
||||
{body && <div className="flex-1">{body}</div>}
|
||||
|
||||
{polledAge && (
|
||||
<div
|
||||
data-testid="last-polled"
|
||||
className="mt-auto border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
title={exactTime(status?.lastPolledAt, locale)}
|
||||
>
|
||||
{t('card.lastPolledAgo', { age: polledAge })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import { HealthBar } from '@/components/proxmox/HealthBar';
|
||||
import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status';
|
||||
import { ServerCard } from './components/ServerCard';
|
||||
import { WELL } from '@/components/proxmox/status-styles';
|
||||
|
||||
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
||||
const CLOCK_TICK_MS = 60_000;
|
||||
|
||||
function RefreshIcon({ spinning }: { spinning: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={spinning ? 'animate-spin motion-reduce:animate-none' : ''}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||
<polyline points="21 3 21 8 16 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 pl-5 shadow-[0_1px_2px_oklch(0_0_0/0.06)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3.5 w-1/3 rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-6 w-20 rounded-full bg-muted" />
|
||||
</div>
|
||||
<div className="mt-4 h-16 rounded-lg bg-muted" />
|
||||
<div className="mt-2 h-12 rounded-lg bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das
|
||||
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
|
||||
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
|
||||
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt. Der Knopf
|
||||
* erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN,
|
||||
* SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6).
|
||||
* Modulseite (quick-260924-h7x, vorher 260923-dhh Aufgabe 6) — liest
|
||||
* ausschliesslich aus dem Zwischenlager, das `GET servers` liefert; kein
|
||||
* Live-Zugriff bei Proxmox von hier aus (D-05).
|
||||
*
|
||||
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
|
||||
* (down, warn, ok, idle, orphan, darin `position`). „Jetzt aktualisieren“
|
||||
* loest je Server eine Abfrage aus und laedt danach neu; der Knopf erscheint
|
||||
* nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)`
|
||||
* verlangt (260923-le6).
|
||||
*/
|
||||
export default function ProxmoxPage() {
|
||||
const t = useTranslations('proxmox');
|
||||
@@ -23,10 +70,14 @@ export default function ProxmoxPage() {
|
||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const reload = useCallback(() => {
|
||||
listServers()
|
||||
.then(setServers)
|
||||
.then((list) => {
|
||||
setServers(list);
|
||||
setNow(Date.now());
|
||||
})
|
||||
.catch(() => setError(t('loadError')));
|
||||
}, [t]);
|
||||
|
||||
@@ -34,6 +85,11 @@ export default function ProxmoxPage() {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!servers || servers.length === 0) return;
|
||||
setIsRefreshing(true);
|
||||
@@ -45,63 +101,102 @@ export default function ProxmoxPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => (servers ? sortServersByHealth(servers, now) : []), [servers, now]);
|
||||
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && servers !== null && servers.length > 0 && (
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{t('card.settingsNav')}
|
||||
</Link>
|
||||
{servers !== null && servers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<RefreshIcon spinning={isRefreshing} />
|
||||
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t('card.settingsLink')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{error && (
|
||||
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && servers === null && (
|
||||
<p className="text-sm text-muted-foreground">{t('loading')}</p>
|
||||
<div
|
||||
aria-busy="true"
|
||||
className="grid animate-pulse gap-4 motion-reduce:animate-none lg:grid-cols-2"
|
||||
>
|
||||
<span className="sr-only">{t('loading')}</span>
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && servers !== null && servers.length === 0 && (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
||||
{t('emptyState')}
|
||||
<div className={`${WELL} flex flex-col items-center gap-3 px-6 py-10 text-center`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
|
||||
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
|
||||
</svg>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{t('emptyState')}</p>
|
||||
{isAdmin && (
|
||||
<>
|
||||
{' '}
|
||||
<Link href="/modules/proxmox/settings" className="text-primary hover:underline">
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{t('card.settingsLink')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && servers !== null && servers.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{servers.map((server) => (
|
||||
<li key={server.id}>
|
||||
<ServerCard server={server} isAdmin={isAdmin} />
|
||||
<>
|
||||
<HealthBar counts={counts} />
|
||||
{/* Spaltenfluss statt Zeilenraster: eine kurze Karte (z. B. "nicht
|
||||
erreichbar") liesse im Raster eine Luecke bis zur hoeheren
|
||||
Nachbarkarte; so rueckt die naechste Karte direkt nach. */}
|
||||
<ul className="gap-4 lg:columns-2">
|
||||
{sorted.map((server) => (
|
||||
<li key={server.id} className="mb-4 min-w-0 break-inside-avoid">
|
||||
<ServerCard server={server} isAdmin={isAdmin} now={now} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
// Echter next-intl-Provider mit den deutschen Texten (quick-260924-h7x) —
|
||||
// ServerCard braucht `useLocale` und ICU-Plural.
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
|
||||
// "Jetzt aktualisieren" ruft `pollServer` je Server.
|
||||
@@ -26,34 +39,6 @@ vi.mock('next/link', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
|
||||
const translations: Record<string, string> = {
|
||||
title: 'Proxmox',
|
||||
description: 'Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick.',
|
||||
loading: 'Lade Serverliste...',
|
||||
loadError: 'Die Serverliste konnte nicht geladen werden.',
|
||||
emptyState:
|
||||
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
|
||||
'card.refresh': 'Jetzt aktualisieren',
|
||||
'card.refreshing': 'Wird aktualisiert...',
|
||||
'card.settingsLink': 'Zu den Einstellungen',
|
||||
'card.unknownValue': 'unbekannt',
|
||||
'card.lastPolledLabel': 'Letzte Abfrage',
|
||||
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
|
||||
'card.notPolledYetAutomatic':
|
||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||
};
|
||||
let result = translations[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
||||
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
|
||||
selector({ user }),
|
||||
@@ -176,3 +161,119 @@ describe('ProxmoxPage role gating (260923-le6)', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Ein erreichbarer, abgefragter Server; Zustand ueber die Ueberschreibungen.
|
||||
function makePolledServer(
|
||||
overrides: Partial<ProxmoxServer> = {},
|
||||
statusOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const base = makeUnpolledServer(overrides);
|
||||
return {
|
||||
...base,
|
||||
status: {
|
||||
...base.status,
|
||||
lastPolledAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||
lastOkAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||
reachable: true,
|
||||
...statusOverrides,
|
||||
},
|
||||
} as ProxmoxServer;
|
||||
}
|
||||
|
||||
describe('ProxmoxPage — Gesundheitsbalken und Sortierung (quick-260924-h7x)', () => {
|
||||
const servers = [
|
||||
makePolledServer({ id: 'a', name: 'ok-server', position: 0 }),
|
||||
makePolledServer(
|
||||
{ id: 'b', name: 'down-server', position: 1 },
|
||||
{ reachable: false, errorKind: 'netz' },
|
||||
),
|
||||
makePolledServer({ id: 'c', name: 'orphan-server', position: 2, isActive: false }),
|
||||
makePolledServer(
|
||||
{ id: 'd', name: 'warn-server', position: 3, productType: 'pmg' },
|
||||
{ metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 0, virusCount: 2 } },
|
||||
),
|
||||
makeUnpolledServer({ id: 'e', name: 'idle-server', position: 4 }),
|
||||
];
|
||||
|
||||
it('Karten stehen in der Reihenfolge down, warn, ok, idle, orphan', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
mockListServers.mockResolvedValue(servers);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
await screen.findByText('ok-server');
|
||||
|
||||
const order = screen.getAllByTestId('server-card').map((c) => c.getAttribute('data-health'));
|
||||
expect(order).toEqual(['down', 'warn', 'ok', 'idle', 'orphan']);
|
||||
});
|
||||
|
||||
it('der Balken liest die Zusammenfassung vor und hat je belegtem Zustand ein Segment', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
mockListServers.mockResolvedValue(servers);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
await screen.findByText('ok-server');
|
||||
|
||||
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
|
||||
expect(bar).toHaveAccessibleName(
|
||||
'Zustand der Server: 1 nicht erreichbar, 1 mit Warnung, 1 in Ordnung, 1 noch nicht abgefragt, 1 offline & verwaist',
|
||||
);
|
||||
expect(Array.from(bar.children).map((s) => s.getAttribute('data-segment'))).toEqual([
|
||||
'down',
|
||||
'warn',
|
||||
'ok',
|
||||
'idle',
|
||||
'orphan',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leere Zustaende entfallen in Balken und Legende', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
mockListServers.mockResolvedValue([
|
||||
makePolledServer({ id: 'a', name: 'eins', position: 0 }),
|
||||
makePolledServer({ id: 'b', name: 'zwei', position: 1 }),
|
||||
]);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
await screen.findByText('eins');
|
||||
|
||||
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
|
||||
expect(bar).toHaveAccessibleName('Zustand der Server: 2 in Ordnung');
|
||||
expect(bar.children).toHaveLength(1);
|
||||
const legend = within(screen.getByTestId('health-bar')).getByRole('list', { hidden: true });
|
||||
expect(legend.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('waehrend des Ladens erscheinen Skelett-Karten ohne Daueranimation bei reduzierter Bewegung', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
mockListServers.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
const { container } = render(<ProxmoxPage />);
|
||||
|
||||
const busy = container.querySelector('[aria-busy="true"]');
|
||||
expect(busy).not.toBeNull();
|
||||
expect(busy).toHaveClass('animate-pulse', 'motion-reduce:animate-none');
|
||||
expect(screen.getByText('Lade Serverliste...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Einstellungen-Verweis nur fuer Admins', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
mockListServers.mockResolvedValue([makePolledServer()]);
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
await screen.findByText('pve-1');
|
||||
expect(screen.queryByRole('link', { name: 'Einstellungen' })).not.toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
mockUser({ role: 'ADMIN' });
|
||||
render(<ProxmoxPage />);
|
||||
await screen.findByText('pve-1');
|
||||
expect(screen.getByRole('link', { name: 'Einstellungen' })).toHaveAttribute(
|
||||
'href',
|
||||
'/modules/proxmox/settings',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWid
|
||||
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/proxmox-widget', () => ({ ProxmoxWidget: () => null }));
|
||||
|
||||
beforeEach(() => {
|
||||
catalogProps.accessibleModuleSlugs = undefined;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget
|
||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
|
||||
@@ -34,6 +35,7 @@ registerWidget('stopwatch', StopwatchWidget);
|
||||
registerWidget('favorites', FavoritesWidget);
|
||||
registerWidget('picture-frame', PictureFrameWidget);
|
||||
registerWidget('xframe', XframeWidget);
|
||||
registerWidget('proxmox', ProxmoxWidget);
|
||||
|
||||
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
||||
interface ActiveModule {
|
||||
@@ -114,9 +116,10 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="relative p-2">
|
||||
{/* Reiterleiste (quick-260923-ad9) — bleibt waehrend eines
|
||||
Reiterwechsels stehen, nur das Raster darunter zeigt eine kurze
|
||||
Ladezeile. */}
|
||||
{/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
|
||||
in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
|
||||
waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
|
||||
kurze Ladezeile. */}
|
||||
<DashboardTabs
|
||||
dashboards={dashboards}
|
||||
activeDashboardId={activeDashboardId}
|
||||
|
||||
@@ -44,6 +44,19 @@
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
/* Statusfarben (quick-260924-h7x): Flaechen (Balken, Punkte, Leisten) und
|
||||
die abgedunkelte bzw. aufgehellte Textvariante `-fg` fuer Schrift. */
|
||||
--color-status-ok: var(--status-ok);
|
||||
--color-status-warn: var(--status-warn);
|
||||
--color-status-down: var(--status-down);
|
||||
--color-status-idle: var(--status-idle);
|
||||
--color-status-orphan: var(--status-orphan);
|
||||
--color-status-ok-fg: var(--status-ok-fg);
|
||||
--color-status-warn-fg: var(--status-warn-fg);
|
||||
--color-status-down-fg: var(--status-down-fg);
|
||||
--color-status-idle-fg: var(--status-idle-fg);
|
||||
--color-status-orphan-fg: var(--status-orphan-fg);
|
||||
--color-well: var(--well);
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
@@ -83,6 +96,24 @@
|
||||
--sidebar-accent: oklch(0.93 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.25 0 0);
|
||||
--sidebar-border: oklch(0.90 0 0);
|
||||
|
||||
/* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem
|
||||
Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis
|
||||
4,9:1 (gemessen, WCAG-Formel). Fuer Text gibt es deshalb die `-fg`-
|
||||
Variante, gemessen auf --card: ok 5,6:1, warn 5,7:1, down 6,1:1,
|
||||
idle/orphan 6,0:1 (auf der Pille mit 12 % Statusflaeche >= 4,9:1). */
|
||||
--status-ok: oklch(0.62 0.15 152);
|
||||
--status-warn: oklch(0.72 0.15 70);
|
||||
--status-down: oklch(0.58 0.21 27);
|
||||
--status-idle: oklch(0.62 0.02 260);
|
||||
--status-orphan: oklch(0.55 0.01 260);
|
||||
--status-ok-fg: oklch(0.50 0.13 152);
|
||||
--status-warn-fg: oklch(0.52 0.12 60);
|
||||
--status-down-fg: oklch(0.52 0.20 27);
|
||||
--status-idle-fg: oklch(0.50 0.02 260);
|
||||
--status-orphan-fg: oklch(0.50 0.01 260);
|
||||
/* Eingelassene Messfelder. */
|
||||
--well: oklch(0.975 0.003 260);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -110,6 +141,20 @@
|
||||
--sidebar-accent: oklch(0.91 0.19 102 / 0.15);
|
||||
--sidebar-accent-foreground: oklch(0.91 0.19 102);
|
||||
--sidebar-border: oklch(0.30 0.01 260);
|
||||
|
||||
/* Statusfarben (dark). Textvariante auf --card gemessen: ok 7,4:1,
|
||||
warn 8,5:1, down 6,1:1, idle 6,1:1, orphan 5,6:1. */
|
||||
--status-ok: oklch(0.72 0.15 152);
|
||||
--status-warn: oklch(0.80 0.14 75);
|
||||
--status-down: oklch(0.68 0.19 27);
|
||||
--status-idle: oklch(0.60 0.02 260);
|
||||
--status-orphan: oklch(0.52 0.01 260);
|
||||
--status-ok-fg: oklch(0.76 0.15 152);
|
||||
--status-warn-fg: oklch(0.82 0.14 75);
|
||||
--status-down-fg: oklch(0.74 0.16 27);
|
||||
--status-idle-fg: oklch(0.72 0.02 260);
|
||||
--status-orphan-fg: oklch(0.70 0.01 260);
|
||||
--well: oklch(0.235 0.01 260);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das
|
||||
@@ -90,8 +90,17 @@ function defaultProps(overrides: Partial<Parameters<typeof DashboardTabs>[0]> =
|
||||
};
|
||||
}
|
||||
|
||||
// Seit quick-260924-h7x rendert die Leiste per Portal in den Einhaengepunkt
|
||||
// der Kopfzeile — der Test legt ihn so an, wie `header.tsx` ihn rendert.
|
||||
beforeEach(() => {
|
||||
const slot = document.createElement('div');
|
||||
slot.id = 'header-center-slot';
|
||||
document.body.appendChild(slot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.getElementById('header-center-slot')?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -99,24 +108,27 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
|
||||
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
|
||||
render(<DashboardTabs {...defaultProps()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ onSelect })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('d2');
|
||||
});
|
||||
|
||||
it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => {
|
||||
it('Test 3: der aktive Reiter traegt aria-selected=true, der andere false', () => {
|
||||
render(<DashboardTabs {...defaultProps()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current');
|
||||
expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => {
|
||||
@@ -171,7 +183,7 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
|
||||
expect(onRename).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
|
||||
@@ -219,7 +231,7 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
|
||||
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
|
||||
expect(onSelect).toHaveBeenCalledWith('d2');
|
||||
});
|
||||
|
||||
@@ -261,14 +273,16 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
|
||||
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
|
||||
|
||||
// Vorschau: d2 steht jetzt vor d1 im DOM.
|
||||
const buttons = screen.getAllByRole('button').filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
|
||||
const buttons = screen
|
||||
.getAllByRole('tab')
|
||||
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
|
||||
expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']);
|
||||
|
||||
fireEvent.pointerCancel(tab, { pointerId: 1 });
|
||||
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
const buttonsAfterCancel = screen
|
||||
.getAllByRole('button')
|
||||
.getAllByRole('tab')
|
||||
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
|
||||
expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']);
|
||||
});
|
||||
@@ -289,13 +303,17 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
|
||||
|
||||
it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => {
|
||||
stubTabRects();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })} />);
|
||||
render(
|
||||
<DashboardTabs
|
||||
{...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })}
|
||||
/>,
|
||||
);
|
||||
const tab = screen.getByTestId('dashboard-tab-d1');
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
|
||||
expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('Test 19: nach einem echten Ziehen unterdrueckt der naechste Klick die Auswahl EINMAL, danach funktioniert Klicken wieder normal', () => {
|
||||
@@ -311,16 +329,18 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
|
||||
expect(onReorder).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' }));
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' }));
|
||||
expect(onSelect).toHaveBeenCalledWith('d1');
|
||||
});
|
||||
|
||||
it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => {
|
||||
const { rerender } = render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS })} />);
|
||||
expect(screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
|
||||
expect(
|
||||
@@ -331,7 +351,9 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
|
||||
it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => {
|
||||
stubTabRects();
|
||||
const onReorder = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />);
|
||||
render(
|
||||
<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />,
|
||||
);
|
||||
const tab = screen.getByTestId('dashboard-tab-d1');
|
||||
|
||||
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
|
||||
@@ -341,3 +363,72 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
|
||||
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardTabs — in der Kopfzeile (quick-260924-h7x)', () => {
|
||||
it('Test 22: rendert in den Einhaengepunkt der Kopfzeile, nicht an Ort und Stelle', () => {
|
||||
const { container } = render(<DashboardTabs {...defaultProps()} />);
|
||||
|
||||
const slot = document.getElementById('header-center-slot') as HTMLElement;
|
||||
expect(within(slot).getByRole('navigation', { name: 'Dashboard-Reiter' })).toBeInTheDocument();
|
||||
expect(within(slot).getByRole('tablist')).toBeInTheDocument();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('Test 23: ohne Einhaengepunkt rendert die Leiste gar nichts', () => {
|
||||
document.getElementById('header-center-slot')?.remove();
|
||||
render(<DashboardTabs {...defaultProps()} />);
|
||||
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 24: Pfeiltasten wandern zwischen den Reitern (mit Umlauf), ohne zu waehlen; nur der aktive ist per Tab erreichbar', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onSelect })} />);
|
||||
const [t1, t2, t3] = screen.getAllByRole('tab');
|
||||
|
||||
expect(t1).toHaveAttribute('tabindex', '0');
|
||||
expect(t2).toHaveAttribute('tabindex', '-1');
|
||||
expect(t3).toHaveAttribute('tabindex', '-1');
|
||||
|
||||
t1.focus();
|
||||
fireEvent.keyDown(t1, { key: 'ArrowRight' });
|
||||
expect(t2).toHaveFocus();
|
||||
fireEvent.keyDown(t2, { key: 'ArrowRight' });
|
||||
expect(t3).toHaveFocus();
|
||||
fireEvent.keyDown(t3, { key: 'ArrowRight' });
|
||||
expect(t1).toHaveFocus();
|
||||
fireEvent.keyDown(t1, { key: 'ArrowLeft' });
|
||||
expect(t3).toHaveFocus();
|
||||
fireEvent.keyDown(t3, { key: 'Home' });
|
||||
expect(t1).toHaveFocus();
|
||||
fireEvent.keyDown(t1, { key: 'End' });
|
||||
expect(t3).toHaveFocus();
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 25: genau ein Reiter — der Umschalter steht trotzdem, im Bearbeitungsmodus nur mit „+“ daneben', () => {
|
||||
render(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 26: „+“ sitzt ausserhalb der scrollenden Spur und bleibt so immer sichtbar', () => {
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
|
||||
|
||||
const tablist = screen.getByRole('tablist');
|
||||
expect(tablist).not.toContainElement(screen.getByLabelText('Dashboard hinzufügen'));
|
||||
});
|
||||
|
||||
it('Test 27: der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile', () => {
|
||||
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
const slot = document.getElementById('header-center-slot') as HTMLElement;
|
||||
expect(slot).not.toContainElement(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||
|
||||
interface DashboardTabsProps {
|
||||
@@ -15,6 +26,9 @@ interface DashboardTabsProps {
|
||||
onReorder: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
/** Breite der weichen Ausblendung an einem ueberlaufenden Rand. */
|
||||
const FADE_PX = 16;
|
||||
|
||||
/** Ab dieser waagerechten Auslenkung (Bildschirmpixel) wird aus einem Klick ein Ziehen (Task 4). */
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
@@ -63,7 +77,17 @@ function computeReorderedIds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3/4).
|
||||
* Dashboard-Reiter (quick-260923-ad9, Task 3/4; seit quick-260924-h7x in der
|
||||
* Kopfzeile).
|
||||
*
|
||||
* Die Leiste belegt keine eigene Zeile mehr: sie rendert per `createPortal`
|
||||
* in den Einhaengepunkt {@link HEADER_CENTER_SLOT_ID} der Kopfzeile, sobald
|
||||
* er im DOM ist — vorher rendert sie nichts. Gestaltung als kompakter
|
||||
* Umschalter: eingelassene Spur, der aktive Reiter liegt erhaben darauf.
|
||||
* Laufen die Reiter ueber, scrollt die Spur waagrecht ohne sichtbare Leiste
|
||||
* und blendet die Raender weich aus; der aktive Reiter wird ins Bild
|
||||
* gescrollt. Pfeiltasten links/rechts wandern zwischen den Reitern
|
||||
* (`role="tablist"`/`tab`), Eingabe oder Leertaste waehlt.
|
||||
*
|
||||
* Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus.
|
||||
* Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft)
|
||||
@@ -103,6 +127,61 @@ export function DashboardTabs({
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const hasDraggedRef = useRef(false);
|
||||
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const tabButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const hintId = useId();
|
||||
const [slot, setSlot] = useState<HTMLElement | null>(null);
|
||||
const [overflow, setOverflow] = useState({ left: false, right: false });
|
||||
|
||||
// Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben
|
||||
// Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM.
|
||||
useEffect(() => {
|
||||
setSlot(document.getElementById(HEADER_CENTER_SLOT_ID));
|
||||
}, []);
|
||||
|
||||
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
|
||||
const measureOverflow = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const left = el.scrollLeft > 1;
|
||||
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||||
setOverflow((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
|
||||
}, []);
|
||||
|
||||
// `slot` ist der Ausloeser: die Spur (scrollRef) existiert erst, wenn das
|
||||
// Portal in die Kopfzeile gezeichnet ist.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `slot` ist der Ausloeser, nicht Eingabe
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
measureOverflow();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(measureOverflow);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [measureOverflow, slot]);
|
||||
|
||||
// Nach jeder Aenderung der Reiter neu messen (neuer Reiter, umbenannt).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `dashboards` ist der Ausloeser, nicht Eingabe
|
||||
useEffect(() => {
|
||||
measureOverflow();
|
||||
}, [dashboards, measureOverflow]);
|
||||
|
||||
// Aktiven Reiter ins Bild scrollen — nur die Spur, nie die Seite.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `slot` ist der Ausloeser, nicht Eingabe
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
const tab = activeDashboardId ? tabRefs.current.get(activeDashboardId) : undefined;
|
||||
if (!container || !tab) return;
|
||||
const c = container.getBoundingClientRect();
|
||||
const r = tab.getBoundingClientRect();
|
||||
if (r.left < c.left) {
|
||||
container.scrollLeft -= c.left - r.left + FADE_PX;
|
||||
} else if (r.right > c.right) {
|
||||
container.scrollLeft += r.right - c.right + FADE_PX;
|
||||
}
|
||||
measureOverflow();
|
||||
}, [activeDashboardId, measureOverflow, slot]);
|
||||
|
||||
// Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber
|
||||
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
|
||||
@@ -204,14 +283,52 @@ export function DashboardTabs({
|
||||
setRenamingId(null);
|
||||
}
|
||||
|
||||
/** Pfeiltasten wandern zwischen den Reitern; gewaehlt wird mit Eingabe/Leertaste. */
|
||||
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
const count = orderedTabs.length;
|
||||
let next: number;
|
||||
if (e.key === 'ArrowRight') next = (index + 1) % count;
|
||||
else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = count - 1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
tabButtonRefs.current.get(orderedTabs[next].id)?.focus();
|
||||
}
|
||||
|
||||
const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label={t('tabs.navLabel')} className="mb-1 flex items-center gap-1 overflow-x-auto">
|
||||
if (!slot) return null;
|
||||
|
||||
const hasActive = orderedTabs.some((tab) => tab.id === activeDashboardId);
|
||||
const fadeMask =
|
||||
overflow.left || overflow.right
|
||||
? `linear-gradient(to right, ${overflow.left ? 'transparent 0' : 'black 0'}, black ${FADE_PX}px, black calc(100% - ${FADE_PX}px), ${overflow.right ? 'transparent 100%' : 'black 100%'})`
|
||||
: undefined;
|
||||
const maskStyle: CSSProperties | undefined = fadeMask
|
||||
? { maskImage: fadeMask, WebkitMaskImage: fadeMask }
|
||||
: undefined;
|
||||
|
||||
const bar = (
|
||||
<nav
|
||||
aria-label={t('tabs.navLabel')}
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5"
|
||||
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
|
||||
>
|
||||
{/* Eingelassene Spur (32 px): der aktive Reiter liegt erhaben darauf. */}
|
||||
<div className="h-8 min-w-0 max-w-full rounded-lg bg-muted p-0.5 shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)] md:max-w-[min(56vw,720px)]">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="tablist"
|
||||
aria-describedby={dashboards.length > 1 ? hintId : undefined}
|
||||
onScroll={measureOverflow}
|
||||
style={maskStyle}
|
||||
className="flex h-7 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{orderedTabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeDashboardId;
|
||||
const isRenaming = renamingId === tab.id;
|
||||
const isFocusable = isActive || (!hasActive && index === 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -222,7 +339,11 @@ export function DashboardTabs({
|
||||
}}
|
||||
data-tab-index={index}
|
||||
data-testid={`dashboard-tab-${tab.id}`}
|
||||
className="flex touch-none items-center"
|
||||
className={`flex h-7 shrink-0 touch-none items-center rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-card/60 hover:text-foreground'
|
||||
}`}
|
||||
onPointerDown={(e) => handlePointerDown(e, tab.id)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@@ -245,17 +366,22 @@ export function DashboardTabs({
|
||||
}}
|
||||
maxLength={40}
|
||||
aria-label={t('tabs.renameInputLabel')}
|
||||
className="h-8 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
|
||||
className="h-6 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) tabButtonRefs.current.set(tab.id, el);
|
||||
else tabButtonRefs.current.delete(tab.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={isFocusable ? 0 : -1}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
className={`h-8 cursor-grab rounded-t px-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border border-b-transparent border-border bg-card text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||
className={`h-7 cursor-grab whitespace-nowrap rounded-md px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
isActive ? 'font-medium' : ''
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
@@ -268,13 +394,13 @@ export function DashboardTabs({
|
||||
onClick={() => startRename(tab)}
|
||||
aria-label={t('tabs.renameButtonLabel')}
|
||||
title={t('tabs.renameButtonLabel')}
|
||||
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="-ml-1.5 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -293,13 +419,13 @@ export function DashboardTabs({
|
||||
onClick={() => setPendingDeleteId(tab.id)}
|
||||
aria-label={t('tabs.deleteButtonLabel')}
|
||||
title={t('tabs.deleteButtonLabel')}
|
||||
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
|
||||
className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -315,20 +441,25 @@ export function DashboardTabs({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* „+“ sitzt AUSSERHALB der scrollenden Spur und bleibt deshalb auch
|
||||
bei vielen Reitern immer sichtbar — wie bisher nur im
|
||||
Bearbeitungsmodus. */}
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
aria-label={t('tabs.add')}
|
||||
title={t('tabs.add')}
|
||||
className="ml-1 flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -342,8 +473,21 @@ export function DashboardTabs({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pendingDeleteTab && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Hinweis zum Ziehen: frueher eine eigene Zeile unter der Leiste, jetzt
|
||||
fuer Vorleseprogramme an der Spur und als Tooltip der Leiste. */}
|
||||
{dashboards.length > 1 && (
|
||||
<span id={hintId} className="sr-only">
|
||||
{t('tabs.dragHint')}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
|
||||
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
|
||||
// sonst saesse er in deren Stapelkontext.
|
||||
const dialog = pendingDeleteTab
|
||||
? createPortal(
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
@@ -354,7 +498,7 @@ export function DashboardTabs({
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('tabs.deleteDialogTitle')}
|
||||
className="relative z-50 mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
className="relative z-[60] mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
{t('tabs.deleteDialogTitle')}
|
||||
@@ -382,12 +526,15 @@ export function DashboardTabs({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
{dashboards.length > 1 && (
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t('tabs.dragHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{createPortal(bar, slot)}
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ vi.mock('next-intl', () => ({
|
||||
'pictureFrame.description': 'Bilder als Diashow',
|
||||
'xframe.name': 'XFrame',
|
||||
'xframe.description': 'Webseite einbetten',
|
||||
'proxmox.name': 'Proxmox',
|
||||
'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick',
|
||||
},
|
||||
common: {
|
||||
close: 'Schließen',
|
||||
@@ -40,8 +42,9 @@ import { WIDGET_TYPES } from '@tessera/shared';
|
||||
import { WIDGET_REGISTRY } from './widget-registry';
|
||||
import { WidgetCatalogModal } from './widget-catalog-modal';
|
||||
|
||||
// Alle neun Kacheln sind heute Plattform-Kacheln ohne moduleSlug, also zeigt
|
||||
// der Katalog sie auch bei leerer Modulliste vollstaendig an.
|
||||
// Neun Kacheln sind Plattform-Kacheln ohne moduleSlug und erscheinen auch bei
|
||||
// leerer Modulliste; die Proxmox-Kachel (quick-260924-i8v) nur mit Zugriff
|
||||
// auf das Modul 'proxmox'.
|
||||
const baseProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
@@ -141,44 +144,39 @@ describe('WidgetCatalogModal', () => {
|
||||
* sie aus der Registry ab und filtert nach Modulzugriff.
|
||||
*/
|
||||
describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => {
|
||||
it('zeigt alle neun Kacheln in der Reihenfolge der Registry', () => {
|
||||
render(<WidgetCatalogModal {...baseProps} />);
|
||||
|
||||
function catalogTypes(): (string | null)[] {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
|
||||
const cards = Array.from(
|
||||
return Array.from(
|
||||
dialog.querySelectorAll<HTMLButtonElement>('button[data-widget-type]'),
|
||||
);
|
||||
).map((c) => c.getAttribute('data-widget-type'));
|
||||
}
|
||||
|
||||
expect(cards.map((c) => c.getAttribute('data-widget-type'))).toEqual([
|
||||
...WIDGET_TYPES,
|
||||
]);
|
||||
it("zeigt mit Zugriff auf 'proxmox' alle zehn Kacheln in der Reihenfolge der Registry, Proxmox zuletzt", () => {
|
||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||
|
||||
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
|
||||
expect(catalogTypes().at(-1)).toBe('proxmox');
|
||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('eine Kachel MIT moduleSlug fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
||||
// Die Registry traegt heute keine Modul-Kachel — fuer den Nachweis am
|
||||
// echten Bauteil wird clock voruebergehend zu einer gemacht.
|
||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
||||
try {
|
||||
it('zeigt ohne Modulzugriff die neun Plattform-Kacheln ohne Proxmox', () => {
|
||||
render(<WidgetCatalogModal {...baseProps} />);
|
||||
|
||||
expect(catalogTypes()).toEqual(WIDGET_TYPES.filter((type) => type !== 'proxmox'));
|
||||
});
|
||||
|
||||
it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
||||
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
|
||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Proxmox/ })).toBeNull();
|
||||
|
||||
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||
expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument();
|
||||
} finally {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
||||
}
|
||||
expect(screen.getByRole('button', { name: /Proxmox/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fail-closed: schlaegt der Modulabruf fehl (null), verschwinden Kacheln MIT moduleSlug, Plattform-Kacheln bleiben', () => {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
||||
try {
|
||||
it('fail-closed: schlaegt der Modulabruf fehl (null), fehlt „Proxmox“, Plattform-Kacheln bleiben', () => {
|
||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Proxmox/ })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument();
|
||||
} finally {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
||||
'picture-frame',
|
||||
// XFrame (quick-260921-qd3)
|
||||
'xframe',
|
||||
// Proxmox — erste Modul-Kachel (quick-260924-i8v)
|
||||
'proxmox',
|
||||
];
|
||||
|
||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
@@ -64,7 +66,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
||||
});
|
||||
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu, neun Typen)', () => {
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => {
|
||||
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
|
||||
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
||||
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
||||
@@ -75,10 +77,11 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
|
||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||
proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 },
|
||||
});
|
||||
|
||||
let counted = 0;
|
||||
@@ -88,7 +91,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
expect(counted).toBe(36);
|
||||
expect(counted).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +107,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('die neun erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||
it('die zehn erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
|
||||
});
|
||||
|
||||
@@ -114,10 +117,14 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('heute traegt keine der neun Kacheln einen moduleSlug (alle sind Plattform-Kacheln)', () => {
|
||||
it("nur proxmox traegt moduleSlug 'proxmox', alle anderen Kacheln keinen (quick-260924-i8v)", () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
if (type === 'proxmox') {
|
||||
expect(WIDGET_REGISTRY[type].moduleSlug).toBe('proxmox');
|
||||
} else {
|
||||
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,15 +161,15 @@ describe('registerWidget (quick-260922-m1h)', () => {
|
||||
expect(() =>
|
||||
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
|
||||
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
|
||||
registerWidget('proxmox' as WidgetType, makeComponent()),
|
||||
).toThrow(/proxmox/);
|
||||
registerWidget('gibt-es-nicht' as WidgetType, makeComponent()),
|
||||
).toThrow(/gibt-es-nicht/);
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
|
||||
const previous = process.env.NODE_ENV;
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
|
||||
expect(() => registerWidget('proxmox' as WidgetType, makeComponent())).not.toThrow();
|
||||
expect(() => registerWidget('gibt-es-nicht' as WidgetType, makeComponent())).not.toThrow();
|
||||
|
||||
vi.stubEnv('NODE_ENV', previous ?? 'test');
|
||||
vi.unstubAllEnvs();
|
||||
@@ -182,8 +189,14 @@ describe('visibleWidgetTypes (quick-260922-m1h)', () => {
|
||||
note: {},
|
||||
};
|
||||
|
||||
it('behaelt die Reihenfolge der Registry bei', () => {
|
||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual([...WIDGET_TYPES]);
|
||||
it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlt nur Proxmox', () => {
|
||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual(
|
||||
WIDGET_TYPES.filter((type) => type !== 'proxmox'),
|
||||
);
|
||||
});
|
||||
|
||||
it("mit Zugriff auf 'proxmox' erscheinen alle zehn Kacheln in WIDGET_TYPES-Reihenfolge (quick-260924-i8v)", () => {
|
||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, ['proxmox'])).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
||||
* decken den Fall ab).
|
||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
|
||||
*/
|
||||
export type { WidgetType };
|
||||
|
||||
@@ -56,7 +57,10 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// SECHS Tastenreihen (%/CE/C/Loeschen, 1/x/x2/Wurzel/Division, 7-9, 4-6, 1-3, 0/Komma/=),
|
||||
// Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px.
|
||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
// Favoriten: minW 1 statt 3 (Nutzerwunsch 23.09.) — bei kurzen Linknamen
|
||||
// blieb rechts viel Leerraum. Der Titel kuerzt mit Auslassungszeichen,
|
||||
// in der schmalsten Stufe bleibt das Symbol als Leiste stehen.
|
||||
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
// quick-260921-pi9: ein Bild braucht Flaeche — 8x12 entspricht der
|
||||
// Kalender-Vorgabe (rund 530x330 px, Querformat wie ein Foto); 8x8 war im
|
||||
@@ -68,6 +72,11 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
|
||||
// Rahmen noch Sinn hat.
|
||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||
// quick-260924-i8v: 4 Zeilen = 104 px reichen genau fuer Balken und
|
||||
// Zusammenfassung (die Serverliste blendet sich darunter per Container-
|
||||
// Query aus); 8x8 = rund 456x216 px bei 1400 px Breite zeigt etwa sechs
|
||||
// Serverzeilen; 3 Spalten = rund 166 px zeigen nur Punkte und Namen.
|
||||
proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,7 +89,7 @@ export interface WidgetDefinition {
|
||||
/**
|
||||
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
|
||||
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel
|
||||
* und immer sichtbar — der heutige Zustand fuer alle neun Kacheln.
|
||||
* und immer sichtbar — der Zustand fuer alle Kacheln ausser Proxmox.
|
||||
*/
|
||||
moduleSlug?: string;
|
||||
/** Inline SVG icon as React component */
|
||||
@@ -314,6 +323,29 @@ function XframeIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProxmoxIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
|
||||
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all widget types. Used by the widget catalog modal
|
||||
* and the grid renderer to look up components and constraints.
|
||||
@@ -400,6 +432,17 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.xframe,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
proxmox: {
|
||||
type: 'proxmox',
|
||||
nameKey: 'proxmox.name',
|
||||
descriptionKey: 'proxmox.description',
|
||||
icon: ProxmoxIcon,
|
||||
...WIDGET_CONSTRAINTS.proxmox,
|
||||
// Erste Modul-Kachel: ohne Zugriff auf das Modul 'proxmox' fehlt sie im
|
||||
// Katalog (Komfort) und verbindlich serverseitig auf dem Dashboard.
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.proxmox,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,17 +6,31 @@ vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Mock favorites-api — all 5 functions as controllable vi.fn() spies
|
||||
// Mock favorites-api — all functions as controllable vi.fn() spies
|
||||
// (reorderFavorites hinzugefuegt, 260917-jdd — ohne diesen Eintrag
|
||||
// importiert das Widget undefined und der Klick auf einen Sortierpfeil
|
||||
// wirft TypeError.)
|
||||
vi.mock('@/lib/favorites-api', () => ({
|
||||
//
|
||||
// 260923-lrr: die Fabrik ist jetzt ASYNCHRON und uebernimmt per
|
||||
// `vi.importActual` die ECHTE `FavoriteRequestError`-Klasse und
|
||||
// `FAVORITE_ICON_MAX_BYTES` — ein `instanceof FavoriteRequestError` im
|
||||
// Widget muss gegen dieselbe Klasse pruefen, die die Tests werfen, sonst
|
||||
// faellt `favoriteErrorKey` immer auf `favorites.error` zurueck.
|
||||
// `uploadFavoriteIcon`/`removeFavoriteIcon` kommen als `vi.fn()` dazu.
|
||||
vi.mock('@/lib/favorites-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/favorites-api')>('@/lib/favorites-api');
|
||||
return {
|
||||
FavoriteRequestError: actual.FavoriteRequestError,
|
||||
FAVORITE_ICON_MAX_BYTES: actual.FAVORITE_ICON_MAX_BYTES,
|
||||
fetchFavorites: vi.fn(),
|
||||
createFavorite: vi.fn(),
|
||||
updateFavorite: vi.fn(),
|
||||
deleteFavorite: vi.fn(),
|
||||
reorderFavorites: vi.fn(),
|
||||
}));
|
||||
uploadFavoriteIcon: vi.fn(),
|
||||
removeFavoriteIcon: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
|
||||
vi.mock('@/lib/dashboard-api', () => ({
|
||||
@@ -24,11 +38,14 @@ vi.mock('@/lib/dashboard-api', () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
FavoriteRequestError,
|
||||
fetchFavorites,
|
||||
createFavorite,
|
||||
updateFavorite,
|
||||
deleteFavorite,
|
||||
reorderFavorites,
|
||||
uploadFavoriteIcon,
|
||||
removeFavoriteIcon,
|
||||
} from '@/lib/favorites-api';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
|
||||
@@ -41,6 +58,8 @@ const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockReorder = reorderFavorites as ReturnType<typeof vi.fn>;
|
||||
const mockUploadIcon = uploadFavoriteIcon as ReturnType<typeof vi.fn>;
|
||||
const mockRemoveIcon = removeFavoriteIcon as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType<typeof vi.fn>;
|
||||
|
||||
const BASE_FAVORITES = [
|
||||
@@ -450,7 +469,8 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
|
||||
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon');
|
||||
// 260923-lrr: die Proxy-Adresse traegt jetzt ?v=<iconVersion> (Cache-Bust).
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=0');
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
@@ -573,4 +593,288 @@ describe('FavoritesWidget', () => {
|
||||
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
// 260923-lrr: eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
|
||||
// erneuern (versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten
|
||||
// und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular).
|
||||
describe('Eigenes Symbol (quick-260923-lrr)', () => {
|
||||
function openEditForGitHub() {
|
||||
const editButtons = screen.getAllByRole('button', { name: /favorites\.editButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(editButtons[0]);
|
||||
});
|
||||
}
|
||||
|
||||
it('Proxy-Bild traegt ?v=<iconVersion>; Zeile ohne iconVersion -> ?v=0', async () => {
|
||||
mockFetch.mockResolvedValue([{ ...BASE_FAVORITES[0], iconVersion: 3 }, BASE_FAVORITES[1]]);
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=3');
|
||||
});
|
||||
|
||||
it('Zeile nur mit uploadedIconMime (iconUrl null) -> Proxy-Bild statt Direktbild', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{
|
||||
id: 'fav-id-4',
|
||||
widgetId: 'fav-1',
|
||||
title: 'Eigenes',
|
||||
url: 'https://eigenes.invalid',
|
||||
iconUrl: null,
|
||||
uploadedIconMime: 'image/png',
|
||||
iconVersion: 1,
|
||||
position: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Eigenes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('icon-proxy-fav-id-4')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Logo-Adress-Feld zeigt den Platzhalter favorites.iconUrlPlaceholder (kein fest verdrahteter Text mehr)', async () => {
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
expect(screen.getByPlaceholderText('favorites.iconUrlPlaceholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Speichern mit neuer Logo-Adresse, updateFavorite liefert iconVersion 1 -> src des Proxy-Bildes endet danach auf ?v=1', async () => {
|
||||
mockUpdate.mockResolvedValue({
|
||||
...BASE_FAVORITES[0],
|
||||
iconUrl: 'https://neu.invalid/icon.png',
|
||||
iconVersion: 1,
|
||||
});
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const iconUrlField = screen.getByPlaceholderText('favorites.iconUrlPlaceholder');
|
||||
act(() => {
|
||||
fireEvent.change(iconUrlField, { target: { value: 'https://neu.invalid/icon.png' } });
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'fav-id-1',
|
||||
expect.objectContaining({ iconUrl: 'https://neu.invalid/icon.png' }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=1');
|
||||
});
|
||||
});
|
||||
|
||||
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => {
|
||||
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
|
||||
mockUploadIcon.mockResolvedValue({
|
||||
...BASE_FAVORITES[0],
|
||||
uploadedIconMime: 'image/png',
|
||||
iconVersion: 2,
|
||||
});
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' });
|
||||
act(() => {
|
||||
fireEvent.change(fileField, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('favorite-icon-upload-fav-id-1')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
|
||||
mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable'));
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
||||
const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' });
|
||||
act(() => {
|
||||
fireEvent.change(fileField, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable');
|
||||
});
|
||||
expect(mockUploadIcon).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Upload wirft FavoriteRequestError(iconTooLarge) -> favorites.iconTooLarge im Formular, Formular bleibt offen', async () => {
|
||||
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0] });
|
||||
mockUploadIcon.mockRejectedValue(new FavoriteRequestError('iconTooLarge'));
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
||||
const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' });
|
||||
act(() => {
|
||||
fireEvent.change(fileField, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconTooLarge');
|
||||
});
|
||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
|
||||
BASE_FAVORITES[1],
|
||||
]);
|
||||
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 });
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const removeButton = screen.getByRole('button', { name: 'favorites.iconRemoveButton' });
|
||||
act(() => {
|
||||
fireEvent.click(removeButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('kein Entfernen-Knopf ohne hochgeladenes Symbol', async () => {
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
|
||||
mockFetch.mockResolvedValue([]);
|
||||
mockUploadIcon.mockResolvedValue({
|
||||
id: 'fav-id-new',
|
||||
widgetId: 'fav-1',
|
||||
title: 'New Link',
|
||||
url: 'https://example.com',
|
||||
iconUrl: null,
|
||||
uploadedIconMime: 'image/png',
|
||||
iconVersion: 1,
|
||||
position: 2,
|
||||
});
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleInput = screen.getByPlaceholderText('favorites.addTitle');
|
||||
const urlInput = screen.getByPlaceholderText('favorites.addUrl');
|
||||
const fileInput = screen.getByTestId('favorite-add-icon-upload');
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' });
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(titleInput, { target: { value: 'New Link' } });
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /favorites\.addButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(addButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-new', file);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,14 @@ import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import {
|
||||
FavoriteRequestError,
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
fetchFavorites,
|
||||
removeFavoriteIcon,
|
||||
reorderFavorites,
|
||||
updateFavorite,
|
||||
uploadFavoriteIcon,
|
||||
type FavoriteLink,
|
||||
} from '@/lib/favorites-api';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
@@ -16,6 +19,18 @@ import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
|
||||
const TITLE_DEBOUNCE_MS = 1500;
|
||||
|
||||
/**
|
||||
* Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs-
|
||||
* schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden
|
||||
* Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/
|
||||
* `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf
|
||||
* die bisherige allgemeine Meldung zurueck.
|
||||
*/
|
||||
function favoriteErrorKey(err: unknown): string {
|
||||
if (err instanceof FavoriteRequestError) return `favorites.${err.reason}`;
|
||||
return 'favorites.error';
|
||||
}
|
||||
|
||||
/**
|
||||
* FavoritesWidget — persistent per-widget list of favorite links (DASH-09).
|
||||
*
|
||||
@@ -79,12 +94,21 @@ export function FavoritesWidget({
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [newIconUrl, setNewIconUrl] = useState('');
|
||||
// 260923-lrr: gewaehlte Datei fuer das Hinzufuegen-Formular; der Ref setzt
|
||||
// das native Datei-Feld nach dem Anlegen zurueck (unkontrolliertes Feld).
|
||||
const [newIconFile, setNewIconFile] = useState<File | null>(null);
|
||||
const newIconFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Inline edit state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editUrl, setEditUrl] = useState('');
|
||||
const [editIconUrl, setEditIconUrl] = useState('');
|
||||
// 260923-lrr: gewaehlte Datei, Fehlermeldung und Sperre des Bearbeitungs-
|
||||
// formulars — eigenes Symbol hochladen/entfernen.
|
||||
const [editIconFile, setEditIconFile] = useState<File | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [editBusy, setEditBusy] = useState(false);
|
||||
|
||||
// Sorted favorites: position asc, then title asc
|
||||
const sortedFavorites = useMemo(
|
||||
@@ -164,6 +188,10 @@ export function FavoritesWidget({
|
||||
}
|
||||
|
||||
// --- Add favorite ---
|
||||
// 260923-lrr: nach createFavorite wird — falls im Hinzufuegen-Formular
|
||||
// eine Datei gewaehlt wurde — diese fuer die neue Kennung hochgeladen;
|
||||
// scheitert nur der Upload, bleibt der Favorit angelegt (mit der Zeile aus
|
||||
// createFavorite) und die Meldung erscheint ueber dem Formular.
|
||||
async function handleAdd(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const cleanTitle = newTitle.trim();
|
||||
@@ -179,12 +207,24 @@ export function FavoritesWidget({
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || undefined,
|
||||
});
|
||||
setFavorites((prev) => [...prev, created]);
|
||||
|
||||
let finalFavorite = created;
|
||||
if (newIconFile) {
|
||||
try {
|
||||
finalFavorite = await uploadFavoriteIcon(created.id, newIconFile);
|
||||
} catch (err) {
|
||||
setError(t(favoriteErrorKey(err)));
|
||||
}
|
||||
}
|
||||
|
||||
setFavorites((prev) => [...prev, finalFavorite]);
|
||||
setNewTitle('');
|
||||
setNewUrl('');
|
||||
setNewIconUrl('');
|
||||
} catch {
|
||||
setError(t('favorites.error'));
|
||||
setNewIconFile(null);
|
||||
if (newIconFileInputRef.current) newIconFileInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
setError(t(favoriteErrorKey(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +234,9 @@ export function FavoritesWidget({
|
||||
setEditTitle(fav.title);
|
||||
setEditUrl(fav.url);
|
||||
setEditIconUrl(fav.iconUrl ?? '');
|
||||
setEditIconFile(null);
|
||||
setEditError(null);
|
||||
setEditBusy(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
@@ -202,8 +245,17 @@ export function FavoritesWidget({
|
||||
setEditTitle('');
|
||||
setEditUrl('');
|
||||
setEditIconUrl('');
|
||||
setEditIconFile(null);
|
||||
setEditError(null);
|
||||
setEditBusy(false);
|
||||
}
|
||||
|
||||
// 260923-lrr: erst PATCH (Titel/URL/Logo-Adresse), danach — falls eine
|
||||
// Datei gewaehlt wurde — der Upload. Scheitert `updateFavorite`, bleibt
|
||||
// die Zeile unveraendert und das Formular offen (z. B. 422 bei einer
|
||||
// nicht abrufbaren Logo-Adresse). Scheitert NUR der Upload, wird die
|
||||
// bereits aktualisierte Zeile trotzdem uebernommen, das Formular bleibt
|
||||
// ebenfalls offen, damit der Hinweis sichtbar ist.
|
||||
async function handleSaveEdit(id: string) {
|
||||
const cleanTitle = editTitle.trim();
|
||||
const cleanUrl = editUrl.trim();
|
||||
@@ -211,16 +263,45 @@ export function FavoritesWidget({
|
||||
|
||||
if (!cleanTitle || !cleanUrl) return;
|
||||
|
||||
setEditBusy(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const updated = await updateFavorite(id, {
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || null,
|
||||
});
|
||||
|
||||
if (editIconFile) {
|
||||
try {
|
||||
const withIcon = await uploadFavoriteIcon(id, editIconFile);
|
||||
setFavorites((prev) => prev.map((f) => (f.id === id ? withIcon : f)));
|
||||
cancelEdit();
|
||||
return;
|
||||
} catch (err) {
|
||||
setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f)));
|
||||
setEditError(t(favoriteErrorKey(err)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f)));
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setError(t('favorites.error'));
|
||||
} catch (err) {
|
||||
setEditError(t(favoriteErrorKey(err)));
|
||||
} finally {
|
||||
setEditBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Eigenes Symbol entfernen (260923-lrr) ---
|
||||
// Wirkt sofort (wie Loeschen einer Zeile), das Formular bleibt offen.
|
||||
async function handleRemoveIcon(id: string) {
|
||||
try {
|
||||
const updated = await removeFavoriteIcon(id);
|
||||
setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f)));
|
||||
} catch (err) {
|
||||
setEditError(t(favoriteErrorKey(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +407,8 @@ export function FavoritesWidget({
|
||||
editTitle={editTitle}
|
||||
editUrl={editUrl}
|
||||
editIconUrl={editIconUrl}
|
||||
editError={editError}
|
||||
editBusy={editBusy}
|
||||
getFallbackLetter={getFallbackLetter}
|
||||
onStartEdit={startEdit}
|
||||
onCancelEdit={cancelEdit}
|
||||
@@ -334,6 +417,8 @@ export function FavoritesWidget({
|
||||
onEditTitleChange={setEditTitle}
|
||||
onEditUrlChange={setEditUrl}
|
||||
onEditIconUrlChange={setEditIconUrl}
|
||||
onEditIconFileChange={setEditIconFile}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < sortedFavorites.length - 1}
|
||||
onMove={(fid, dir) => void handleMove(fid, dir)}
|
||||
@@ -355,6 +440,8 @@ export function FavoritesWidget({
|
||||
editTitle={editTitle}
|
||||
editUrl={editUrl}
|
||||
editIconUrl={editIconUrl}
|
||||
editError={editError}
|
||||
editBusy={editBusy}
|
||||
getFallbackLetter={getFallbackLetter}
|
||||
onStartEdit={startEdit}
|
||||
onCancelEdit={cancelEdit}
|
||||
@@ -363,6 +450,8 @@ export function FavoritesWidget({
|
||||
onEditTitleChange={setEditTitle}
|
||||
onEditUrlChange={setEditUrl}
|
||||
onEditIconUrlChange={setEditIconUrl}
|
||||
onEditIconFileChange={setEditIconFile}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < sortedFavorites.length - 1}
|
||||
onMove={(fid, dir) => void handleMove(fid, dir)}
|
||||
@@ -391,6 +480,18 @@ export function FavoritesWidget({
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder={t('favorites.addUrl')}
|
||||
/>
|
||||
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('favorites.iconUploadLabel')}
|
||||
<input
|
||||
type="file"
|
||||
data-testid="favorite-add-icon-upload"
|
||||
ref={newIconFileInputRef}
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
|
||||
className="mt-0.5 block w-full text-xs"
|
||||
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
@@ -443,6 +544,16 @@ function getDirectFaviconSrc(url: string): string | null {
|
||||
* Grenzen: ein http-Favorit auf einem https-Tessera ist Mischinhalt
|
||||
* (Browser blockiert ihn); ein selbstsigniertes Zertifikat ohne Vertrauen
|
||||
* im Browser des Nutzers klappt auch ueber Stufe 2 nicht.
|
||||
*
|
||||
* 260923-lrr: die Proxy-Adresse traegt jetzt `?v=<iconVersion>` — der
|
||||
* bisherige 24-Stunden-Zwischenspeicher des Browsers (`Cache-Control` am
|
||||
* Controller) aenderte sich bei einer neuen Logo-Adresse oder einem neuen
|
||||
* hochgeladenen Symbol NIE, weil die Adresse selbst gleich blieb. Der
|
||||
* Remount-Key an der Aufrufstelle (`FavoriteTile`) enthaelt `iconVersion`
|
||||
* und `uploadedIconMime` zusaetzlich zu `iconUrl`/`url`, damit nach einer
|
||||
* Aenderung wieder mit Stufe `proxy` begonnen wird — ein hochgeladenes
|
||||
* Symbol hat serverseitig bereits Vorrang (`getIconBytes`), die Kachel
|
||||
* zeigt es also auch ohne gesetzte `iconUrl`.
|
||||
*/
|
||||
function FavoriteIcon({
|
||||
fav,
|
||||
@@ -451,8 +562,9 @@ function FavoriteIcon({
|
||||
fav: FavoriteLink;
|
||||
getFallbackLetter: (title: string) => string;
|
||||
}) {
|
||||
const proxySrc = fav.iconUrl
|
||||
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon`
|
||||
const hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime);
|
||||
const proxySrc = hasServerIcon
|
||||
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
|
||||
: null;
|
||||
const directSrc = getDirectFaviconSrc(fav.url);
|
||||
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>(
|
||||
@@ -507,6 +619,9 @@ type FavoriteTileProps = {
|
||||
editTitle: string;
|
||||
editUrl: string;
|
||||
editIconUrl: string;
|
||||
/** 260923-lrr — Fehlermeldung/Sperre des Bearbeitungsformulars (eigenes Symbol). */
|
||||
editError: string | null;
|
||||
editBusy: boolean;
|
||||
getFallbackLetter: (title: string) => string;
|
||||
onStartEdit: (fav: FavoriteLink) => void;
|
||||
onCancelEdit: () => void;
|
||||
@@ -515,6 +630,10 @@ type FavoriteTileProps = {
|
||||
onEditTitleChange: (v: string) => void;
|
||||
onEditUrlChange: (v: string) => void;
|
||||
onEditIconUrlChange: (v: string) => void;
|
||||
/** 260923-lrr — gewaehlte Datei im Bearbeitungsformular. */
|
||||
onEditIconFileChange: (file: File | null) => void;
|
||||
/** 260923-lrr — „Hochgeladenes Symbol entfernen“. */
|
||||
onRemoveIcon: (id: string) => Promise<void>;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onMove: (id: string, direction: 'up' | 'down') => void;
|
||||
@@ -528,6 +647,8 @@ function FavoriteTile({
|
||||
editTitle,
|
||||
editUrl,
|
||||
editIconUrl,
|
||||
editError,
|
||||
editBusy,
|
||||
getFallbackLetter,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
@@ -536,6 +657,8 @@ function FavoriteTile({
|
||||
onEditTitleChange,
|
||||
onEditUrlChange,
|
||||
onEditIconUrlChange,
|
||||
onEditIconFileChange,
|
||||
onRemoveIcon,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMove,
|
||||
@@ -557,7 +680,7 @@ function FavoriteTile({
|
||||
>
|
||||
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
|
||||
<FavoriteIcon
|
||||
key={`${fav.iconUrl ?? ''}|${fav.url}`}
|
||||
key={`${fav.iconUrl ?? ''}|${fav.url}|${fav.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
|
||||
fav={fav}
|
||||
getFallbackLetter={getFallbackLetter}
|
||||
/>
|
||||
@@ -663,12 +786,43 @@ function FavoriteTile({
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editIconUrl}
|
||||
onChange={(e) => onEditIconUrlChange(e.target.value)}
|
||||
placeholder="Logo-URL (optional)"
|
||||
placeholder={t('favorites.iconUrlPlaceholder')}
|
||||
aria-label={t('favorites.iconUrlPlaceholder')}
|
||||
/>
|
||||
{/* 260923-lrr: eigenes Symbol — Datei-Feld, Hinweis, ggf. Entfernen-Knopf */}
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('favorites.iconUploadLabel')}
|
||||
<input
|
||||
type="file"
|
||||
data-testid={`favorite-icon-upload-${fav.id}`}
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
|
||||
className="mt-0.5 block w-full text-xs"
|
||||
onChange={(e) => onEditIconFileChange(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t('favorites.iconUploadHint')}</p>
|
||||
{fav.uploadedIconMime != null && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{t('favorites.iconUploadedHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
onClick={() => void onRemoveIcon(fav.id)}
|
||||
>
|
||||
{t('favorites.iconRemoveButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{editError && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="flex-1 rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
disabled={editBusy}
|
||||
onClick={() => void onSaveEdit(fav.id)}
|
||||
>
|
||||
{t('favorites.saveButton')}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import {
|
||||
healthSummary,
|
||||
resolveProxmoxWidgetConfig,
|
||||
selectServers,
|
||||
widgetKeyFigure,
|
||||
} from './proxmox-widget-model';
|
||||
|
||||
const NOW = Date.parse('2026-09-24T12:00:00.000Z');
|
||||
const NOW_S = NOW / 1000;
|
||||
const POLLED_AT = '2026-09-24T11:58:00.000Z';
|
||||
|
||||
function server(
|
||||
overrides: Partial<ProxmoxServer> & {
|
||||
metrics?: ProxmoxMetrics | null;
|
||||
reachable?: boolean;
|
||||
polled?: boolean;
|
||||
} = {},
|
||||
): ProxmoxServer {
|
||||
const { metrics = null, reachable = true, polled = true, ...rest } = overrides;
|
||||
const id = rest.id ?? 'srv';
|
||||
return {
|
||||
id,
|
||||
tenantId: 't1',
|
||||
name: id,
|
||||
productType: metrics?.productType ?? 'pve',
|
||||
baseUrl: 'https://x.intern',
|
||||
authMethod: 'token',
|
||||
tokenId: 'a@pam!b',
|
||||
username: null,
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
pollIntervalMin: 5,
|
||||
position: 0,
|
||||
createdAt: POLLED_AT,
|
||||
updatedAt: POLLED_AT,
|
||||
status: {
|
||||
id: `st-${id}`,
|
||||
serverId: id,
|
||||
lastPolledAt: polled ? POLLED_AT : null,
|
||||
lastOkAt: reachable ? POLLED_AT : null,
|
||||
reachable,
|
||||
errorKind: reachable ? null : 'netz',
|
||||
errorDetail: null,
|
||||
rawSample: null,
|
||||
updatedAt: POLLED_AT,
|
||||
metrics,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function pve(
|
||||
overrides: Partial<Extract<ProxmoxMetrics, { productType: 'pve' }>> = {},
|
||||
): ProxmoxMetrics {
|
||||
return {
|
||||
productType: 'pve',
|
||||
nodeCount: 1,
|
||||
guestsRunning: 3,
|
||||
guestsStopped: 1,
|
||||
nodes: [{ node: 'n1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 10 }],
|
||||
storages: [{ storage: 'local', node: 'n1', disk: 1, maxdisk: 10 }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pbsDatastore(lastBackupAt: number | null) {
|
||||
return {
|
||||
name: `ds-${lastBackupAt}`,
|
||||
total: 100,
|
||||
used: 10,
|
||||
free: 90,
|
||||
lastBackupAt,
|
||||
lastVerifyState: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
describe('healthSummary', () => {
|
||||
it('alle ok -> allOk, keine Eintraege, worst ok', () => {
|
||||
expect(healthSummary({ down: 0, warn: 0, ok: 3, idle: 0, orphan: 0 })).toEqual({
|
||||
allOk: true,
|
||||
entries: [],
|
||||
worst: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('Abweichungen in HEALTH_ORDER, ok wird nicht genannt, worst = schlimmster', () => {
|
||||
expect(healthSummary({ down: 1, warn: 2, ok: 5, idle: 0, orphan: 1 })).toEqual({
|
||||
allOk: false,
|
||||
entries: [
|
||||
{ health: 'down', count: 1 },
|
||||
{ health: 'warn', count: 2 },
|
||||
{ health: 'orphan', count: 1 },
|
||||
],
|
||||
worst: 'down',
|
||||
});
|
||||
});
|
||||
|
||||
it('keine Server -> nicht allOk, worst null', () => {
|
||||
expect(healthSummary({ down: 0, warn: 0, ok: 0, idle: 0, orphan: 0 })).toEqual({
|
||||
allOk: false,
|
||||
entries: [],
|
||||
worst: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProxmoxWidgetConfig', () => {
|
||||
it('leere Konfiguration -> leerer Titel, keine Auswahl', () => {
|
||||
expect(resolveProxmoxWidgetConfig({})).toEqual({ title: '', serverIds: [] });
|
||||
});
|
||||
|
||||
it('Titel, der kein String ist, wird leer', () => {
|
||||
expect(resolveProxmoxWidgetConfig({ title: 42 }).title).toBe('');
|
||||
expect(resolveProxmoxWidgetConfig({ title: null }).title).toBe('');
|
||||
});
|
||||
|
||||
it('Titel als String bleibt erhalten', () => {
|
||||
expect(resolveProxmoxWidgetConfig({ title: 'Rechenzentrum' }).title).toBe('Rechenzentrum');
|
||||
});
|
||||
|
||||
it('serverIds, das kein Array ist, wird leer', () => {
|
||||
expect(resolveProxmoxWidgetConfig({ serverIds: 'a' }).serverIds).toEqual([]);
|
||||
expect(resolveProxmoxWidgetConfig({ serverIds: { a: 1 } }).serverIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('Nicht-Strings und leere Strings fallen weg, doppelte Kennungen einmal', () => {
|
||||
expect(
|
||||
resolveProxmoxWidgetConfig({ serverIds: ['a', 1, '', null, 'b', 'a', { id: 'c' }] })
|
||||
.serverIds,
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectServers', () => {
|
||||
const list = [server({ id: 'a' }), server({ id: 'b' }), server({ id: 'c' })];
|
||||
|
||||
it('leere Auswahl liefert alle Server', () => {
|
||||
expect(selectServers(list, [])).toEqual({ servers: list, selectionGone: false });
|
||||
});
|
||||
|
||||
it('Auswahl filtert nach Kennung', () => {
|
||||
const result = selectServers(list, ['c', 'a']);
|
||||
expect(result.servers.map((s) => s.id)).toEqual(['a', 'c']);
|
||||
expect(result.selectionGone).toBe(false);
|
||||
});
|
||||
|
||||
it('nur noch geloeschte Kennungen ausgewaehlt -> leere Liste, selectionGone', () => {
|
||||
expect(selectServers(list, ['weg', 'auch-weg'])).toEqual({
|
||||
servers: [],
|
||||
selectionGone: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('eine geloeschte und eine vorhandene Kennung -> nur die vorhandene', () => {
|
||||
const result = selectServers(list, ['weg', 'b']);
|
||||
expect(result.servers.map((s) => s.id)).toEqual(['b']);
|
||||
expect(result.selectionGone).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('widgetKeyFigure', () => {
|
||||
it('down, idle, orphan liefern den Zustand, auch mit alten Messwerten', () => {
|
||||
expect(widgetKeyFigure(server({ reachable: false, metrics: pve() }), NOW)).toEqual({
|
||||
kind: 'status',
|
||||
health: 'down',
|
||||
});
|
||||
expect(widgetKeyFigure(server({ polled: false }), NOW)).toEqual({
|
||||
kind: 'status',
|
||||
health: 'idle',
|
||||
});
|
||||
expect(widgetKeyFigure(server({ isActive: false, metrics: pve() }), NOW)).toEqual({
|
||||
kind: 'status',
|
||||
health: 'orphan',
|
||||
});
|
||||
});
|
||||
|
||||
it('erreichbarer Server ohne Messwerte -> unknown', () => {
|
||||
expect(widgetKeyFigure(server({ metrics: null }), NOW)).toEqual({ kind: 'unknown' });
|
||||
});
|
||||
|
||||
it('PVE ok -> laufende Gaeste, total = laufend + gestoppt', () => {
|
||||
expect(widgetKeyFigure(server({ metrics: pve() }), NOW)).toEqual({
|
||||
kind: 'guests',
|
||||
running: 3,
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('PVE ok ohne Gaeste -> noGuests', () => {
|
||||
expect(
|
||||
widgetKeyFigure(server({ metrics: pve({ guestsRunning: 0, guestsStopped: 0 }) }), NOW),
|
||||
).toEqual({ kind: 'noGuests' });
|
||||
});
|
||||
|
||||
it('PVE mit unbekannter Gaestezahl -> unknown, nie 0', () => {
|
||||
const metrics = pve({
|
||||
guestsRunning: null as unknown as number,
|
||||
guestsStopped: 1,
|
||||
});
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'unknown' });
|
||||
});
|
||||
|
||||
it('PVE warn -> hoechster bekannter Anteil aus CPU, RAM und Speicher', () => {
|
||||
const metrics = pve({
|
||||
nodes: [
|
||||
{ node: 'n1', cpu: 0.5, maxcpu: 8, mem: 87, maxmem: 100 },
|
||||
{ node: 'n2', cpu: null, maxcpu: 8, mem: null, maxmem: 100 },
|
||||
],
|
||||
storages: [{ storage: 's', node: 'n1', disk: 70, maxdisk: 100 }],
|
||||
});
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({
|
||||
kind: 'load',
|
||||
fraction: 0.87,
|
||||
level: 'warn',
|
||||
});
|
||||
});
|
||||
|
||||
it('PVE warn durch Speicher -> Anteil des Speichers mit Stufe crit', () => {
|
||||
const metrics = pve({
|
||||
nodes: [{ node: 'n1', cpu: 0.2, maxcpu: 8, mem: 1, maxmem: 10 }],
|
||||
storages: [{ storage: 's', node: 'n1', disk: 95, maxdisk: 100 }],
|
||||
});
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({
|
||||
kind: 'load',
|
||||
fraction: 0.95,
|
||||
level: 'crit',
|
||||
});
|
||||
});
|
||||
|
||||
it('PBS -> aelteste bekannte letzte Sicherung samt stale-Flag', () => {
|
||||
const old = NOW_S - 30 * 3600;
|
||||
const fresh = NOW_S - 2 * 3600;
|
||||
const metrics: ProxmoxMetrics = {
|
||||
productType: 'pbs',
|
||||
datastores: [pbsDatastore(fresh), pbsDatastore(null), pbsDatastore(old)],
|
||||
};
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({
|
||||
kind: 'backup',
|
||||
at: old,
|
||||
stale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('PBS mit frischer Sicherung -> nicht stale', () => {
|
||||
const fresh = NOW_S - 5 * 3600;
|
||||
const metrics: ProxmoxMetrics = { productType: 'pbs', datastores: [pbsDatastore(fresh)] };
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({
|
||||
kind: 'backup',
|
||||
at: fresh,
|
||||
stale: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('PBS: Datenspeicher ohne jede Sicherung -> noBackup', () => {
|
||||
const metrics: ProxmoxMetrics = {
|
||||
productType: 'pbs',
|
||||
datastores: [pbsDatastore(null), pbsDatastore(null)],
|
||||
};
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'noBackup' });
|
||||
});
|
||||
|
||||
it('PBS ohne Datenspeicher -> unknown', () => {
|
||||
const metrics: ProxmoxMetrics = { productType: 'pbs', datastores: [] };
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'unknown' });
|
||||
});
|
||||
|
||||
it('PMG -> eingehende Mails aus countIn', () => {
|
||||
const metrics: ProxmoxMetrics = {
|
||||
productType: 'pmg',
|
||||
countIn: 12904,
|
||||
countOut: 5,
|
||||
spamCount: 1,
|
||||
virusCount: 0,
|
||||
};
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'mailIn', count: 12904 });
|
||||
});
|
||||
|
||||
it('PMG mit countIn null -> unknown, nie 0', () => {
|
||||
const metrics: ProxmoxMetrics = {
|
||||
productType: 'pmg',
|
||||
countIn: null,
|
||||
countOut: null,
|
||||
spamCount: null,
|
||||
virusCount: null,
|
||||
};
|
||||
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'unknown' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
HEALTH_ORDER,
|
||||
isBackupStale,
|
||||
type MeterLevel,
|
||||
meterLevel,
|
||||
ratio,
|
||||
type ServerHealth,
|
||||
serverHealth,
|
||||
} from '@/components/proxmox/proxmox-status';
|
||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||
|
||||
/**
|
||||
* Reine Funktionen der Proxmox-Kachel (quick-260924-i8v) — ohne React,
|
||||
* damit jede Verzweigung einzeln pruefbar ist (Muster `picture-frame-config.ts`).
|
||||
* Die Statuslogik selbst (Zustand je Server, Schwellen, Sortierung) kommt
|
||||
* aus `components/proxmox/proxmox-status.ts` und wird hier nicht kopiert.
|
||||
*
|
||||
* Grundregel wie dort: ein unbekannter Wert ist nie 0.
|
||||
*/
|
||||
|
||||
export interface HealthSummary {
|
||||
/** Es gibt Server, und keiner weicht von „in Ordnung“ ab. */
|
||||
allOk: boolean;
|
||||
/** Alle Zustaende ausser `ok` mit Anzahl > 0, in `HEALTH_ORDER`. */
|
||||
entries: Array<{ health: ServerHealth; count: number }>;
|
||||
/** Schlimmster vorhandene Zustand (erster in `HEALTH_ORDER` mit Anzahl > 0). */
|
||||
worst: ServerHealth | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zusammenfassung fuer die Zeile unter dem Balken: „Alles in Ordnung“ oder
|
||||
* die Abweichungen („1 nicht erreichbar, 1 mit Warnung“). Die Anzahl `ok`
|
||||
* wird bewusst nicht genannt — die Zeile soll sagen, was zu tun ist.
|
||||
*/
|
||||
export function healthSummary(counts: Record<ServerHealth, number>): HealthSummary {
|
||||
const entries = HEALTH_ORDER.filter((h) => h !== 'ok' && counts[h] > 0).map((health) => ({
|
||||
health,
|
||||
count: counts[health],
|
||||
}));
|
||||
const total = HEALTH_ORDER.reduce((sum, h) => sum + counts[h], 0);
|
||||
const worst = HEALTH_ORDER.find((h) => counts[h] > 0) ?? null;
|
||||
return { allOk: total > 0 && entries.length === 0, entries, worst };
|
||||
}
|
||||
|
||||
export interface ProxmoxWidgetConfig {
|
||||
/** Leer = keine Kopfzeile. */
|
||||
title: string;
|
||||
/** Leer = alle Server. */
|
||||
serverIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguration abwehrend lesen (T-I8V-03): die API prueft Kachel-
|
||||
* Konfigurationen nicht inhaltlich, `config` kann also alles enthalten.
|
||||
* Nicht-Strings und leere Kennungen fallen weg, doppelte zaehlen einmal.
|
||||
*/
|
||||
export function resolveProxmoxWidgetConfig(config: Record<string, unknown>): ProxmoxWidgetConfig {
|
||||
const title = typeof config.title === 'string' ? config.title : '';
|
||||
const rawIds = Array.isArray(config.serverIds) ? config.serverIds : [];
|
||||
const serverIds: string[] = [];
|
||||
for (const id of rawIds) {
|
||||
if (typeof id === 'string' && id !== '' && !serverIds.includes(id)) serverIds.push(id);
|
||||
}
|
||||
return { title, serverIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serverauswahl anwenden. Keine Auswahl = alle Server. Sind nur noch
|
||||
* Kennungen geloeschter Server ausgewaehlt, ist `selectionGone` gesetzt —
|
||||
* die Kachel sagt das dann, statt still „alle“ oder gar nichts zu zeigen.
|
||||
* Die Reihenfolge der gelieferten Liste bleibt erhalten.
|
||||
*/
|
||||
export function selectServers(
|
||||
servers: readonly ProxmoxServer[],
|
||||
serverIds: readonly string[],
|
||||
): { servers: ProxmoxServer[]; selectionGone: boolean } {
|
||||
if (serverIds.length === 0) return { servers: [...servers], selectionGone: false };
|
||||
const selected = servers.filter((s) => serverIds.includes(s.id));
|
||||
return { servers: selected, selectionGone: selected.length === 0 };
|
||||
}
|
||||
|
||||
/** Die eine Kennzahl rechts in einer Serverzeile. */
|
||||
export type KeyFigure =
|
||||
| { kind: 'status'; health: 'down' | 'idle' | 'orphan' }
|
||||
| { kind: 'guests'; running: number; total: number }
|
||||
| { kind: 'noGuests' }
|
||||
| { kind: 'load'; fraction: number; level: MeterLevel }
|
||||
| { kind: 'backup'; at: number; stale: boolean }
|
||||
| { kind: 'noBackup' }
|
||||
| { kind: 'mailIn'; count: number }
|
||||
| { kind: 'unknown' };
|
||||
|
||||
function isKnownNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && !Number.isNaN(value);
|
||||
}
|
||||
|
||||
function maxKnown(values: Array<number | null>): number | null {
|
||||
let max: number | null = null;
|
||||
for (const value of values) {
|
||||
if (isKnownNumber(value) && (max === null || value > max)) max = value;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennzahl je Server. Zuerst der Zustand: nicht erreichbar, noch nicht
|
||||
* abgefragt und verwaist zeigen immer nur den Zustand (ein verwaister
|
||||
* Server zeigt keine alten Messwerte, wie auf der Modulseite).
|
||||
*
|
||||
* - PVE: bei Warnung der hoechste bekannte Anteil aus Knoten-CPU,
|
||||
* Knoten-RAM und Speicher (das ist der Grund der Warnung), sonst die
|
||||
* laufenden Gaeste.
|
||||
* - PBS: die AELTESTE bekannte letzte Sicherung ueber alle Datenspeicher —
|
||||
* sie ist der Grund fuer „Sicherung zu alt“.
|
||||
* - PMG: eingehende Mails.
|
||||
*/
|
||||
export function widgetKeyFigure(server: ProxmoxServer, now: number = Date.now()): KeyFigure {
|
||||
const health = serverHealth(server, now);
|
||||
if (health === 'down' || health === 'idle' || health === 'orphan') {
|
||||
return { kind: 'status', health };
|
||||
}
|
||||
|
||||
const metrics = server.status?.metrics ?? null;
|
||||
if (metrics === null) return { kind: 'unknown' };
|
||||
|
||||
if (metrics.productType === 'pve') {
|
||||
if (health === 'warn') {
|
||||
const fraction = maxKnown([
|
||||
...metrics.nodes.map((n) => (isKnownNumber(n.cpu) ? n.cpu : null)),
|
||||
...metrics.nodes.map((n) => ratio(n.mem, n.maxmem)),
|
||||
...metrics.storages.map((s) => ratio(s.disk, s.maxdisk)),
|
||||
]);
|
||||
if (fraction !== null) return { kind: 'load', fraction, level: meterLevel(fraction) };
|
||||
}
|
||||
if (!isKnownNumber(metrics.guestsRunning) || !isKnownNumber(metrics.guestsStopped)) {
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
const total = metrics.guestsRunning + metrics.guestsStopped;
|
||||
if (total === 0) return { kind: 'noGuests' };
|
||||
return { kind: 'guests', running: metrics.guestsRunning, total };
|
||||
}
|
||||
|
||||
if (metrics.productType === 'pbs') {
|
||||
if (metrics.datastores.length === 0) return { kind: 'unknown' };
|
||||
let oldest: number | null = null;
|
||||
for (const ds of metrics.datastores) {
|
||||
if (isKnownNumber(ds.lastBackupAt) && (oldest === null || ds.lastBackupAt < oldest)) {
|
||||
oldest = ds.lastBackupAt;
|
||||
}
|
||||
}
|
||||
if (oldest === null) return { kind: 'noBackup' };
|
||||
return { kind: 'backup', at: oldest, stale: isBackupStale(oldest, now) };
|
||||
}
|
||||
|
||||
if (!isKnownNumber(metrics.countIn)) return { kind: 'unknown' };
|
||||
return { kind: 'mailIn', count: metrics.countIn };
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render as rtlRender,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import de from '@/messages/de.json';
|
||||
// Statisch importiert (vi.mock wird darueber gehoben) — das Laden faellt in die
|
||||
// Sammelphase, nicht in die Frist des ersten Tests (quick-260924-m4n).
|
||||
import { ProxmoxWidget } from './proxmox-widget';
|
||||
|
||||
// Echter next-intl-Provider mit den deutschen Texten (Muster
|
||||
// proxmox-page-roles.test.tsx) — die Kachel braucht ICU-Plural und useLocale.
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// T-I8V-02: `pollServer` ist nur als Spion da — die Kachel darf ihn nie rufen.
|
||||
const mockListServers = vi.fn();
|
||||
const mockPollServer = vi.fn();
|
||||
|
||||
vi.mock('@/lib/proxmox-api', () => ({
|
||||
listServers: (...args: unknown[]) => mockListServers(...args),
|
||||
pollServer: (...args: unknown[]) => mockPollServer(...args),
|
||||
}));
|
||||
|
||||
const mockAuthStore = vi.fn();
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUpdateWidgetConfig = vi.fn();
|
||||
vi.mock('@/lib/dashboard-api', () => ({
|
||||
updateWidgetConfig: (...args: unknown[]) => mockUpdateWidgetConfig(...args),
|
||||
}));
|
||||
|
||||
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
||||
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
|
||||
selector({ user }),
|
||||
);
|
||||
}
|
||||
|
||||
type Health = 'ok' | 'warn' | 'down' | 'idle' | 'orphan';
|
||||
|
||||
const POLLED_AT = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
||||
|
||||
function pveMetrics(overrides: Partial<Extract<ProxmoxMetrics, { productType: 'pve' }>> = {}) {
|
||||
return {
|
||||
productType: 'pve' as const,
|
||||
nodeCount: 1,
|
||||
guestsRunning: 3,
|
||||
guestsStopped: 1,
|
||||
nodes: [{ node: 'n1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 10 }],
|
||||
storages: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baukasten: ein Server mit Zwischenlager passend zum gewuenschten Zustand.
|
||||
* `warn` = PVE-Knoten bei 87 % Last.
|
||||
*/
|
||||
function makeServer(
|
||||
health: Health,
|
||||
overrides: Partial<ProxmoxServer> & { metrics?: ProxmoxMetrics | null } = {},
|
||||
): ProxmoxServer {
|
||||
const { metrics: metricsOverride, ...rest } = overrides;
|
||||
const id = rest.id ?? `srv-${health}`;
|
||||
const metrics: ProxmoxMetrics | null =
|
||||
metricsOverride !== undefined
|
||||
? metricsOverride
|
||||
: health === 'warn'
|
||||
? pveMetrics({ nodes: [{ node: 'n1', cpu: 0.87, maxcpu: 8, mem: 1, maxmem: 10 }] })
|
||||
: pveMetrics();
|
||||
return {
|
||||
id,
|
||||
tenantId: 't1',
|
||||
name: `server-${health}`,
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://pve.intern',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!x',
|
||||
username: null,
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: health !== 'orphan',
|
||||
pollIntervalMin: 5,
|
||||
position: 0,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
status: {
|
||||
id: `st-${id}`,
|
||||
serverId: id,
|
||||
lastPolledAt: health === 'idle' ? null : POLLED_AT,
|
||||
lastOkAt: health === 'down' ? null : POLLED_AT,
|
||||
reachable: health !== 'down' && health !== 'idle',
|
||||
errorKind: health === 'down' ? 'netz' : null,
|
||||
errorDetail: health === 'down' ? 'connect ECONNREFUSED 10.0.0.1:8006' : null,
|
||||
rawSample: null,
|
||||
updatedAt: POLLED_AT,
|
||||
metrics: health === 'down' || health === 'idle' ? null : metrics,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert die Kachel und wartet INNERHALB von act() das Ende des ersten
|
||||
* Ladens ab (aufgeloestes listServers-Versprechen -> setServers/setLoadFailed/
|
||||
* setNow). Ohne das liefen diese Zustandsaenderungen nach dem Rendern ins Leere
|
||||
* und React meldete je Test drei „not wrapped in act(...)“-Warnungen
|
||||
* (quick-260924-m4n).
|
||||
*/
|
||||
async function renderWidget(
|
||||
props: { config?: Record<string, unknown>; isEditMode?: boolean } = {},
|
||||
) {
|
||||
let result!: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<ProxmoxWidget
|
||||
instanceId="w-1"
|
||||
config={props.config ?? {}}
|
||||
isEditMode={props.isEditMode ?? false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser({ role: 'USER' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ProxmoxWidget: Balken und Zusammenfassung (quick-260924-i8v)', () => {
|
||||
it('alle Server in Ordnung: „Alles in Ordnung“ in der Ok-Farbe', async () => {
|
||||
mockListServers.mockResolvedValue([
|
||||
makeServer('ok', { id: 'a', name: 'pve-a' }),
|
||||
makeServer('ok', { id: 'b', name: 'pve-b' }),
|
||||
]);
|
||||
await renderWidget();
|
||||
|
||||
const summary = await screen.findByTestId('proxmox-summary');
|
||||
expect(summary).toHaveTextContent('Alles in Ordnung');
|
||||
expect(summary).toHaveClass('text-status-ok-fg');
|
||||
});
|
||||
|
||||
it('je ein down, warn, ok: „1 nicht erreichbar, 1 mit Warnung“ in der Farbe von down', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('warn'), makeServer('down')]);
|
||||
await renderWidget();
|
||||
|
||||
const summary = await screen.findByTestId('proxmox-summary');
|
||||
expect(summary).toHaveTextContent('1 nicht erreichbar, 1 mit Warnung');
|
||||
expect(summary).toHaveClass('text-status-down-fg');
|
||||
expect(summary).toHaveAttribute('title', '1 nicht erreichbar, 1 mit Warnung');
|
||||
});
|
||||
|
||||
it('zeigt den kompakten Balken (6 px, aria-hidden) ohne Legende', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]);
|
||||
await renderWidget();
|
||||
|
||||
const bar = await screen.findByTestId('health-bar');
|
||||
expect(bar).toHaveAttribute('data-variant', 'compact');
|
||||
expect(bar).toHaveClass('h-1.5');
|
||||
expect(bar).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(bar.querySelectorAll('[data-segment]')).toHaveLength(2);
|
||||
// Keine Legende: kein Listeneintrag im Balken, kein role="img"
|
||||
expect(bar.querySelector('li')).toBeNull();
|
||||
expect(screen.queryByRole('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('zeigt die Fehlerdetails eines nicht erreichbaren Servers nicht (T-I8V-05)', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('down')]);
|
||||
await renderWidget();
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
expect(screen.queryByText(/ECONNREFUSED/)).toBeNull();
|
||||
expect(screen.queryByText(/pve\.intern/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxmoxWidget: Lade-, Leer- und Fehlerzustand (quick-260924-i8v)', () => {
|
||||
it('waehrend des Ladens: pulsierende Leiste und Vorlesetext', async () => {
|
||||
mockListServers.mockReturnValue(new Promise(() => {}));
|
||||
await renderWidget();
|
||||
|
||||
expect(screen.getByText('Lade Serverliste...')).toHaveClass('sr-only');
|
||||
});
|
||||
|
||||
it('leere Liste: Satz; ein Admin sieht den Link zu den Einstellungen', async () => {
|
||||
mockUser({ role: 'ADMIN' });
|
||||
mockListServers.mockResolvedValue([]);
|
||||
await renderWidget();
|
||||
|
||||
expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Zu den Einstellungen' })).toHaveAttribute(
|
||||
'href',
|
||||
'/modules/proxmox/settings',
|
||||
);
|
||||
});
|
||||
|
||||
it('leere Liste: ein Benutzer mit Rolle USER sieht keinen Link', async () => {
|
||||
mockListServers.mockResolvedValue([]);
|
||||
await renderWidget();
|
||||
|
||||
expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
expect(screen.queryByText('Zu den Einstellungen')).toBeNull();
|
||||
});
|
||||
|
||||
it('leere Liste im Bearbeitungsmodus: der Hinweis fuer Admins steht ohne Link da', async () => {
|
||||
mockUser({ role: 'SUPER_ADMIN' });
|
||||
mockListServers.mockResolvedValue([]);
|
||||
await renderWidget({ isEditMode: true });
|
||||
|
||||
expect(await screen.findByText('Zu den Einstellungen')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('Laden schlaegt fehl: „Die Serverliste konnte nicht geladen werden.“', async () => {
|
||||
mockListServers.mockRejectedValue(new Error('500'));
|
||||
await renderWidget();
|
||||
|
||||
expect(
|
||||
await screen.findByText('Die Serverliste konnte nicht geladen werden.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ruft nie die manuelle Abfrage auf (T-I8V-02)', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]);
|
||||
await renderWidget();
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
await waitFor(() => expect(mockListServers).toHaveBeenCalledTimes(1));
|
||||
expect(mockPollServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function rowNames(): string[] {
|
||||
return screen
|
||||
.getAllByTestId('proxmox-row')
|
||||
.map((row) => row.getAttribute('data-server-name') ?? '');
|
||||
}
|
||||
|
||||
describe('ProxmoxWidget: Serverliste mit Kennzahl (quick-260924-i8v, Aufgabe 2)', () => {
|
||||
it('Zeilen stehen in der Reihenfolge down, warn, ok, idle, orphan', async () => {
|
||||
mockListServers.mockResolvedValue([
|
||||
makeServer('orphan', { position: 0 }),
|
||||
makeServer('ok', { position: 1 }),
|
||||
makeServer('idle', { position: 2 }),
|
||||
makeServer('warn', { position: 3 }),
|
||||
makeServer('down', { position: 4 }),
|
||||
]);
|
||||
await renderWidget();
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
expect(rowNames()).toEqual([
|
||||
'server-down',
|
||||
'server-warn',
|
||||
'server-ok',
|
||||
'server-idle',
|
||||
'server-orphan',
|
||||
]);
|
||||
});
|
||||
|
||||
it('zeigt je Zeile genau eine Kennzahl in Worten', async () => {
|
||||
const backupAt = Math.floor(Date.now() / 1000) - 5 * 3600;
|
||||
mockListServers.mockResolvedValue([
|
||||
makeServer('ok', { id: 'pve', name: 'pve-ok' }),
|
||||
makeServer('warn', { id: 'pve-w', name: 'pve-warn' }),
|
||||
makeServer('ok', {
|
||||
id: 'pbs',
|
||||
name: 'pbs-ok',
|
||||
productType: 'pbs',
|
||||
metrics: {
|
||||
productType: 'pbs',
|
||||
datastores: [
|
||||
{
|
||||
name: 'ds',
|
||||
total: 100,
|
||||
used: 10,
|
||||
free: 90,
|
||||
lastBackupAt: backupAt,
|
||||
lastVerifyState: 'ok',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
makeServer('ok', {
|
||||
id: 'pmg',
|
||||
name: 'pmg-ok',
|
||||
productType: 'pmg',
|
||||
metrics: {
|
||||
productType: 'pmg',
|
||||
countIn: 12904,
|
||||
countOut: 10,
|
||||
spamCount: 2,
|
||||
virusCount: 0,
|
||||
},
|
||||
}),
|
||||
makeServer('down'),
|
||||
makeServer('orphan'),
|
||||
makeServer('idle'),
|
||||
]);
|
||||
await renderWidget();
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
const figure = (name: string) =>
|
||||
within(
|
||||
screen
|
||||
.getAllByTestId('proxmox-row')
|
||||
.find((row) => row.getAttribute('data-server-name') === name) as HTMLElement,
|
||||
).getByTestId('proxmox-key-figure');
|
||||
|
||||
expect(figure('pve-ok')).toHaveTextContent('3/4 Gäste laufen');
|
||||
expect(figure('pve-warn').textContent).toMatch(/^Auslastung 87\s%$/);
|
||||
expect(figure('pve-warn')).toHaveClass('text-status-warn-fg');
|
||||
expect(figure('pbs-ok')).toHaveTextContent('Sicherung vor 5 Std.');
|
||||
expect(figure('pmg-ok')).toHaveTextContent('12.904 eingehend');
|
||||
expect(figure('server-down')).toHaveTextContent('nicht erreichbar');
|
||||
expect(figure('server-down')).toHaveClass('text-status-down-fg');
|
||||
expect(figure('server-down')).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(figure('server-orphan')).toHaveTextContent('offline & verwaist');
|
||||
expect(figure('server-idle')).toHaveTextContent('noch nicht abgefragt');
|
||||
});
|
||||
|
||||
it('unbekannte Werte heissen „unbekannt“ und nie 0', async () => {
|
||||
mockListServers.mockResolvedValue([
|
||||
makeServer('ok', { id: 'pve', name: 'pve-leer', metrics: null }),
|
||||
makeServer('ok', {
|
||||
id: 'pmg',
|
||||
name: 'pmg-leer',
|
||||
productType: 'pmg',
|
||||
metrics: {
|
||||
productType: 'pmg',
|
||||
countIn: null,
|
||||
countOut: null,
|
||||
spamCount: null,
|
||||
virusCount: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
await renderWidget();
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
const figures = screen.getAllByTestId('proxmox-key-figure');
|
||||
expect(figures.map((f) => f.textContent)).toEqual(['unbekannt', 'unbekannt']);
|
||||
expect(screen.queryByText(/0 eingehend/)).toBeNull();
|
||||
expect(screen.queryByText(/0\s%/)).toBeNull();
|
||||
});
|
||||
|
||||
it('jede Zeile nennt den Zustand auch fuer Vorleser (nicht nur Farbe)', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('warn')]);
|
||||
await renderWidget();
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
expect(screen.getByText('Warnung')).toHaveClass('sr-only');
|
||||
});
|
||||
|
||||
it('Kennzahl, Liste und Name tragen die Klassen der Groessenstufen', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('orphan')]);
|
||||
await renderWidget();
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
const list = screen.getByTestId('proxmox-list');
|
||||
expect(list.tagName).toBe('UL');
|
||||
expect(list).toHaveClass('[@container(max-height:7.5rem)]:hidden');
|
||||
expect(list).toHaveClass('@max-[8rem]:hidden');
|
||||
for (const figure of screen.getAllByTestId('proxmox-key-figure')) {
|
||||
expect(figure).toHaveClass('@max-[15rem]:hidden');
|
||||
}
|
||||
// verwaist: nur der Name wird gedaempft
|
||||
expect(screen.getByText('server-orphan')).toHaveClass('opacity-70');
|
||||
expect(screen.getByText('server-ok')).not.toHaveClass('opacity-70');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxmoxWidget: Serverauswahl, Titel, Links (quick-260924-i8v, Aufgabe 2)', () => {
|
||||
it('config.serverIds beschraenkt Zeilen UND Balken/Zusammenfassung auf die Auswahl', async () => {
|
||||
mockListServers.mockResolvedValue([
|
||||
makeServer('ok', { id: 'a', name: 'pve-a' }),
|
||||
makeServer('down', { id: 'b', name: 'pve-b' }),
|
||||
]);
|
||||
await renderWidget({ config: { serverIds: ['a'] } });
|
||||
|
||||
const summary = await screen.findByTestId('proxmox-summary');
|
||||
expect(summary).toHaveTextContent('Alles in Ordnung');
|
||||
expect(rowNames()).toEqual(['pve-a']);
|
||||
expect(screen.getByTestId('health-bar').querySelectorAll('[data-segment]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('nur geloeschte Kennungen ausgewaehlt: Satz statt Balken und Liste', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok', { id: 'a' })]);
|
||||
await renderWidget({ config: { serverIds: ['weg'] } });
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Die ausgewählten Server gibt es nicht mehr. Wählen Sie im Bearbeitungsmodus andere aus.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('health-bar')).toBeNull();
|
||||
expect(screen.queryByTestId('proxmox-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('Ansichtsmodus: jede Zeile ist ein Link auf /modules/proxmox', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]);
|
||||
await renderWidget();
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
for (const link of links) expect(link).toHaveAttribute('href', '/modules/proxmox');
|
||||
});
|
||||
|
||||
it('Bearbeitungsmodus: keine Links in der Kachel', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]);
|
||||
await renderWidget({ isEditMode: true });
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
expect(screen.getAllByTestId('proxmox-row')).toHaveLength(2);
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('Titel gesetzt: Ueberschrift h2', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok')]);
|
||||
await renderWidget({ config: { title: 'Rechenzentrum' } });
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Rechenzentrum' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Titel leer: keine Kopfzeile', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok')]);
|
||||
await renderWidget({ config: { title: ' ' } });
|
||||
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxmoxWidget: Minutentakt (quick-260924-i8v, Aufgabe 2)', () => {
|
||||
let visibility: DocumentVisibilityState = 'visible';
|
||||
|
||||
beforeEach(() => {
|
||||
visibility = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => visibility,
|
||||
});
|
||||
// Nur die Intervall-Zeitgeber faelschen: die Warte-Helfer der Testing
|
||||
// Library laufen weiter ueber echte Zeitgeber.
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
// Eigene Eigenschaft entfernen, damit wieder die von Document.prototype gilt.
|
||||
delete (document as unknown as Record<string, unknown>).visibilityState;
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
it('laedt nach 60 s erneut aus dem Zwischenlager', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok')]);
|
||||
await renderWidget();
|
||||
await flush();
|
||||
expect(mockListServers).toHaveBeenCalledTimes(1);
|
||||
|
||||
await advance(60_000);
|
||||
expect(mockListServers).toHaveBeenCalledTimes(2);
|
||||
expect(mockPollServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pausiert bei verborgenem Tab und laedt sofort, sobald er wieder sichtbar ist', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok')]);
|
||||
await renderWidget();
|
||||
await flush();
|
||||
expect(mockListServers).toHaveBeenCalledTimes(1);
|
||||
|
||||
visibility = 'hidden';
|
||||
await advance(120_000);
|
||||
expect(mockListServers).toHaveBeenCalledTimes(1);
|
||||
|
||||
visibility = 'visible';
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
expect(mockListServers).toHaveBeenCalledTimes(2);
|
||||
expect(mockPollServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nach dem Aushaengen keine weiteren Abrufe', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok')]);
|
||||
const { unmount } = await renderWidget();
|
||||
await flush();
|
||||
unmount();
|
||||
|
||||
await advance(180_000);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(mockListServers).toHaveBeenCalledTimes(1);
|
||||
expect(mockPollServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scheitert ein spaeteres Nachladen, bleibt die letzte Liste stehen', async () => {
|
||||
mockListServers.mockResolvedValueOnce([makeServer('down')]);
|
||||
await renderWidget();
|
||||
await flush();
|
||||
expect(await screen.findByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar');
|
||||
|
||||
mockListServers.mockRejectedValueOnce(new Error('500'));
|
||||
await advance(60_000);
|
||||
await flush();
|
||||
|
||||
expect(mockListServers).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar');
|
||||
expect(screen.queryByText('Die Serverliste konnte nicht geladen werden.')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxmoxWidget: Bearbeitungsmodus mit Titel und Serverauswahl (quick-260924-i8v, Aufgabe 3)', () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateWidgetConfig.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('Titelfeld speichert entprellt nach 1500 ms per updateWidgetConfig({ title })', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok')]);
|
||||
await renderWidget({ isEditMode: true, config: { title: 'Alt' } });
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
|
||||
const input = screen.getByLabelText('Titel') as HTMLInputElement;
|
||||
expect(input.value).toBe('Alt');
|
||||
expect(input).toHaveAttribute('placeholder', 'Titel (optional)');
|
||||
expect(input).toHaveClass('widgetNoDrag');
|
||||
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
try {
|
||||
fireEvent.change(input, { target: { value: 'N' } });
|
||||
fireEvent.change(input, { target: { value: 'Neu' } });
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(mockUpdateWidgetConfig).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mockUpdateWidgetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('w-1', { title: 'Neu' });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('im Bearbeitungsmodus steht die Kopfzeile auch ohne Titel, mit Abstand zur Griffleiste', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok')]);
|
||||
const { container } = await renderWidget({ isEditMode: true });
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
|
||||
expect(screen.getByLabelText('Titel')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="proxmox-widget"]')).toHaveClass('pt-5');
|
||||
});
|
||||
|
||||
it('„Server auswählen“ oeffnet die Auswahl an Stelle der Liste; eine Aenderung speichert sofort und filtert', async () => {
|
||||
mockListServers.mockResolvedValue([
|
||||
makeServer('ok', { id: 'a', name: 'pve-a', position: 0 }),
|
||||
makeServer('down', { id: 'b', name: 'pve-b', position: 1 }),
|
||||
]);
|
||||
await renderWidget({ isEditMode: true });
|
||||
expect(await screen.findByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar');
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Server auswählen' });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(toggle).toHaveAttribute('data-no-drag');
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
expect(screen.queryByTestId('proxmox-list')).toBeNull();
|
||||
const group = screen.getByRole('group', { name: 'Angezeigte Server' });
|
||||
expect(group.closest('.widgetNoDrag')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /pve-a/ }));
|
||||
|
||||
expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('w-1', { serverIds: ['a'] });
|
||||
expect(screen.getByTestId('proxmox-summary')).toHaveTextContent('Alles in Ordnung');
|
||||
// Kein zusaetzlicher Abruf fuer die Auswahl
|
||||
expect(mockListServers).toHaveBeenCalledTimes(1);
|
||||
expect(mockPollServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('die Auswahl bekommt die ungefilterte Liste', async () => {
|
||||
mockListServers.mockResolvedValue([
|
||||
makeServer('ok', { id: 'a', name: 'pve-a' }),
|
||||
makeServer('ok', { id: 'b', name: 'pve-b' }),
|
||||
]);
|
||||
await renderWidget({ isEditMode: true, config: { serverIds: ['a'] } });
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Server auswählen' }));
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /pve-a/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /pve-b/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('Verlassen des Bearbeitungsmodus schliesst die Auswahl', async () => {
|
||||
mockListServers.mockResolvedValue([makeServer('ok')]);
|
||||
const ui = (isEditMode: boolean) => (
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
<ProxmoxWidget instanceId="w-1" config={{}} isEditMode={isEditMode} />
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
let rerender!: ReturnType<typeof rtlRender>['rerender'];
|
||||
await act(async () => {
|
||||
({ rerender } = rtlRender(ui(true)));
|
||||
});
|
||||
await screen.findByTestId('proxmox-summary');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Server auswählen' }));
|
||||
expect(screen.getByRole('group', { name: 'Angezeigte Server' })).toBeInTheDocument();
|
||||
|
||||
rerender(ui(false));
|
||||
expect(screen.queryByRole('group', { name: 'Angezeigte Server' })).toBeNull();
|
||||
expect(screen.getByTestId('proxmox-list')).toBeInTheDocument();
|
||||
|
||||
rerender(ui(true));
|
||||
expect(screen.getByRole('button', { name: 'Server auswählen' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { HealthBar } from '@/components/proxmox/HealthBar';
|
||||
import { ProxmoxServerPicker } from '@/components/proxmox/proxmox-server-picker';
|
||||
import {
|
||||
formatAge,
|
||||
formatCount,
|
||||
formatPercent,
|
||||
type ServerHealth,
|
||||
serverHealth,
|
||||
sortServersByHealth,
|
||||
summarizeHealth,
|
||||
} from '@/components/proxmox/proxmox-status';
|
||||
import { HEALTH_STYLE, METER_TEXT } from '@/components/proxmox/status-styles';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
// T-I8V-02: aus dem Proxmox-Klienten NUR die Leseabfrage des Zwischenlagers.
|
||||
// Die manuelle Abfrage (POST .../poll) gehoert der Modulseite und darf hier
|
||||
// nie auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen aus.
|
||||
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import {
|
||||
healthSummary,
|
||||
type KeyFigure,
|
||||
resolveProxmoxWidgetConfig,
|
||||
selectServers,
|
||||
widgetKeyFigure,
|
||||
} from './proxmox-widget-model';
|
||||
|
||||
/** Minutentakt: so oft liest die Kachel das Zwischenlager neu. */
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
/** Wie TITLE_DEBOUNCE_MS der Favoriten (quick-260916-iex). */
|
||||
const TITLE_DEBOUNCE_MS = 1500;
|
||||
|
||||
const ROW_CLASS = 'flex items-center gap-2 rounded-md px-1.5 py-1 text-sm';
|
||||
|
||||
/**
|
||||
* ProxmoxWidget (quick-260924-i8v) — die erste Modul-Kachel. Zeigt den
|
||||
* Zustand der Proxmox-Server in der Statussprache der Modulseite: kompakter
|
||||
* Gesundheitsbalken, Zusammenfassung in Worten, darunter die Server nach
|
||||
* Dringlichkeit (down, warn, ok, idle, orphan) mit je einer Kennzahl.
|
||||
*
|
||||
* - Liest ausschliesslich das Zwischenlager (`GET /modules/proxmox/servers`,
|
||||
* serverseitig hinter `@UseModule('proxmox')`), alle 60 s neu; bei
|
||||
* verborgenem Browser-Tab pausiert der Takt, beim Zurueckkehren laedt
|
||||
* die Kachel sofort.
|
||||
* - Groessenstufen per Container-Query am Kachelrumpf des Wrappers (der ist
|
||||
* bereits `@container-size`; hier bewusst KEIN weiterer Container, der
|
||||
* die Abfragen umlenken wuerde): schmal (< 15rem) nur Punkte und Namen,
|
||||
* sehr klein (< 7.5rem hoch oder < 8rem breit) nur Balken und Satz.
|
||||
* - Ansichtsmodus: jede Zeile fuehrt zur Modulseite. Bearbeitungsmodus:
|
||||
* Zeilen ohne Ziel und ohne Tabstopp — bewusst KEIN Anker mit
|
||||
* verhindertem Klick wie bei den Favoriten, denn Links stehen im
|
||||
* Abbruch-Selektor von `dashboard-grid.tsx`; Anker-Zeilen wuerden das
|
||||
* Ziehen ueber fast die ganze Kachel blockieren.
|
||||
* - T-I8V-05: bei „nicht erreichbar“ nur das Zustandswort, keine
|
||||
* Fehlerdetails und keine Adresse — die bleiben auf der Modulseite.
|
||||
* - Bearbeitungsmodus (Aufgabe 3): Titelfeld (entprellt gespeichert) und
|
||||
* „Server auswählen“ direkt an der Kachel. Grund: Einstellungen >
|
||||
* Dashboard zeigt nur die Kacheln des ersten Reiters (quick-260923-ad9),
|
||||
* eine Proxmox-Kachel auf einem weiteren Reiter waere sonst nicht
|
||||
* einstellbar. Die Auswahl bekommt die schon geladene, ungefilterte
|
||||
* Liste — kein zusaetzlicher Abruf.
|
||||
*/
|
||||
export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const tp = useTranslations('proxmox');
|
||||
const locale = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
|
||||
// Lokaler Zustand aus `config` initialisiert (wie `viewMode` bei den
|
||||
// Favoriten); Aenderungen gehen per updateWidgetConfig an den Server.
|
||||
const [title, setTitle] = useState(() => resolveProxmoxWidgetConfig(config).title);
|
||||
const [serverIds, setServerIds] = useState(() => resolveProxmoxWidgetConfig(config).serverIds);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(titleTimerRef.current), []);
|
||||
|
||||
// Verlaesst der Nutzer den Bearbeitungsmodus, schliesst die Auswahl.
|
||||
useEffect(() => {
|
||||
if (!isEditMode) setPickerOpen(false);
|
||||
}, [isEditMode]);
|
||||
|
||||
async function saveConfig(partial: Record<string, unknown>) {
|
||||
try {
|
||||
await updateWidgetConfig(instanceId, partial);
|
||||
} catch {
|
||||
// still: der naechste Ladevorgang zeigt den gespeicherten Stand
|
||||
}
|
||||
}
|
||||
|
||||
function handleTitleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const value = e.target.value;
|
||||
setTitle(value);
|
||||
clearTimeout(titleTimerRef.current);
|
||||
titleTimerRef.current = setTimeout(() => {
|
||||
void saveConfig({ title: value });
|
||||
}, TITLE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function handleSelectionChange(next: string[]) {
|
||||
setServerIds(next);
|
||||
void saveConfig({ serverIds: next });
|
||||
}
|
||||
|
||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||
// Fehler als Flag, nicht als Text: `t` gehoert nicht in die Effekt-
|
||||
// Abhaengigkeiten (Befund 14 aus favorites-widget).
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
function load() {
|
||||
listServers()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setServers(list);
|
||||
setLoadFailed(false);
|
||||
setNow(Date.now());
|
||||
})
|
||||
.catch(() => {
|
||||
// Eine schon geladene Liste bleibt stehen; der Fehlersatz
|
||||
// erscheint nur, solange es noch nie eine Liste gab.
|
||||
if (!cancelled) setLoadFailed(true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible') load();
|
||||
}
|
||||
|
||||
load();
|
||||
const intervalId = setInterval(() => {
|
||||
if (document.visibilityState !== 'hidden') load();
|
||||
}, REFRESH_MS);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selection = useMemo(() => selectServers(servers ?? [], serverIds), [servers, serverIds]);
|
||||
const counts = useMemo(() => summarizeHealth(selection.servers, now), [selection, now]);
|
||||
const summary = useMemo(() => healthSummary(counts), [counts]);
|
||||
const sorted = useMemo(() => sortServersByHealth(selection.servers, now), [selection, now]);
|
||||
|
||||
const canChoose = servers !== null && servers.length > 0;
|
||||
const showPicker = isEditMode && pickerOpen && canChoose;
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
let header: ReactNode = null;
|
||||
if (isEditMode) {
|
||||
header = (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2.5 py-1.5">
|
||||
<input
|
||||
type="text"
|
||||
className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
placeholder={t('proxmox.titlePlaceholder')}
|
||||
aria-label={t('proxmox.titleLabel')}
|
||||
/>
|
||||
{canChoose && (
|
||||
<button
|
||||
type="button"
|
||||
data-no-drag=""
|
||||
aria-expanded={pickerOpen}
|
||||
onClick={() => setPickerOpen((open) => !open)}
|
||||
className="shrink-0 rounded-sm text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{t('proxmox.chooseServers')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedTitle !== '') {
|
||||
header = (
|
||||
<div className="shrink-0 border-b border-border px-2.5 py-1.5">
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Die Auswahl ersetzt den Listenbereich; die Huelle traegt `widgetNoDrag`,
|
||||
// damit ein Klick auf eine Beschriftung kein Ziehen der Kachel startet.
|
||||
const picker = showPicker && (
|
||||
<div className="widgetNoDrag min-h-0 flex-1 overflow-y-auto">
|
||||
<ProxmoxServerPicker
|
||||
servers={servers ?? []}
|
||||
selectedIds={serverIds}
|
||||
onChange={handleSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
function frame(body: ReactNode, bodyClassName = 'gap-2') {
|
||||
return (
|
||||
<div
|
||||
data-testid="proxmox-widget"
|
||||
// pt-5: die 20 px hohe Griffleiste des Wrappers verdeckt sonst das
|
||||
// Titelfeld (vgl. top-6 im XFrame).
|
||||
className={`flex h-full flex-col overflow-hidden${isEditMode ? ' pt-5' : ''}`}
|
||||
>
|
||||
{header}
|
||||
<div className={`flex min-h-0 flex-1 flex-col p-2.5 ${bodyClassName}`}>{body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function centered(text: string, extra?: ReactNode) {
|
||||
return frame(
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
{extra}
|
||||
</>,
|
||||
'items-center justify-center gap-1.5 text-center',
|
||||
);
|
||||
}
|
||||
|
||||
if (servers === null && loadFailed) return centered(tp('loadError'));
|
||||
|
||||
if (servers === null) {
|
||||
return frame(
|
||||
<div aria-busy="true">
|
||||
<div className="h-1.5 w-full animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
|
||||
<span className="sr-only">{tp('loading')}</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
const settingsHint =
|
||||
isAdmin &&
|
||||
(isEditMode ? (
|
||||
<span className="text-sm font-medium text-foreground">{tp('card.settingsLink')}</span>
|
||||
) : (
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="rounded-sm text-sm font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{tp('card.settingsLink')}
|
||||
</Link>
|
||||
));
|
||||
return centered(t('proxmox.empty'), settingsHint);
|
||||
}
|
||||
|
||||
if (selection.selectionGone) {
|
||||
// Mit offener Auswahl kann der Nutzer das gleich beheben.
|
||||
return picker ? frame(picker) : centered(t('proxmox.selectionGone'));
|
||||
}
|
||||
|
||||
const summaryText = summary.allOk
|
||||
? t('proxmox.allOk')
|
||||
: summary.entries
|
||||
.map((e) => `${e.count} ${tp(`legend.${e.health}`, { count: e.count })}`)
|
||||
.join(', ');
|
||||
const summaryColor = summary.allOk
|
||||
? HEALTH_STYLE.ok.text
|
||||
: summary.worst
|
||||
? HEALTH_STYLE[summary.worst].text
|
||||
: 'text-muted-foreground';
|
||||
|
||||
function keyFigureText(figure: KeyFigure): string {
|
||||
switch (figure.kind) {
|
||||
case 'status':
|
||||
return tp(`legend.${figure.health}`, { count: 1 });
|
||||
case 'guests':
|
||||
return t('proxmox.guests', { running: figure.running, total: figure.total });
|
||||
case 'noGuests':
|
||||
return t('proxmox.noGuests');
|
||||
case 'load':
|
||||
return t('proxmox.load', { percent: formatPercent(figure.fraction, locale) });
|
||||
case 'backup': {
|
||||
const age = formatAge(figure.at, now, locale);
|
||||
return age === null ? tp('card.unknownValue') : t('proxmox.backupAgo', { age });
|
||||
}
|
||||
case 'noBackup':
|
||||
return tp('card.pbs.noBackupYet');
|
||||
case 'mailIn':
|
||||
return t('proxmox.mailIn', { count: formatCount(figure.count, locale) });
|
||||
case 'unknown':
|
||||
return tp('card.unknownValue');
|
||||
}
|
||||
}
|
||||
|
||||
function keyFigureColor(figure: KeyFigure): string {
|
||||
if (figure.kind === 'status') return HEALTH_STYLE[figure.health].text;
|
||||
if (figure.kind === 'load') return METER_TEXT[figure.level];
|
||||
if (figure.kind === 'backup' && figure.stale) return HEALTH_STYLE.warn.text;
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
|
||||
function rowContent(server: ProxmoxServer, health: ServerHealth) {
|
||||
const figure = widgetKeyFigure(server, now);
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${HEALTH_STYLE[health].fill}`}
|
||||
/>
|
||||
<span className={`min-w-0 flex-1 truncate${health === 'orphan' ? ' opacity-70' : ''}`}>
|
||||
{server.name}
|
||||
</span>
|
||||
{/* Der Zustand haengt nie nur an der Farbe. */}
|
||||
<span className="sr-only">{tp(`health.${health}`)}</span>
|
||||
<span
|
||||
data-testid="proxmox-key-figure"
|
||||
// Wiederholt die Kennzahl nur das Zustandswort, liest der Vorleser
|
||||
// sie nicht ein zweites Mal.
|
||||
aria-hidden={figure.kind === 'status' ? true : undefined}
|
||||
className={`shrink-0 text-right text-xs tabular-nums @max-[15rem]:hidden ${keyFigureColor(figure)}`}
|
||||
>
|
||||
{keyFigureText(figure)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return frame(
|
||||
<>
|
||||
<HealthBar variant="compact" counts={counts} />
|
||||
<p
|
||||
className={`shrink-0 truncate text-sm font-medium ${summaryColor}`}
|
||||
title={summaryText}
|
||||
data-testid="proxmox-summary"
|
||||
>
|
||||
{summaryText}
|
||||
</p>
|
||||
{picker || (
|
||||
<ul
|
||||
data-testid="proxmox-list"
|
||||
className="-mx-1 min-h-0 flex-1 overflow-y-auto [@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden"
|
||||
>
|
||||
{sorted.map((server) => {
|
||||
const health = serverHealth(server, now);
|
||||
return (
|
||||
<li key={server.id} data-testid="proxmox-row" data-server-name={server.name}>
|
||||
{isEditMode ? (
|
||||
<div className={ROW_CLASS}>{rowContent(server, health)}</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/modules/proxmox"
|
||||
className={`${ROW_CLASS} transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
|
||||
>
|
||||
{rowContent(server, health)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Kennung des Einhaengepunkts in der Mitte der Kopfzeile (quick-260924-h7x).
|
||||
* Die Kopfzeile rendert ein leeres Element mit dieser Kennung; Seiten
|
||||
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
|
||||
*/
|
||||
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
|
||||
@@ -150,3 +150,31 @@ describe('Header — Sitzungswaechter (quick-260917-gyd)', () => {
|
||||
).toHaveTextContent('?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Header — Mitte der Kopfzeile (quick-260924-h7x)', () => {
|
||||
it('auf der Startseite: kein Text „Startseite“, aber der leere Einhaengepunkt fuer die Reiter', async () => {
|
||||
mockPathname = '/';
|
||||
vi.stubGlobal('location', { href: '', pathname: '/', search: '' });
|
||||
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
|
||||
|
||||
await renderHeader();
|
||||
|
||||
expect(screen.queryByText('Startseite')).not.toBeInTheDocument();
|
||||
const slot = document.getElementById('header-center-slot');
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot).toBeEmptyDOMElement();
|
||||
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('auf anderen Seiten: „Startseite“ bleibt wie bisher', async () => {
|
||||
mockPathname = '/modules/proxmox';
|
||||
vi.stubGlobal('location', { href: '', pathname: '/modules/proxmox', search: '' });
|
||||
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
|
||||
|
||||
await renderHeader();
|
||||
|
||||
expect(screen.getByText('Startseite')).toBeInTheDocument();
|
||||
expect(document.getElementById('header-center-slot')).not.toBeNull();
|
||||
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchSessionState, logout } from '@/lib/auth-actions';
|
||||
import { buildNextParam } from '@/lib/safe-next';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { BugReportButton } from '@/components/bug-report/bug-report-button';
|
||||
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function Header() {
|
||||
@@ -97,7 +98,7 @@ export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm">
|
||||
{/* Left: Logo + Mobile hamburger */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
@@ -128,19 +129,29 @@ export function Header() {
|
||||
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
|
||||
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
|
||||
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
|
||||
wordmarkClassName="dark:text-primary"
|
||||
// Unter 640 px nur die Bildmarke: der Platz gehoert dort den
|
||||
// Dashboard-Reitern in der Kopfzeilenmitte (260924-h7x).
|
||||
wordmarkClassName="hidden sm:inline dark:text-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center: Breadcrumb / page title */}
|
||||
<div className="flex-1 px-4 text-center">
|
||||
{/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x).
|
||||
`DashboardTabs` rendert per Portal hierher; der Punkt muss deshalb
|
||||
im React-Baum der Kopfzeile LEER bleiben. Auf der Startseite
|
||||
entfaellt der Text „Startseite“, auf allen anderen Seiten bleibt
|
||||
er wie bisher. `min-w-0` laesst die Mitte schrumpfen, damit Logo
|
||||
und Aktionen rechts auch bei schmalem Fenster Platz behalten. */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4">
|
||||
<div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
|
||||
{pathname !== '/' && (
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{tHeader('breadcrumb.home')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<BugReportButton />
|
||||
<ThemeToggle />
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { HEALTH_ORDER, type ServerHealth } from './proxmox-status';
|
||||
import { HEALTH_STYLE } from './status-styles';
|
||||
|
||||
interface HealthBarProps {
|
||||
counts: Record<ServerHealth, number>;
|
||||
/**
|
||||
* `full` (Standard, Modulseite): 8 px hoher Balken samt Legende, der
|
||||
* Balken liest seine Zusammenfassung als `role="img"` vor.
|
||||
* `compact` (Dashboard-Kachel, quick-260924-i8v): 6 px hoher Balken OHNE
|
||||
* Legende und `aria-hidden` — die Kachel zeigt die Zusammenfassung direkt
|
||||
* darunter als sichtbaren Text, ein Vorleser saehe sie sonst doppelt.
|
||||
*/
|
||||
variant?: 'full' | 'compact';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesundheitsbalken (quick-260924-h7x) — das eine praegnante Element der
|
||||
* Seite: ein 8 px hoher, voll gerundeter Balken, anteilig je Zustand geteilt
|
||||
* (down, warn, ok, idle, orphan; leere Zustaende entfallen), darunter die
|
||||
* Legende „Punkt + Zahl + Wort“. Der Balken liest seine Zusammenfassung als
|
||||
* `role="img"` vor.
|
||||
*
|
||||
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel
|
||||
* (dort als `variant="compact"`).
|
||||
*/
|
||||
export function HealthBar({ counts, variant = 'full' }: HealthBarProps) {
|
||||
const t = useTranslations('proxmox');
|
||||
const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({
|
||||
health,
|
||||
count: counts[health],
|
||||
word: t(`legend.${health}`, { count: counts[health] }),
|
||||
}));
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const segments = entries.map((e) => (
|
||||
<div
|
||||
key={e.health}
|
||||
data-segment={e.health}
|
||||
className={`h-full min-w-2 first:rounded-l-full last:rounded-r-full ${HEALTH_STYLE[e.health].fill}`}
|
||||
style={{ flexGrow: e.count, flexBasis: 0 }}
|
||||
/>
|
||||
));
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div
|
||||
data-testid="health-bar"
|
||||
data-variant="compact"
|
||||
aria-hidden="true"
|
||||
className="flex h-1.5 w-full shrink-0 gap-[3px] overflow-hidden rounded-full"
|
||||
>
|
||||
{segments}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = entries.map((e) => `${e.count} ${e.word}`).join(', ');
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5" data-testid="health-bar" data-variant="full">
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t('legend.barLabel', { summary })}
|
||||
className="flex h-2 w-full gap-[3px] overflow-hidden rounded-full"
|
||||
>
|
||||
{segments}
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-hidden="true">
|
||||
{entries.map((e) => (
|
||||
<li key={e.health} className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${HEALTH_STYLE[e.health].fill}`} />
|
||||
<span>
|
||||
<span className="font-semibold tabular-nums">{e.count}</span>{' '}
|
||||
<span className="text-muted-foreground">{e.word}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { cleanup, fireEvent, render as rtlRender, screen } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProxmoxProductType, ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import de from '@/messages/de.json';
|
||||
import { ProxmoxServerPicker } from './proxmox-server-picker';
|
||||
|
||||
/**
|
||||
* Auswahl-Bauteil der Proxmox-Kachel (quick-260924-i8v) — gemeinsam fuer die
|
||||
* Kachel im Bearbeitungsmodus und Einstellungen > Dashboard.
|
||||
*/
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function server(id: string, productType: ProxmoxProductType, position: number): ProxmoxServer {
|
||||
return {
|
||||
id,
|
||||
tenantId: 't1',
|
||||
name: id,
|
||||
productType,
|
||||
baseUrl: 'https://x.intern',
|
||||
authMethod: 'token',
|
||||
tokenId: 'a@pam!b',
|
||||
username: null,
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
pollIntervalMin: 5,
|
||||
position,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
status: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Absichtlich NICHT nach position geliefert.
|
||||
const SERVERS = [server('pmg-1', 'pmg', 2), server('pve-1', 'pve', 0), server('pbs-1', 'pbs', 1)];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('ProxmoxServerPicker (quick-260924-i8v)', () => {
|
||||
it('zeigt je Server ein Kaestchen, sortiert nach position, mit Produktwort', () => {
|
||||
render(<ProxmoxServerPicker servers={SERVERS} selectedIds={[]} onChange={vi.fn()} />);
|
||||
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
expect(boxes.map((b) => b.getAttribute('value'))).toEqual(['pve-1', 'pbs-1', 'pmg-1']);
|
||||
expect(screen.getByRole('checkbox', { name: /pve-1.*Virtualisierung/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: /pbs-1.*Datensicherung/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: /pmg-1.*Mail-Gateway/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('group', { name: 'Angezeigte Server' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ausgewaehlte Kennungen sind angehakt', () => {
|
||||
render(<ProxmoxServerPicker servers={SERVERS} selectedIds={['pbs-1']} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /pbs-1/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /pve-1/ })).not.toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /pmg-1/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('Anhaken liefert die neue Liste in Listenreihenfolge', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ProxmoxServerPicker servers={SERVERS} selectedIds={['pmg-1']} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /pve-1/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['pve-1', 'pmg-1']);
|
||||
});
|
||||
|
||||
it('Kennungen geloeschter Server fallen bei jeder Aenderung heraus', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ProxmoxServerPicker
|
||||
servers={SERVERS}
|
||||
selectedIds={['geloescht', 'pbs-1']}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /pmg-1/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['pbs-1', 'pmg-1']);
|
||||
});
|
||||
|
||||
it('alles abhaken ergibt eine leere Liste', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ProxmoxServerPicker servers={SERVERS} selectedIds={['pve-1']} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /pve-1/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('der Hinweis „Ohne Auswahl zeigt die Kachel alle Server.“ steht sichtbar da', () => {
|
||||
render(<ProxmoxServerPicker servers={SERVERS} selectedIds={[]} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Ohne Auswahl zeigt die Kachel alle Server.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('zwei Instanzen erzeugen eindeutige Feldkennungen', () => {
|
||||
render(
|
||||
<>
|
||||
<ProxmoxServerPicker servers={SERVERS} selectedIds={[]} onChange={vi.fn()} />
|
||||
<ProxmoxServerPicker servers={SERVERS} selectedIds={[]} onChange={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
|
||||
const ids = screen.getAllByRole('checkbox').map((b) => b.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useId, useMemo } from 'react';
|
||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||
|
||||
interface ProxmoxServerPickerProps {
|
||||
servers: readonly ProxmoxServer[];
|
||||
selectedIds: readonly string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serverauswahl der Proxmox-Kachel (quick-260924-i8v) — gemeinsam fuer die
|
||||
* Kachel im Bearbeitungsmodus und das Formular unter Einstellungen >
|
||||
* Dashboard. Keine eigene Datenabfrage: die Liste wird hereingereicht.
|
||||
*
|
||||
* `onChange` liefert die Kennungen in Listenreihenfolge (nach `position`)
|
||||
* und nur fuer vorhandene Server — jede Aenderung raeumt so Kennungen
|
||||
* geloeschter Server mit auf. Keine Auswahl heisst: alle Server.
|
||||
*/
|
||||
export function ProxmoxServerPicker({ servers, selectedIds, onChange }: ProxmoxServerPickerProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const tp = useTranslations('proxmox');
|
||||
const baseId = useId();
|
||||
|
||||
const ordered = useMemo(() => [...servers].sort((a, b) => a.position - b.position), [servers]);
|
||||
|
||||
function toggle(id: string, checked: boolean) {
|
||||
const next = ordered
|
||||
.map((s) => s.id)
|
||||
.filter((sid) => (sid === id ? checked : selectedIds.includes(sid)));
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="min-w-0 space-y-1.5">
|
||||
<legend className="text-sm font-medium text-foreground">{t('proxmox.serversLabel')}</legend>
|
||||
<p className="text-xs text-muted-foreground">{t('proxmox.serversHint')}</p>
|
||||
<ul className="space-y-0.5">
|
||||
{ordered.map((server, index) => {
|
||||
const inputId = `${baseId}-${index}`;
|
||||
return (
|
||||
<li key={server.id} className="flex items-center gap-2">
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
value={server.id}
|
||||
checked={selectedIds.includes(server.id)}
|
||||
onChange={(e) => toggle(server.id, e.target.checked)}
|
||||
className="h-4 w-4 shrink-0 rounded border-border text-primary"
|
||||
/>
|
||||
<label htmlFor={inputId} className="min-w-0 truncate text-sm text-foreground">
|
||||
{server.name}{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{tp(`card.product.${server.productType}`)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ProxmoxMetrics, ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
|
||||
import {
|
||||
formatAge,
|
||||
formatCount,
|
||||
formatPercent,
|
||||
HEALTH_ORDER,
|
||||
isBackupStale,
|
||||
meterLevel,
|
||||
ratio,
|
||||
serverHealth,
|
||||
sortServersByHealth,
|
||||
summarizeHealth,
|
||||
THRESHOLDS,
|
||||
} from './proxmox-status';
|
||||
|
||||
/** Fester Bezugszeitpunkt fuer alle Tests: 24.09.2026, 12:00 UTC. */
|
||||
const NOW = Date.parse('2026-09-24T12:00:00.000Z');
|
||||
const NOW_S = NOW / 1000;
|
||||
|
||||
function makeStatus(overrides: Partial<ProxmoxServerStatus> = {}): ProxmoxServerStatus {
|
||||
return {
|
||||
id: 's1',
|
||||
serverId: 'srv-1',
|
||||
lastPolledAt: '2026-09-24T11:55:00.000Z',
|
||||
lastOkAt: '2026-09-24T11:55:00.000Z',
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: null,
|
||||
rawSample: null,
|
||||
updatedAt: '2026-09-24T11:55:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||
return {
|
||||
id: 'srv-1',
|
||||
tenantId: 't1',
|
||||
name: 'pve-1',
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://pve.intern',
|
||||
authMethod: 'token',
|
||||
tokenId: 'root@pam!x',
|
||||
username: null,
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
pollIntervalMin: 5,
|
||||
position: 0,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
status: makeStatus(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withMetrics(
|
||||
metrics: ProxmoxMetrics,
|
||||
overrides: Partial<ProxmoxServer> = {},
|
||||
): ProxmoxServer {
|
||||
return makeServer({
|
||||
productType: metrics.productType,
|
||||
status: makeStatus({ metrics }),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function pve(
|
||||
nodes: Array<{ cpu: number | null; mem: number | null; maxmem: number | null }>,
|
||||
storages: Array<{ disk: number | null; maxdisk: number | null }> = [],
|
||||
): ProxmoxMetrics {
|
||||
return {
|
||||
productType: 'pve',
|
||||
nodeCount: nodes.length,
|
||||
guestsRunning: 1,
|
||||
guestsStopped: 0,
|
||||
nodes: nodes.map((n, i) => ({ node: `n${i}`, maxcpu: 8, ...n })),
|
||||
storages: storages.map((s, i) => ({ storage: `s${i}`, node: 'n0', ...s })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('THRESHOLDS', () => {
|
||||
it('traegt die Schwellen aus dem Plan an EINER Stelle', () => {
|
||||
expect(THRESHOLDS.usageWarn).toBe(0.8);
|
||||
expect(THRESHOLDS.usageCrit).toBe(0.92);
|
||||
expect(THRESHOLDS.backupMaxAgeHours).toBe(26);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meterLevel', () => {
|
||||
it('null/undefined/NaN -> unknown (nie ok, nie 0)', () => {
|
||||
expect(meterLevel(null)).toBe('unknown');
|
||||
expect(meterLevel(undefined)).toBe('unknown');
|
||||
expect(meterLevel(Number.NaN)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('unter 0.80 ok, ab 0.80 warn, ab 0.92 crit', () => {
|
||||
expect(meterLevel(0)).toBe('ok');
|
||||
expect(meterLevel(0.79)).toBe('ok');
|
||||
expect(meterLevel(0.8)).toBe('warn');
|
||||
expect(meterLevel(0.91)).toBe('warn');
|
||||
expect(meterLevel(0.92)).toBe('crit');
|
||||
expect(meterLevel(1)).toBe('crit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ratio', () => {
|
||||
it('rechnet used/total', () => {
|
||||
expect(ratio(25, 100)).toBe(0.25);
|
||||
});
|
||||
|
||||
it('null, wenn einer der Werte fehlt oder total nicht positiv ist', () => {
|
||||
expect(ratio(null, 100)).toBeNull();
|
||||
expect(ratio(10, null)).toBeNull();
|
||||
expect(ratio(10, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBackupStale', () => {
|
||||
it('null (noch keine Sicherung) ist KEINE Warnung', () => {
|
||||
expect(isBackupStale(null, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('juenger als 26 h ist frisch, aelter ist veraltet', () => {
|
||||
expect(isBackupStale(NOW_S - 25 * 3600, NOW)).toBe(false);
|
||||
expect(isBackupStale(NOW_S - 27 * 3600, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverHealth', () => {
|
||||
it('deaktivierter Server ist orphan — Vorrang vor allem, auch mit alten Messwerten und Fehler', () => {
|
||||
expect(serverHealth(makeServer({ isActive: false }), NOW)).toBe('orphan');
|
||||
expect(
|
||||
serverHealth(
|
||||
makeServer({
|
||||
isActive: false,
|
||||
status: makeStatus({ reachable: false, errorKind: 'netz' }),
|
||||
}),
|
||||
NOW,
|
||||
),
|
||||
).toBe('orphan');
|
||||
expect(
|
||||
serverHealth(
|
||||
withMetrics(pve([{ cpu: 0.99, mem: 99, maxmem: 100 }]), { isActive: false }),
|
||||
NOW,
|
||||
),
|
||||
).toBe('orphan');
|
||||
expect(serverHealth(makeServer({ isActive: false, status: null }), NOW)).toBe('orphan');
|
||||
});
|
||||
|
||||
it('kein Zwischenlager oder noch nie abgefragt -> idle', () => {
|
||||
expect(serverHealth(makeServer({ status: null }), NOW)).toBe('idle');
|
||||
expect(
|
||||
serverHealth(
|
||||
makeServer({ status: makeStatus({ lastPolledAt: null, reachable: false }) }),
|
||||
NOW,
|
||||
),
|
||||
).toBe('idle');
|
||||
});
|
||||
|
||||
it('abgefragt, aber nicht erreichbar -> down', () => {
|
||||
expect(
|
||||
serverHealth(
|
||||
makeServer({ status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
|
||||
NOW,
|
||||
),
|
||||
).toBe('down');
|
||||
});
|
||||
|
||||
it('erreichbar ohne Messwerte -> ok', () => {
|
||||
expect(serverHealth(makeServer(), NOW)).toBe('ok');
|
||||
});
|
||||
|
||||
it('PVE: Prozessor, Arbeitsspeicher oder Datenspeicher ab 0.80 -> warn', () => {
|
||||
expect(serverHealth(withMetrics(pve([{ cpu: 0.2, mem: 10, maxmem: 100 }])), NOW)).toBe('ok');
|
||||
expect(serverHealth(withMetrics(pve([{ cpu: 0.85, mem: 10, maxmem: 100 }])), NOW)).toBe('warn');
|
||||
expect(serverHealth(withMetrics(pve([{ cpu: 0.1, mem: 80, maxmem: 100 }])), NOW)).toBe('warn');
|
||||
expect(
|
||||
serverHealth(
|
||||
withMetrics(pve([{ cpu: 0.1, mem: 10, maxmem: 100 }], [{ disk: 95, maxdisk: 100 }])),
|
||||
NOW,
|
||||
),
|
||||
).toBe('warn');
|
||||
});
|
||||
|
||||
it('PVE: unbekannte Werte (null) erzeugen NIE eine Warnung', () => {
|
||||
expect(
|
||||
serverHealth(
|
||||
withMetrics(pve([{ cpu: null, mem: null, maxmem: null }], [{ disk: null, maxdisk: null }])),
|
||||
NOW,
|
||||
),
|
||||
).toBe('ok');
|
||||
});
|
||||
|
||||
it('PBS: Fuellstand ab 0.80, Sicherung aelter als 26 h, Pruefung nicht ok -> warn', () => {
|
||||
const base = {
|
||||
name: 'ds',
|
||||
total: 100,
|
||||
used: 10,
|
||||
free: 90,
|
||||
lastBackupAt: NOW_S - 3600,
|
||||
lastVerifyState: 'ok',
|
||||
};
|
||||
const pbs = (ds: Partial<typeof base> & Record<string, unknown>): ProxmoxMetrics => ({
|
||||
productType: 'pbs',
|
||||
datastores: [{ ...base, ...ds } as never],
|
||||
});
|
||||
expect(serverHealth(withMetrics(pbs({})), NOW)).toBe('ok');
|
||||
expect(serverHealth(withMetrics(pbs({ used: 85 })), NOW)).toBe('warn');
|
||||
expect(serverHealth(withMetrics(pbs({ lastBackupAt: NOW_S - 30 * 3600 })), NOW)).toBe('warn');
|
||||
expect(serverHealth(withMetrics(pbs({ lastVerifyState: 'failed' })), NOW)).toBe('warn');
|
||||
});
|
||||
|
||||
it('PBS: null bei Belegung, Sicherung oder Pruefung erzeugt KEINE Warnung', () => {
|
||||
const metrics: ProxmoxMetrics = {
|
||||
productType: 'pbs',
|
||||
datastores: [
|
||||
{
|
||||
name: 'ds',
|
||||
total: null,
|
||||
used: null,
|
||||
free: null,
|
||||
lastBackupAt: null,
|
||||
lastVerifyState: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(serverHealth(withMetrics(metrics), NOW)).toBe('ok');
|
||||
});
|
||||
|
||||
it('PMG: Viren > 0 -> warn, 0 oder null -> ok', () => {
|
||||
const pmg = (virusCount: number | null): ProxmoxMetrics => ({
|
||||
productType: 'pmg',
|
||||
countIn: 1,
|
||||
countOut: 1,
|
||||
spamCount: 1,
|
||||
virusCount,
|
||||
});
|
||||
expect(serverHealth(withMetrics(pmg(2)), NOW)).toBe('warn');
|
||||
expect(serverHealth(withMetrics(pmg(0)), NOW)).toBe('ok');
|
||||
expect(serverHealth(withMetrics(pmg(null)), NOW)).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortServersByHealth', () => {
|
||||
it('ordnet down, warn, ok, idle, orphan — innerhalb eines Zustands nach position', () => {
|
||||
const servers = [
|
||||
makeServer({ id: 'orphan', isActive: false, position: 0 }),
|
||||
makeServer({ id: 'ok-2', position: 5 }),
|
||||
makeServer({ id: 'idle', status: null, position: 1 }),
|
||||
makeServer({ id: 'ok-1', position: 2 }),
|
||||
makeServer({ id: 'down', status: makeStatus({ reachable: false }), position: 9 }),
|
||||
withMetrics(pve([{ cpu: 0.9, mem: 1, maxmem: 10 }]), { id: 'warn', position: 7 }),
|
||||
];
|
||||
|
||||
expect(sortServersByHealth(servers, NOW).map((s) => s.id)).toEqual([
|
||||
'down',
|
||||
'warn',
|
||||
'ok-1',
|
||||
'ok-2',
|
||||
'idle',
|
||||
'orphan',
|
||||
]);
|
||||
});
|
||||
|
||||
it('veraendert die uebergebene Liste nicht', () => {
|
||||
const servers = [makeServer({ id: 'a', isActive: false }), makeServer({ id: 'b' })];
|
||||
sortServersByHealth(servers, NOW);
|
||||
expect(servers.map((s) => s.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeHealth', () => {
|
||||
it('zaehlt je Zustand, in der Reihenfolge von HEALTH_ORDER', () => {
|
||||
const servers = [
|
||||
makeServer({ id: 'a' }),
|
||||
makeServer({ id: 'b' }),
|
||||
makeServer({ id: 'c', isActive: false }),
|
||||
makeServer({ id: 'd', status: makeStatus({ reachable: false }) }),
|
||||
];
|
||||
expect(HEALTH_ORDER).toEqual(['down', 'warn', 'ok', 'idle', 'orphan']);
|
||||
expect(summarizeHealth(servers, NOW)).toEqual({ down: 1, warn: 0, ok: 2, idle: 0, orphan: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAge', () => {
|
||||
it('null oder ungueltig -> null', () => {
|
||||
expect(formatAge(null, NOW)).toBeNull();
|
||||
expect(formatAge('kein Datum', NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('ISO-Zeichenkette: Minuten, Stunden, Tage auf Deutsch', () => {
|
||||
expect(formatAge('2026-09-24T11:57:00.000Z', NOW)).toBe('vor 3 Min.');
|
||||
expect(formatAge('2026-09-24T07:00:00.000Z', NOW)).toBe('vor 5 Std.');
|
||||
expect(formatAge('2026-09-22T12:00:00.000Z', NOW)).toBe('vor 2 Tagen');
|
||||
});
|
||||
|
||||
it('Zahl gilt als Unix-Sekunden (PBS lastBackupAt)', () => {
|
||||
expect(formatAge(NOW_S - 5 * 3600, NOW)).toBe('vor 5 Std.');
|
||||
});
|
||||
|
||||
it('unter einer Minute und leichte Uhrabweichung in die Zukunft -> "jetzt"', () => {
|
||||
expect(formatAge('2026-09-24T11:59:40.000Z', NOW)).toBe('jetzt');
|
||||
expect(formatAge('2026-09-24T12:00:30.000Z', NOW)).toBe('jetzt');
|
||||
});
|
||||
|
||||
it('englisch ueber den Sprachparameter', () => {
|
||||
expect(formatAge('2026-09-24T11:57:00.000Z', NOW, 'en')).toBe('3 min. ago');
|
||||
});
|
||||
});
|
||||
|
||||
// quick-260924-i8v: Zahlformat EINMAL, gemeinsam fuer ServerCard und Kachel.
|
||||
describe('formatPercent / formatCount', () => {
|
||||
it('deutsches Prozentformat mit geschuetztem Leerzeichen', () => {
|
||||
expect(formatPercent(0.87, 'de')).toMatch(/^87\s%$/);
|
||||
});
|
||||
|
||||
it('deutsches Tausendertrennzeichen', () => {
|
||||
expect(formatCount(12904, 'de')).toBe('12.904');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
|
||||
|
||||
/**
|
||||
* Statuslogik der Proxmox-Seite (quick-260924-h7x) — reine Funktionen ohne
|
||||
* React, damit jede Verzweigung einzeln pruefbar ist.
|
||||
*
|
||||
* Grundregel: ein UNBEKANNTER Wert (`null`) ist nie „0“ und loest nie eine
|
||||
* Warnung aus. Die Feldnamen von PBS und PMG sind nur abgeleitet (siehe
|
||||
* ServerCard), ein Fehlalarm aus einem fehlenden Feld waere schlimmer als
|
||||
* ein ehrliches „unbekannt“.
|
||||
*
|
||||
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel —
|
||||
* deshalb unter `components/proxmox/` statt im Routenordner der Seite.
|
||||
*/
|
||||
|
||||
/** Gesamtzustand eines Servers — bestimmt Farbe, Pille und Sortierung. */
|
||||
export type ServerHealth = 'ok' | 'warn' | 'down' | 'idle' | 'orphan';
|
||||
|
||||
/** Zustand eines einzelnen Balkens. */
|
||||
export type MeterLevel = 'ok' | 'warn' | 'crit' | 'unknown';
|
||||
|
||||
/** Alle Schwellen an EINER Stelle. Auslastungen sind Anteile (0..1). */
|
||||
export const THRESHOLDS = {
|
||||
/** Last, Arbeitsspeicher, Datenspeicher: ab hier Warnung. */
|
||||
usageWarn: 0.8,
|
||||
/** Ab hier kritisch (nur fuer die Balkenfarbe). */
|
||||
usageCrit: 0.92,
|
||||
/** PBS: letzte Sicherung aelter als so viele Stunden -> Warnung. */
|
||||
backupMaxAgeHours: 26,
|
||||
} as const;
|
||||
|
||||
/** Reihenfolge fuer Sortierung, Gesundheitsbalken und Legende. */
|
||||
export const HEALTH_ORDER: readonly ServerHealth[] = ['down', 'warn', 'ok', 'idle', 'orphan'];
|
||||
|
||||
function isKnownNumber(value: number | null | undefined): value is number {
|
||||
return typeof value === 'number' && !Number.isNaN(value);
|
||||
}
|
||||
|
||||
/** Balkenstufe aus einem Anteil (0..1); `null` bleibt `unknown`. */
|
||||
export function meterLevel(fraction: number | null | undefined): MeterLevel {
|
||||
if (!isKnownNumber(fraction)) return 'unknown';
|
||||
if (fraction >= THRESHOLDS.usageCrit) return 'crit';
|
||||
if (fraction >= THRESHOLDS.usageWarn) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/** `used / total` — `null`, sobald ein Wert fehlt oder `total` nicht positiv ist. */
|
||||
export function ratio(
|
||||
used: number | null | undefined,
|
||||
total: number | null | undefined,
|
||||
): number | null {
|
||||
if (!isKnownNumber(used) || !isKnownNumber(total) || total <= 0) return null;
|
||||
return used / total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitpunkt in Millisekunden. Zahlen sind Unix-SEKUNDEN (so liefert PBS
|
||||
* `lastBackupAt`), Zeichenketten ISO-Zeitpunkte (Zwischenlager).
|
||||
*/
|
||||
export function toEpochMs(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const ms = typeof value === 'number' ? value * 1000 : Date.parse(value);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
/** Letzte PBS-Sicherung aelter als {@link THRESHOLDS.backupMaxAgeHours}? `null` (noch nie) ist KEINE Warnung. */
|
||||
export function isBackupStale(
|
||||
lastBackupAt: number | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): boolean {
|
||||
const ms = toEpochMs(lastBackupAt);
|
||||
if (ms === null) return false;
|
||||
return now - ms > THRESHOLDS.backupMaxAgeHours * 3600 * 1000;
|
||||
}
|
||||
|
||||
function isHigh(fraction: number | null): boolean {
|
||||
return fraction !== null && fraction >= THRESHOLDS.usageWarn;
|
||||
}
|
||||
|
||||
/** Liegt irgendein Messwert ueber seiner Warnschwelle? */
|
||||
function metricsNeedAttention(metrics: ProxmoxMetrics, now: number): boolean {
|
||||
if (metrics.productType === 'pve') {
|
||||
const nodeWarn = metrics.nodes.some(
|
||||
(n) => isHigh(isKnownNumber(n.cpu) ? n.cpu : null) || isHigh(ratio(n.mem, n.maxmem)),
|
||||
);
|
||||
const storageWarn = metrics.storages.some((s) => isHigh(ratio(s.disk, s.maxdisk)));
|
||||
return nodeWarn || storageWarn;
|
||||
}
|
||||
if (metrics.productType === 'pbs') {
|
||||
return metrics.datastores.some(
|
||||
(ds) =>
|
||||
isHigh(ratio(ds.used, ds.total)) ||
|
||||
isBackupStale(ds.lastBackupAt, now) ||
|
||||
(ds.lastVerifyState !== null && ds.lastVerifyState !== 'ok'),
|
||||
);
|
||||
}
|
||||
return isKnownNumber(metrics.virusCount) && metrics.virusCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesamtzustand eines Servers. Reihenfolge der Pruefung ist Absicht:
|
||||
* ein deaktivierter Server ist „offline & verwaist“, egal was im
|
||||
* Zwischenlager noch steht.
|
||||
*/
|
||||
export function serverHealth(server: ProxmoxServer, now: number = Date.now()): ServerHealth {
|
||||
if (!server.isActive) return 'orphan';
|
||||
const status = server.status;
|
||||
if (!status || status.lastPolledAt === null) return 'idle';
|
||||
if (!status.reachable) return 'down';
|
||||
if (status.metrics && metricsNeedAttention(status.metrics, now)) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/** Neue Liste: down, warn, ok, idle, orphan — innerhalb eines Zustands nach `position`. */
|
||||
export function sortServersByHealth(
|
||||
servers: readonly ProxmoxServer[],
|
||||
now: number = Date.now(),
|
||||
): ProxmoxServer[] {
|
||||
const rank = new Map(servers.map((s) => [s.id, HEALTH_ORDER.indexOf(serverHealth(s, now))]));
|
||||
return [...servers].sort(
|
||||
(a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0) || a.position - b.position,
|
||||
);
|
||||
}
|
||||
|
||||
/** Anzahl Server je Zustand (alle Zustaende vorhanden, auch mit 0). */
|
||||
export function summarizeHealth(
|
||||
servers: readonly ProxmoxServer[],
|
||||
now: number = Date.now(),
|
||||
): Record<ServerHealth, number> {
|
||||
const counts: Record<ServerHealth, number> = { down: 0, warn: 0, ok: 0, idle: 0, orphan: 0 };
|
||||
for (const server of servers) counts[serverHealth(server, now)]++;
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative Zeitangabe („vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“) ueber
|
||||
* `Intl.RelativeTimeFormat` — keine zusaetzliche Abhaengigkeit. Unter einer
|
||||
* Minute (und bei leichter Uhrabweichung in die Zukunft) „jetzt“.
|
||||
* `null`, wenn der Zeitpunkt fehlt oder ungueltig ist.
|
||||
*/
|
||||
export function formatAge(
|
||||
value: number | string | null | undefined,
|
||||
now: number = Date.now(),
|
||||
locale = 'de',
|
||||
): string | null {
|
||||
const ms = toEpochMs(value);
|
||||
if (ms === null) return null;
|
||||
const seconds = Math.max(0, Math.round((now - ms) / 1000));
|
||||
if (seconds < 60) {
|
||||
return new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'auto' }).format(
|
||||
0,
|
||||
'second',
|
||||
);
|
||||
}
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'always' });
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return rtf.format(-minutes, 'minute');
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return rtf.format(-hours, 'hour');
|
||||
return rtf.format(-Math.floor(hours / 24), 'day');
|
||||
}
|
||||
|
||||
/**
|
||||
* „87 %“ — Anteil (0..1) als ganze Prozentzahl nach Sprache (deutsch mit
|
||||
* geschuetztem Leerzeichen). Seit quick-260924-i8v hier statt in ServerCard,
|
||||
* damit Modulseite und Dashboard-Kachel dasselbe Zahlformat nutzen.
|
||||
*/
|
||||
export function formatPercent(fraction: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }).format(
|
||||
fraction,
|
||||
);
|
||||
}
|
||||
|
||||
/** „12.904“ — ganze Zahl mit Tausendertrennzeichen nach Sprache. */
|
||||
export function formatCount(value: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale).format(value);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { MeterLevel, ServerHealth } from './proxmox-status';
|
||||
|
||||
/**
|
||||
* Tailwind-Klassen je Zustand (quick-260924-h7x). Bewusst als vollstaendige,
|
||||
* statische Zeichenketten — Tailwind findet Klassen nur, wenn sie woertlich im
|
||||
* Quelltext stehen, zusammengesetzte Namen wie `bg-status-${x}` gingen verloren.
|
||||
*
|
||||
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
|
||||
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
|
||||
* zweischichtiger Schatten, der untere im Statuston getoent.
|
||||
*
|
||||
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel.
|
||||
*/
|
||||
export const HEALTH_STYLE: Record<
|
||||
ServerHealth,
|
||||
{ fill: string; pill: string; text: string; shadow: string }
|
||||
> = {
|
||||
ok: {
|
||||
fill: 'bg-status-ok',
|
||||
pill: 'bg-status-ok/12 text-status-ok-fg',
|
||||
text: 'text-status-ok-fg',
|
||||
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-ok)]',
|
||||
},
|
||||
warn: {
|
||||
fill: 'bg-status-warn',
|
||||
pill: 'bg-status-warn/12 text-status-warn-fg',
|
||||
text: 'text-status-warn-fg',
|
||||
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-warn)]',
|
||||
},
|
||||
down: {
|
||||
fill: 'bg-status-down',
|
||||
pill: 'bg-status-down/12 text-status-down-fg',
|
||||
text: 'text-status-down-fg',
|
||||
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-down)]',
|
||||
},
|
||||
idle: {
|
||||
fill: 'bg-status-idle',
|
||||
pill: 'bg-status-idle/12 text-status-idle-fg',
|
||||
text: 'text-status-idle-fg',
|
||||
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-idle)]',
|
||||
},
|
||||
// Kein farbiger Schatten: ein verwaister Server soll nicht „leuchten“.
|
||||
orphan: {
|
||||
fill: 'bg-status-orphan',
|
||||
pill: 'bg-status-orphan/12 text-status-orphan-fg',
|
||||
text: 'text-status-orphan-fg',
|
||||
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.04)]',
|
||||
},
|
||||
};
|
||||
|
||||
/** Balkenfarbe je Stufe; `unknown` wird schraffiert gezeichnet (siehe Meter). */
|
||||
export const METER_FILL: Record<Exclude<MeterLevel, 'unknown'>, string> = {
|
||||
ok: 'bg-status-ok',
|
||||
warn: 'bg-status-warn',
|
||||
crit: 'bg-status-down',
|
||||
};
|
||||
|
||||
/** Schriftfarbe der Prozentzahl je Stufe. */
|
||||
export const METER_TEXT: Record<MeterLevel, string> = {
|
||||
ok: 'text-foreground',
|
||||
warn: 'text-status-warn-fg',
|
||||
crit: 'text-status-down-fg',
|
||||
unknown: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
/** Eingelassenes Messfeld („Well“). */
|
||||
export const WELL = 'rounded-lg bg-well shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]';
|
||||
@@ -0,0 +1,118 @@
|
||||
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
/**
|
||||
* Einstellungsformular der Proxmox-Kachel (quick-260924-i8v) fuer
|
||||
* Einstellungen > Dashboard: Titel und Serverauswahl.
|
||||
*/
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// T-I8V-02: `pollServer` nur als Spion — das Formular darf ihn nie rufen.
|
||||
const mockListServers = vi.fn();
|
||||
const mockPollServer = vi.fn();
|
||||
vi.mock('@/lib/proxmox-api', () => ({
|
||||
listServers: (...args: unknown[]) => mockListServers(...args),
|
||||
pollServer: (...args: unknown[]) => mockPollServer(...args),
|
||||
}));
|
||||
|
||||
import { ProxmoxWidgetConfigForm } from './proxmox-widget-config-form';
|
||||
|
||||
function server(id: string, position: number): ProxmoxServer {
|
||||
return {
|
||||
id,
|
||||
tenantId: 't1',
|
||||
name: id,
|
||||
productType: 'pve',
|
||||
baseUrl: 'https://x.intern',
|
||||
authMethod: 'token',
|
||||
tokenId: 'a@pam!b',
|
||||
username: null,
|
||||
tlsRejectUnauthorized: true,
|
||||
isActive: true,
|
||||
pollIntervalMin: 5,
|
||||
position,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
status: null,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ProxmoxWidgetConfigForm (quick-260924-i8v)', () => {
|
||||
it('laedt die Server einmal und zeigt die Auswahl, vorbelegt aus der Konfiguration', async () => {
|
||||
mockListServers.mockResolvedValue([server('pve-a', 0), server('pve-b', 1)]);
|
||||
render(
|
||||
<ProxmoxWidgetConfigForm
|
||||
config={{ title: 'Rechenzentrum', serverIds: ['pve-b'] }}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Lade Serverliste...')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('checkbox', { name: /pve-b/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /pve-a/ })).not.toBeChecked();
|
||||
expect((screen.getByLabelText('Titel') as HTMLInputElement).value).toBe('Rechenzentrum');
|
||||
expect(mockListServers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Fehlersatz, wenn die Liste nicht geladen werden kann', async () => {
|
||||
mockListServers.mockRejectedValue(new Error('500'));
|
||||
render(<ProxmoxWidgetConfigForm config={{}} onChange={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Die Serverliste konnte nicht geladen werden.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Leersatz ohne Server', async () => {
|
||||
mockListServers.mockResolvedValue([]);
|
||||
render(<ProxmoxWidgetConfigForm config={{}} onChange={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('checkbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('Titelaenderung ruft onChange({ title }) mit dem rohen Tippwert', async () => {
|
||||
mockListServers.mockResolvedValue([server('pve-a', 0)]);
|
||||
const onChange = vi.fn();
|
||||
render(<ProxmoxWidgetConfigForm config={{}} onChange={onChange} />);
|
||||
await screen.findByRole('checkbox');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Keller ' } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ title: 'Keller ' });
|
||||
});
|
||||
|
||||
it('Auswahlaenderung ruft onChange({ serverIds })', async () => {
|
||||
mockListServers.mockResolvedValue([server('pve-a', 0), server('pve-b', 1)]);
|
||||
const onChange = vi.fn();
|
||||
render(<ProxmoxWidgetConfigForm config={{ serverIds: ['pve-a'] }} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: /pve-b/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ serverIds: ['pve-a', 'pve-b'] });
|
||||
});
|
||||
|
||||
it('ruft nie die manuelle Abfrage auf (T-I8V-02)', async () => {
|
||||
mockListServers.mockResolvedValue([server('pve-a', 0)]);
|
||||
render(<ProxmoxWidgetConfigForm config={{}} onChange={vi.fn()} />);
|
||||
|
||||
await screen.findByRole('checkbox');
|
||||
await waitFor(() => expect(mockListServers).toHaveBeenCalledTimes(1));
|
||||
expect(mockPollServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { resolveProxmoxWidgetConfig } from '@/components/dashboard/widgets/proxmox-widget-model';
|
||||
import { ProxmoxServerPicker } from '@/components/proxmox/proxmox-server-picker';
|
||||
// T-I8V-02: nur die Leseabfrage des Zwischenlagers, nie die manuelle Abfrage.
|
||||
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||
|
||||
interface ProxmoxWidgetConfigFormProps {
|
||||
config: Record<string, unknown>;
|
||||
onChange: (cfg: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einstellungen der Proxmox-Kachel unter Einstellungen > Dashboard
|
||||
* (quick-260924-i8v): Titel und Serverauswahl. Laedt die Serverliste einmal;
|
||||
* dieselbe Auswahl gibt es an der Kachel im Bearbeitungsmodus (dort auch
|
||||
* fuer Kacheln auf weiteren Reitern, die diese Seite nicht zeigt).
|
||||
*/
|
||||
export function ProxmoxWidgetConfigForm({ config, onChange }: ProxmoxWidgetConfigFormProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const tp = useTranslations('proxmox');
|
||||
const { title: rawTitle, serverIds } = resolveProxmoxWidgetConfig(config);
|
||||
// Anzeige getrimmt (Leerraum-Titel sind fachlich „kein Titel“), gesendet
|
||||
// wird der rohe Tippwert — Muster FavoritesConfig.
|
||||
const title = rawTitle.trim() === '' ? '' : rawTitle;
|
||||
|
||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
listServers()
|
||||
.then((list) => {
|
||||
if (!cancelled) setServers(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
let serverPart: ReactNode;
|
||||
if (loadFailed) {
|
||||
serverPart = <p className="text-sm text-muted-foreground">{tp('loadError')}</p>;
|
||||
} else if (servers === null) {
|
||||
serverPart = <p className="text-sm text-muted-foreground">{tp('loading')}</p>;
|
||||
} else if (servers.length === 0) {
|
||||
serverPart = <p className="text-sm text-muted-foreground">{t('proxmox.empty')}</p>;
|
||||
} else {
|
||||
serverPart = (
|
||||
<ProxmoxServerPicker
|
||||
servers={servers}
|
||||
selectedIds={serverIds}
|
||||
onChange={(next) => onChange({ serverIds: next })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="proxmox-widget-title" className="mb-1 block text-sm text-foreground">
|
||||
{t('proxmox.titleLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="proxmox-widget-title"
|
||||
type="text"
|
||||
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||
value={title}
|
||||
placeholder={t('proxmox.titlePlaceholder')}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{serverPart}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,15 @@ vi.mock('@/components/settings/search-provider-form', () => ({
|
||||
SearchProviderForm: () => null,
|
||||
}));
|
||||
|
||||
// quick-260924-i8v: das Proxmox-Formular laedt die Serverliste; `pollServer`
|
||||
// nur als Spion (T-I8V-02).
|
||||
const mockListServers = vi.fn();
|
||||
const mockPollServer = vi.fn();
|
||||
vi.mock('@/lib/proxmox-api', () => ({
|
||||
listServers: (...args: unknown[]) => mockListServers(...args),
|
||||
pollServer: (...args: unknown[]) => mockPollServer(...args),
|
||||
}));
|
||||
|
||||
const clockTexts = (de as { widgets: { clock: Record<string, string> } }).widgets.clock;
|
||||
const cal = (de as { widgets: { calendar: Record<string, string> } }).widgets.calendar;
|
||||
|
||||
@@ -285,3 +294,42 @@ describe('WidgetSettingsPanel — Bilderrahmen-Kopfzeile (Kosmetik nach quick-26
|
||||
expect(screen.getByRole('button', { name: /Bilderrahmen #3/ })).not.toHaveTextContent('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WidgetSettingsPanel — Proxmox (quick-260924-i8v)', () => {
|
||||
it('Test PX1: der Zweig rendert das Proxmox-Formular, Kopfzeile zeigt "— Titel", Auswahl ruft updateWidgetConfig/onWidgetUpdate', async () => {
|
||||
mockListServers.mockResolvedValue([
|
||||
{
|
||||
id: 'srv-a',
|
||||
name: 'pve-a',
|
||||
productType: 'pve',
|
||||
position: 0,
|
||||
isActive: true,
|
||||
status: null,
|
||||
},
|
||||
]);
|
||||
const onWidgetUpdate = vi.fn();
|
||||
const widget = { id: 'px1', widgetType: 'proxmox', config: { title: 'Rechenzentrum' } };
|
||||
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
|
||||
|
||||
const header = screen.getByRole('button', { name: /Proxmox #1/ });
|
||||
expect(header).toHaveTextContent('— Rechenzentrum');
|
||||
fireEvent.click(header);
|
||||
|
||||
expect((screen.getByLabelText('Titel') as HTMLInputElement).value).toBe('Rechenzentrum');
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: /pve-a/ }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(onWidgetUpdate).toHaveBeenCalledWith('px1', { serverIds: ['srv-a'] }),
|
||||
);
|
||||
expect(updateWidgetConfig).toHaveBeenCalledWith('px1', { serverIds: ['srv-a'] });
|
||||
expect(mockPollServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test PX2: ohne Titel kein Zusatz in der Kopfzeile', () => {
|
||||
mockListServers.mockResolvedValue([]);
|
||||
const widget = { id: 'px2', widgetType: 'proxmox', config: {} };
|
||||
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Proxmox #1/ })).not.toHaveTextContent('—');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
|
||||
import { ProxmoxWidgetConfigForm } from '@/components/settings/proxmox-widget-config-form';
|
||||
import { SearchProviderForm } from '@/components/settings/search-provider-form';
|
||||
import { XframeConfigForm } from '@/components/settings/xframe-config-form';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
@@ -122,7 +123,8 @@ export function WidgetSettingsPanel({
|
||||
{t(def.nameKey)} #{idx + 1}
|
||||
{(widget.widgetType === 'note' ||
|
||||
widget.widgetType === 'favorites' ||
|
||||
widget.widgetType === 'xframe') &&
|
||||
widget.widgetType === 'xframe' ||
|
||||
widget.widgetType === 'proxmox') &&
|
||||
typeof widget.config.title === 'string' &&
|
||||
widget.config.title.trim() !== '' && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
@@ -232,6 +234,16 @@ export function WidgetSettingsPanel({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Proxmox: Titel und Serverauswahl (quick-260924-i8v) */}
|
||||
{widget.widgetType === 'proxmox' && (
|
||||
<ProxmoxWidgetConfigForm
|
||||
config={widget.config}
|
||||
onChange={(cfg) =>
|
||||
handleConfigChange(widget.id, cfg)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sendBugReport } from './bug-report-api';
|
||||
import { captureScreenshot, sendBugReport } from './bug-report-api';
|
||||
|
||||
const { mockToPng } = vi.hoisted(() => ({ mockToPng: vi.fn() }));
|
||||
vi.mock('html-to-image', () => ({ toPng: mockToPng }));
|
||||
|
||||
/**
|
||||
* bug-report-api.test — NEU (quick-260918-gza). Bisher pruefte nur der
|
||||
@@ -85,3 +88,42 @@ describe('sendBugReport (quick-260918-gza)', () => {
|
||||
).resolves.toEqual({ ok: false, status: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureScreenshot (Bild von fremder Adresse)', () => {
|
||||
beforeEach(() => {
|
||||
mockToPng.mockReset();
|
||||
});
|
||||
|
||||
it('ersetzt nicht ladbare Bilder statt die Aufnahme abzubrechen', async () => {
|
||||
mockToPng.mockResolvedValue('data:image/png;base64,AAA');
|
||||
|
||||
await expect(captureScreenshot()).resolves.toBe('data:image/png;base64,AAA');
|
||||
|
||||
const opts = mockToPng.mock.calls[0][1];
|
||||
expect(opts.imagePlaceholder).toMatch(/^data:image\/png;base64,/);
|
||||
expect(opts.onImageErrorHandler()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('versucht es ein zweites Mal ohne Bilder und Rahmen, wenn der erste Versuch scheitert', async () => {
|
||||
mockToPng
|
||||
.mockRejectedValueOnce(new Error('tainted'))
|
||||
.mockResolvedValueOnce('data:image/png;base64,BBB');
|
||||
|
||||
await expect(captureScreenshot()).resolves.toBe('data:image/png;base64,BBB');
|
||||
|
||||
expect(mockToPng).toHaveBeenCalledTimes(2);
|
||||
const filter = mockToPng.mock.calls[1][1].filter as (n: Node) => boolean;
|
||||
expect(filter(document.createElement('img'))).toBe(false);
|
||||
expect(filter(document.createElement('iframe'))).toBe(false);
|
||||
expect(filter(document.createElement('div'))).toBe(true);
|
||||
const ignored = document.createElement('div');
|
||||
ignored.dataset.bugReportIgnore = 'true';
|
||||
expect(filter(ignored)).toBe(false);
|
||||
});
|
||||
|
||||
it('liefert null, wenn auch der zweite Versuch scheitert', async () => {
|
||||
mockToPng.mockRejectedValue(new Error('kaputt'));
|
||||
|
||||
await expect(captureScreenshot()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,23 +42,50 @@ export function computeCaptureSize(
|
||||
};
|
||||
}
|
||||
|
||||
/** 1x1 transparentes PNG — Ersatz fuer Bilder, die sich nicht laden lassen. */
|
||||
const TRANSPARENT_PIXEL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
|
||||
function isIgnoredNode(n: Node): boolean {
|
||||
return n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu
|
||||
* werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne.
|
||||
*
|
||||
* `html-to-image` laedt jedes `<img>` per `fetch` nach. Ein einziges Bild
|
||||
* von fremder Adresse ohne CORS (z. B. ein direkt von der Website geholtes
|
||||
* Favoriten-Symbol) liess bisher die GANZE Aufnahme scheitern — im Dialog
|
||||
* blieb das Haekchen „Bildschirmfoto beifuegen“ dann gesperrt. Deshalb:
|
||||
* nicht ladbare Bilder werden zum transparenten Pixel, und scheitert die
|
||||
* Aufnahme trotzdem, folgt ein zweiter Versuch ganz ohne Bilder und Rahmen.
|
||||
*/
|
||||
export async function captureScreenshot(): Promise<string | null> {
|
||||
try {
|
||||
const { toPng } = await import('html-to-image');
|
||||
const node = document.body;
|
||||
const size = computeCaptureSize(node.scrollWidth, node.scrollHeight);
|
||||
return await toPng(node, {
|
||||
const base = {
|
||||
pixelRatio: 1,
|
||||
skipFonts: true,
|
||||
cacheBust: true,
|
||||
canvasWidth: size.width,
|
||||
canvasHeight: size.height,
|
||||
filter: (n: Node) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'),
|
||||
imagePlaceholder: TRANSPARENT_PIXEL,
|
||||
onImageErrorHandler: () => undefined,
|
||||
};
|
||||
try {
|
||||
return await toPng(node, { ...base, filter: (n: Node) => !isIgnoredNode(n) });
|
||||
} catch {
|
||||
return await toPng(node, {
|
||||
...base,
|
||||
filter: (n: Node) =>
|
||||
!isIgnoredNode(n) &&
|
||||
!(n instanceof HTMLImageElement) &&
|
||||
!(n instanceof HTMLIFrameElement),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FAVORITE_ICON_MAX_BYTES,
|
||||
FavoriteRequestError,
|
||||
createFavorite,
|
||||
removeFavoriteIcon,
|
||||
updateFavorite,
|
||||
uploadFavoriteIcon,
|
||||
} from './favorites-api';
|
||||
|
||||
/**
|
||||
* favorites-api.test — NEU (quick-260923-lrr).
|
||||
*
|
||||
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
|
||||
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
|
||||
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
|
||||
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit
|
||||
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische
|
||||
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('favorites-api (quick-260923-lrr)', () => {
|
||||
describe('uploadFavoriteIcon', () => {
|
||||
it('schickt POST an /favorites/<id>/icon mit credentials include, FormData-Feld icon, OHNE eigenen Content-Type; 200 -> Zeile', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ id: 'fav-1', uploadedIconMime: 'image/png', iconVersion: 1 }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const result = await uploadFavoriteIcon('fav-1', file);
|
||||
|
||||
expect(result.id).toBe('fav-1');
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/favorites/fav-1/icon')).toBe(true);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.headers).toBeUndefined();
|
||||
const body = init.body as FormData;
|
||||
expect([...body.keys()]).toEqual(['icon']);
|
||||
expect(body.get('icon')).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it('Datei groesser 512 KB -> FavoriteRequestError iconTooLarge, fetch NICHT aufgerufen', async () => {
|
||||
const big = new File([new Uint8Array(FAVORITE_ICON_MAX_BYTES + 1)], 'gross.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', big).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconTooLarge');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('413 -> iconTooLarge', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 413 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconTooLarge');
|
||||
});
|
||||
|
||||
it('400 -> iconInvalidType', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.txt', { type: 'text/plain' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconInvalidType');
|
||||
});
|
||||
|
||||
it('500 -> iconUploadFailed', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('kaputt', { status: 500 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUploadFailed');
|
||||
});
|
||||
});
|
||||
|
||||
it('removeFavoriteIcon schickt DELETE an /favorites/<id>/icon mit credentials include, liefert die Zeile', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'fav-1', uploadedIconMime: null, iconVersion: 2 }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await removeFavoriteIcon('fav-1');
|
||||
|
||||
expect(result.uploadedIconMime).toBeNull();
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/favorites/fav-1/icon')).toBe(true);
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => {
|
||||
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
|
||||
const err = await createFavorite({
|
||||
widgetId: 'w1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
}).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
});
|
||||
|
||||
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 500 }));
|
||||
|
||||
const err = await createFavorite({ widgetId: 'w1', title: 'X', url: 'https://x.invalid' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
});
|
||||
|
||||
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 500 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { title: 'Neu' }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,22 @@
|
||||
* Mirrors the NestJS FavoritesController routes (08-03).
|
||||
* All calls use credentials: 'include' for cookie-based auth.
|
||||
* PUT /favorites/order (reorderFavorites) added 260917-jdd.
|
||||
*
|
||||
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
|
||||
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
|
||||
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
|
||||
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API
|
||||
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit
|
||||
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen
|
||||
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
|
||||
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
/** Hoechstgroesse eines hochgeladenen Symbols — muss der API-Konstante entsprechen. */
|
||||
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
|
||||
|
||||
export interface FavoriteLink {
|
||||
id: string;
|
||||
widgetId: string;
|
||||
@@ -14,6 +26,33 @@ export interface FavoriteLink {
|
||||
url: string;
|
||||
iconUrl: string | null;
|
||||
position: number;
|
||||
/** 260923-lrr — Typ eines hochgeladenen eigenen Symbols, null = keins. */
|
||||
uploadedIconMime?: string | null;
|
||||
/** 260923-lrr — Zaehler fuer die Symbol-Adresse (`?v=`), macht den 24h-Zwischenspeicher nach einer Aenderung sofort ungueltig. */
|
||||
iconVersion?: number;
|
||||
}
|
||||
|
||||
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
|
||||
export type FavoriteErrorReason =
|
||||
| 'iconUrlUnreachable'
|
||||
| 'iconTooLarge'
|
||||
| 'iconInvalidType'
|
||||
| 'iconUploadFailed';
|
||||
|
||||
/**
|
||||
* Ein abgewiesener Favoriten-Aufruf mit einem uebersetzbaren Grund
|
||||
* (260923-lrr) — das Widget bildet `reason` auf `t('favorites.' + reason)`
|
||||
* ab, damit die Meldung sprachrichtig ist statt einer festen deutschen
|
||||
* Zeichenkette aus dem Klienten.
|
||||
*/
|
||||
export class FavoriteRequestError extends Error {
|
||||
public readonly reason: FavoriteErrorReason;
|
||||
|
||||
constructor(reason: FavoriteErrorReason) {
|
||||
super(reason);
|
||||
this.name = 'FavoriteRequestError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +72,8 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
|
||||
/**
|
||||
* Create a new favorite link.
|
||||
* Server-side icon discovery runs automatically if iconUrl is not provided.
|
||||
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden
|
||||
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
*/
|
||||
export async function createFavorite(payload: {
|
||||
widgetId: string;
|
||||
@@ -46,6 +87,7 @@ export async function createFavorite(payload: {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to create favorite');
|
||||
|
||||
return res.json();
|
||||
@@ -54,6 +96,8 @@ export async function createFavorite(payload: {
|
||||
/**
|
||||
* Update an existing favorite link.
|
||||
* Pass iconUrl: null to clear a stored icon.
|
||||
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 ->
|
||||
* `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
*/
|
||||
export async function updateFavorite(
|
||||
id: string,
|
||||
@@ -65,6 +109,7 @@ export async function updateFavorite(
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to update favorite');
|
||||
|
||||
return res.json();
|
||||
@@ -100,3 +145,43 @@ export async function deleteFavorite(id: string): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete favorite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt ein eigenes Symbol fuer einen Favoriten hoch (260923-lrr). Muster
|
||||
* `uploadDashboardImage` (dashboard-images-api.ts): FormData ohne eigenen
|
||||
* Content-Type-Header — die Multipart-Grenze setzt der Browser. Die
|
||||
* Groessenpruefung laeuft VOR dem Netzwerkaufruf (kein Aufruf fuer eine
|
||||
* offensichtlich zu grosse Datei).
|
||||
*/
|
||||
export async function uploadFavoriteIcon(id: string, file: File): Promise<FavoriteLink> {
|
||||
if (file.size > FAVORITE_ICON_MAX_BYTES) {
|
||||
throw new FavoriteRequestError('iconTooLarge');
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('icon', file, file.name);
|
||||
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}/icon`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
|
||||
if (res.status === 413) throw new FavoriteRequestError('iconTooLarge');
|
||||
if (res.status === 400) throw new FavoriteRequestError('iconInvalidType');
|
||||
throw new FavoriteRequestError('iconUploadFailed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein zuvor hochgeladenes Symbol wieder (260923-lrr); die Kachel
|
||||
* faellt danach auf `iconUrl` bzw. automatische Erkennung zurueck.
|
||||
*/
|
||||
export async function removeFavoriteIcon(id: string): Promise<FavoriteLink> {
|
||||
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}/icon`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to remove favorite icon');
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -336,7 +336,16 @@
|
||||
"error": "Fehler beim Laden der Favoriten",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel (optional)",
|
||||
"viewModeLabel": "Ansicht wechseln"
|
||||
"viewModeLabel": "Ansicht wechseln",
|
||||
"iconUrlPlaceholder": "Logo-Adresse (optional)",
|
||||
"iconUploadLabel": "Eigenes Symbol hochladen",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.",
|
||||
"iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.",
|
||||
"iconRemoveButton": "Hochgeladenes Symbol entfernen",
|
||||
"iconUrlUnreachable": "Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.",
|
||||
"iconTooLarge": "Die Datei ist zu groß – erlaubt sind höchstens 512 KB.",
|
||||
"iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.",
|
||||
"iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden."
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stoppuhr",
|
||||
@@ -408,6 +417,23 @@
|
||||
"zoomLabel": "Vergrößerung der ganzen Seite",
|
||||
"zoomOption": "{n} %",
|
||||
"readOnly": "Nur anzeigen – Klicks und Scrollen im Rahmen sperren"
|
||||
},
|
||||
"proxmox": {
|
||||
"name": "Proxmox",
|
||||
"description": "Zustand Ihrer Proxmox-Server auf einen Blick",
|
||||
"allOk": "Alles in Ordnung",
|
||||
"empty": "Noch kein Proxmox-Server eingetragen.",
|
||||
"guests": "{running}/{total} {total, plural, one {Gast läuft} other {Gäste laufen}}",
|
||||
"noGuests": "keine Gäste",
|
||||
"load": "Auslastung {percent}",
|
||||
"backupAgo": "Sicherung {age}",
|
||||
"mailIn": "{count} eingehend",
|
||||
"selectionGone": "Die ausgewählten Server gibt es nicht mehr. Wählen Sie im Bearbeitungsmodus andere aus.",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel (optional)",
|
||||
"serversLabel": "Angezeigte Server",
|
||||
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
|
||||
"chooseServers": "Server auswählen"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -711,24 +737,36 @@
|
||||
"emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.",
|
||||
"card": {
|
||||
"unknownValue": "unbekannt",
|
||||
"lastPolledLabel": "Letzte Abfrage",
|
||||
"lastPolledAgo": "Letzte Abfrage {age}",
|
||||
"notPolledYet": "Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.",
|
||||
"notPolledYetAutomatic": "Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.",
|
||||
"lastOkLabel": "Letzte erfolgreiche Messung",
|
||||
"lastOkAgo": "Zuletzt erreichbar {age}",
|
||||
"orphanText": "Dieser Server ist deaktiviert und wird nicht mehr abgefragt.",
|
||||
"orphanAdminHint": "Sie können ihn in den Einstellungen wieder aktivieren.",
|
||||
"refresh": "Jetzt aktualisieren",
|
||||
"refreshing": "Wird aktualisiert...",
|
||||
"settingsLink": "Zu den Einstellungen",
|
||||
"settingsNav": "Einstellungen",
|
||||
"product": {
|
||||
"pve": "Virtualisierung",
|
||||
"pbs": "Datensicherung",
|
||||
"pmg": "Mail-Gateway"
|
||||
},
|
||||
"pve": {
|
||||
"nodeCount": "Knoten",
|
||||
"guests": "{running} laufend / {stopped} gestoppt",
|
||||
"cpu": "Prozessorlast",
|
||||
"mem": "Speicher"
|
||||
"running": "{count, plural, one {Gast läuft} other {Gäste laufen}}",
|
||||
"stopped": "gestoppt",
|
||||
"nodes": "{count, plural, one {# Knoten} other {# Knoten}}",
|
||||
"cpu": "Prozessor",
|
||||
"mem": "Arbeitsspeicher"
|
||||
},
|
||||
"pbs": {
|
||||
"used": "Belegt",
|
||||
"lastBackup": "Letzte Sicherung",
|
||||
"lastBackupAgo": "Letzte Sicherung {age}",
|
||||
"noBackupYet": "noch keine Sicherung",
|
||||
"verifyState": "Letzte Prüfung"
|
||||
"verifyOk": "Prüfung in Ordnung",
|
||||
"verifyFailed": "Prüfung fehlgeschlagen",
|
||||
"verifyUnknown": "Prüfung unbekannt",
|
||||
"noDatastores": "Keine Datenspeicher gemeldet."
|
||||
},
|
||||
"pmg": {
|
||||
"countIn": "Eingehend",
|
||||
@@ -737,6 +775,21 @@
|
||||
"virusCount": "Viren"
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"ok": "In Ordnung",
|
||||
"warn": "Warnung",
|
||||
"down": "Nicht erreichbar",
|
||||
"idle": "Noch nicht abgefragt",
|
||||
"orphan": "Offline & verwaist"
|
||||
},
|
||||
"legend": {
|
||||
"ok": "{count, plural, one {in Ordnung} other {in Ordnung}}",
|
||||
"warn": "{count, plural, one {mit Warnung} other {mit Warnung}}",
|
||||
"down": "{count, plural, one {nicht erreichbar} other {nicht erreichbar}}",
|
||||
"idle": "{count, plural, one {noch nicht abgefragt} other {noch nicht abgefragt}}",
|
||||
"orphan": "{count, plural, one {offline & verwaist} other {offline & verwaist}}",
|
||||
"barLabel": "Zustand der Server: {summary}"
|
||||
},
|
||||
"errors": {
|
||||
"netz": "Der Server ist nicht erreichbar. Bitte prüfen Sie die Adresse und die Netzwerkverbindung.",
|
||||
"zugang": "Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.",
|
||||
|
||||
@@ -336,7 +336,16 @@
|
||||
"error": "Error loading favorites",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Title (optional)",
|
||||
"viewModeLabel": "Switch view"
|
||||
"viewModeLabel": "Switch view",
|
||||
"iconUrlPlaceholder": "Logo URL (optional)",
|
||||
"iconUploadLabel": "Upload custom icon",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.",
|
||||
"iconUploadedHint": "A custom icon has been uploaded for this favorite.",
|
||||
"iconRemoveButton": "Remove uploaded icon",
|
||||
"iconUrlUnreachable": "The image at this address could not be loaded. The site likely blocks automated requests (for example via a Cloudflare check) or is unreachable. Please upload the icon instead.",
|
||||
"iconTooLarge": "The file is too large – at most 512 KB is allowed.",
|
||||
"iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.",
|
||||
"iconUploadFailed": "The icon could not be uploaded."
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stopwatch",
|
||||
@@ -408,6 +417,23 @@
|
||||
"zoomLabel": "Zoom of the full page",
|
||||
"zoomOption": "{n} %",
|
||||
"readOnly": "View only – block clicks and scrolling inside the frame"
|
||||
},
|
||||
"proxmox": {
|
||||
"name": "Proxmox",
|
||||
"description": "Health of your Proxmox servers at a glance",
|
||||
"allOk": "All good",
|
||||
"empty": "No Proxmox server added yet.",
|
||||
"guests": "{running}/{total} {total, plural, one {guest running} other {guests running}}",
|
||||
"noGuests": "no guests",
|
||||
"load": "Load {percent}",
|
||||
"backupAgo": "Backup {age}",
|
||||
"mailIn": "{count} incoming",
|
||||
"selectionGone": "The selected servers no longer exist. Choose others in edit mode.",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Title (optional)",
|
||||
"serversLabel": "Servers shown",
|
||||
"serversHint": "With nothing selected, the tile shows all servers.",
|
||||
"chooseServers": "Choose servers"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -711,24 +737,36 @@
|
||||
"emptyState": "No server configured yet. Add one in the module settings.",
|
||||
"card": {
|
||||
"unknownValue": "unknown",
|
||||
"lastPolledLabel": "Last poll",
|
||||
"lastPolledAgo": "Last poll {age}",
|
||||
"notPolledYet": "No poll has run yet. Click \"{refreshLabel}\" above.",
|
||||
"notPolledYetAutomatic": "No poll has run yet. The values will appear after the next automatic poll.",
|
||||
"lastOkLabel": "Last successful measurement",
|
||||
"lastOkAgo": "Last reachable {age}",
|
||||
"orphanText": "This server is deactivated and is no longer polled.",
|
||||
"orphanAdminHint": "You can reactivate it in the settings.",
|
||||
"refresh": "Refresh now",
|
||||
"refreshing": "Refreshing...",
|
||||
"settingsLink": "Go to settings",
|
||||
"settingsNav": "Settings",
|
||||
"product": {
|
||||
"pve": "Virtualization",
|
||||
"pbs": "Backup",
|
||||
"pmg": "Mail gateway"
|
||||
},
|
||||
"pve": {
|
||||
"nodeCount": "Nodes",
|
||||
"guests": "{running} running / {stopped} stopped",
|
||||
"cpu": "CPU load",
|
||||
"running": "{count, plural, one {guest running} other {guests running}}",
|
||||
"stopped": "stopped",
|
||||
"nodes": "{count, plural, one {# node} other {# nodes}}",
|
||||
"cpu": "CPU",
|
||||
"mem": "Memory"
|
||||
},
|
||||
"pbs": {
|
||||
"used": "Used",
|
||||
"lastBackup": "Last backup",
|
||||
"lastBackupAgo": "Last backup {age}",
|
||||
"noBackupYet": "no backup yet",
|
||||
"verifyState": "Last verification"
|
||||
"verifyOk": "Verification OK",
|
||||
"verifyFailed": "Verification failed",
|
||||
"verifyUnknown": "Verification unknown",
|
||||
"noDatastores": "No datastores reported."
|
||||
},
|
||||
"pmg": {
|
||||
"countIn": "Inbound",
|
||||
@@ -737,6 +775,21 @@
|
||||
"virusCount": "Viruses"
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"ok": "Healthy",
|
||||
"warn": "Warning",
|
||||
"down": "Unreachable",
|
||||
"idle": "Not polled yet",
|
||||
"orphan": "Offline & orphaned"
|
||||
},
|
||||
"legend": {
|
||||
"ok": "{count, plural, one {healthy} other {healthy}}",
|
||||
"warn": "{count, plural, one {with warning} other {with warnings}}",
|
||||
"down": "{count, plural, one {unreachable} other {unreachable}}",
|
||||
"idle": "{count, plural, one {not polled yet} other {not polled yet}}",
|
||||
"orphan": "{count, plural, one {offline & orphaned} other {offline & orphaned}}",
|
||||
"barLabel": "Server health: {summary}"
|
||||
},
|
||||
"errors": {
|
||||
"netz": "The server is unreachable. Please check the address and the network connection.",
|
||||
"zugang": "Access was denied. Please check the username and password, or the token details.",
|
||||
|
||||
@@ -189,4 +189,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'gemessene',
|
||||
'Messung',
|
||||
'Prozessorlast',
|
||||
// quick-260924-h7x: Proxmox-Seite neu gestaltet — korrektes Deutsch mit „ss“
|
||||
'Prozessor',
|
||||
'Arbeitsspeicher',
|
||||
];
|
||||
|
||||
@@ -82,12 +82,13 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
||||
| Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein |
|
||||
| Notizen | Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken |
|
||||
| Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt. Im Bearbeitungsformular tragen Sie stattdessen eine eigene Logo-Adresse ein oder laden ein eigenes Symbol hoch (PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB) — ein hochgeladenes Symbol hat Vorrang und lässt sich über „Hochgeladenes Symbol entfernen“ wieder zurücknehmen; blockiert eine Seite automatische Abrufe (z. B. durch eine Cloudflare-Prüfung), meldet Tessera das beim Speichern |
|
||||
| Stoppuhr | Zeitmessung mit Rundenzeiten |
|
||||
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
||||
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
||||
| Proxmox | Zeigt den Zustand Ihrer Proxmox-Server: oben ein farbiger Balken mit „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar, 1 mit Warnung“, darunter die Server, auffällige zuerst, jeweils mit einer Kennzahl (laufende Gäste, Auslastung bei einer Warnung, letzte Sicherung oder eingehende E-Mails). Ein Klick auf einen Server öffnet die Proxmox-Seite. Die Kachel steht nur Benutzern zur Verfügung, die das Proxmox-Modul nutzen dürfen. Sie aktualisiert sich jede Minute aus dem zuletzt gespeicherten Stand und fragt die Server dabei nicht neu ab. Einen Titel und die Auswahl der angezeigten Server (ohne Auswahl: alle) legen Sie im Bearbeitungsmodus direkt an der Kachel über das Titelfeld und „Server auswählen“ fest oder unter Einstellungen > Dashboard. In einer schmalen Kachel stehen nur Punkte und Namen, in einer sehr kleinen nur Balken und Zusammenfassung |
|
||||
|
||||
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen und XFrame gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen, XFrame und Proxmox gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame, Titel und angezeigte Server der Proxmox-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
|
||||
## Marktplatz
|
||||
|
||||
@@ -162,6 +163,8 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
|
||||
|
||||
**„Verbindung testen"** prüft den hinterlegten Zugang gegen den echten Server, ohne die zuletzt gemessenen Werte auf der Modulseite zu überschreiben. Bei Erfolg erscheint eine grüne Bestätigung; scheitert der Test, nennt die Meldung die Ursache in Alltagssprache — etwa „nicht erreichbar", „Zugang abgelehnt", „Rechte reichen nicht" oder ein Zertifikatsproblem.
|
||||
|
||||
**Die Dashboard-Kachel „Proxmox“:** Wer das Modul nutzen darf, kann sich den Zustand der Server auch als Kachel auf das Dashboard legen („Widget hinzufügen“, ganz unten im Katalog). Sie zeigt den farbigen Balken der Modulseite in klein, darunter in Worten, ob alles in Ordnung ist, und die Server mit je einer Kennzahl; ein Klick auf einen Server öffnet diese Seite. Die Kachel liest jede Minute den zuletzt gespeicherten Stand und löst selbst nie eine Abfrage bei Proxmox aus — wie aktuell die Werte sind, bestimmt weiterhin der Abfrageabstand je Server. Welche Server die Kachel zeigt, stellen Sie im Bearbeitungsmodus an der Kachel oder unter Einstellungen > Dashboard ein.
|
||||
|
||||
## Persönliche Einstellungen
|
||||
|
||||
Öffnen Sie **Einstellungen** über das Benutzermenü oben rechts. Der Bereich gliedert sich in zwei Kategorien in der linken Unterleiste:
|
||||
@@ -171,7 +174,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
|
||||
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
||||
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
||||
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert.
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert. Bei der Proxmox-Kachel legen Sie einen Titel fest und haken an, welche Server sie zeigt; ohne Haken zeigt sie alle Server. Dieselbe Auswahl erreichen Sie im Bearbeitungsmodus des Dashboards auch direkt an der Kachel über „Server auswählen“ — das ist der Weg für eine Proxmox-Kachel auf einem weiteren Reiter, denn diese Seite zeigt nur die Kacheln des ersten Reiters.
|
||||
|
||||
**Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.
|
||||
|
||||
|
||||
@@ -221,6 +221,35 @@ der `.env` als `IMAGE_TAG` steht, siehe Kapitel 9.)
|
||||
sie auf dem Server abweichen.) Liegt `StartedAt` **vor** `Created` des Images, läuft
|
||||
noch die alte Version – dann `--force-recreate` nachholen.
|
||||
|
||||
**Hinweis für die erste Version nach 1.3.1 – alte Bildspalte fällt weg:** Diese
|
||||
Version entfernt die alte Spalte, in der die Bilder des Bilderrahmen-Widgets
|
||||
früher in der Datenbank lagen (Migration `20260924120000_dashboard_image_drop_data`).
|
||||
Die Bilder selbst liegen seit 1.3.1 im Volume `user-files`; 1.3.1 hat sie beim
|
||||
ersten Start von selbst dorthin umgezogen. Hat ein Server 1.3.1 übersprungen, wäre
|
||||
der Umzug dort nie gelaufen – dann bricht die Migration ab, **bevor** sie etwas
|
||||
ändert, und der `api`-Container startet nicht. In `docker compose logs api` steht
|
||||
dann die Meldung „DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug
|
||||
(quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut
|
||||
deployen“. Es gehen dabei keine Bilder verloren. Abhilfe in drei Schritten:
|
||||
|
||||
1. Den abgebrochenen Versuch als zurückgenommen vermerken – sonst verweigert
|
||||
auch 1.3.1 jeden Start, weil Prisma eine fehlgeschlagene Migration in der
|
||||
Datenbank sieht (Fehler `P3009`):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec db \
|
||||
psql -U tessera -d tessera -c \
|
||||
"UPDATE _prisma_migrations SET rolled_back_at = now() WHERE migration_name = '20260924120000_dashboard_image_drop_data' AND finished_at IS NULL;"
|
||||
```
|
||||
|
||||
2. In der `.env` `IMAGE_TAG=v1.3.1` setzen, `pull` und `--force-recreate` wie
|
||||
oben, den Start abwarten – der Umzug läuft dabei von selbst.
|
||||
3. `IMAGE_TAG` zurück auf den Kanal (`live` bzw. `beta`) und erneut einspielen;
|
||||
jetzt läuft die Migration durch.
|
||||
|
||||
(Dieser Ablauf ist am 24.09.2026 in einer Wegwerf-Datenbank vollständig
|
||||
durchgespielt.)
|
||||
|
||||
## 5. Datenbank-Migrationen
|
||||
|
||||
Ein separater Migrationsschritt ist **nicht** nötig. Der `api`-Container führt
|
||||
@@ -285,9 +314,12 @@ Restores stattfinden.
|
||||
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
|
||||
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, Bilder des
|
||||
Bilderrahmen-Widgets unter `user-files/dashboard-images/<Benutzerkennung>/`,
|
||||
generierte DKV-Exporte unter `user-files/`, siehe
|
||||
Symbole des Favoriten-Widgets unter
|
||||
`user-files/favorite-icons/<Benutzerkennung>/`, generierte DKV-Exporte unter
|
||||
`user-files/`, siehe
|
||||
`apps/api/src/user/user.controller.ts`,
|
||||
`apps/api/src/dashboard/dashboard-images.service.ts` und
|
||||
`apps/api/src/dashboard/dashboard-images.service.ts`,
|
||||
`apps/api/src/favorites/favorites.service.ts` und
|
||||
`apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien liegen im benannten
|
||||
Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der
|
||||
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
|
||||
|
||||
@@ -454,6 +454,22 @@ verbindliche Prüfung sitzt serverseitig in `DashboardService.getWidgets()` (fil
|
||||
gesperrter Module fail-closed aus `GET /dashboard/widgets`) — und die Daten, die eine Modul-Kachel
|
||||
anzeigt, holt sie über die Endpunkte ihres Moduls, die `@UseModule('<slug>')` tragen müssen.
|
||||
|
||||
**Erstes echtes Beispiel: Proxmox (`quick-260924-i8v`).** Die Kachel steht in
|
||||
`apps/web/src/components/dashboard/widgets/proxmox-widget.tsx`, ihre reinen Modellfunktionen
|
||||
(`resolveProxmoxWidgetConfig`, `selectServers`, `healthSummary`, `widgetKeyFigure`) in
|
||||
`proxmox-widget-model.ts` daneben; `WIDGET_MODULE_SLUGS` trägt `proxmox: 'proxmox'`. Die
|
||||
Statuslogik, die Modulseite und Kachel teilen (`proxmox-status.ts` samt `formatPercent`/`formatCount`,
|
||||
`HealthBar.tsx` mit `variant="compact"`, `status-styles.ts`, das Auswahl-Bauteil
|
||||
`proxmox-server-picker.tsx`), liegt seit 260924-i8v unter `apps/web/src/components/proxmox/` — eine
|
||||
Kachel unter `components/` greift so nicht in einen Routenordner unter `app/`. Das
|
||||
Einstellungsformular für Einstellungen > Dashboard ist `components/settings/proxmox-widget-config-form.tsx`.
|
||||
Zwei Regeln, die für jede weitere Modul-Kachel gelten: Die Kachel liest nur Daten, die das Modul
|
||||
ohnehin vorhält (hier das Zwischenlager über `GET /modules/proxmox/servers`), und löst nie selbst
|
||||
eine Abfrage beim Fremdsystem aus — ein Minutentakt je Kachel und Benutzer wäre sonst eine Last auf
|
||||
den Proxmox-Hosts. Und Zeilen, die im Ansichtsmodus Links sind, werden im Bearbeitungsmodus zu
|
||||
schlichten Elementen ohne Ziel: Links stehen im Abbruch-Selektor von `dashboard-grid.tsx`, eine Kachel
|
||||
aus Links ließe sich sonst kaum noch ziehen.
|
||||
|
||||
## Mandantentrennung
|
||||
|
||||
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
||||
|
||||
@@ -168,15 +168,15 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
||||
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
|
||||
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
||||
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
||||
| dashboard | 1 | 28 | 1 | **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| dashboard | 1 | 29 | 0 | **quick-260924-m4n (Stufe 2 der Bilderrahmen-Umstellung):** nachgemessen mit der Gate-Schleife 1/29/0 — die Zeile nannte zuletzt 1/28/1, gemessen waren vor dieser Änderung aber schon 1/31/1: quick-260923-lrr hatte in `dashboard.service.ts` zwei gebundene `tenantPrisma.favoriteLink.`-Rohtreffer (Aufräumen hochgeladener Favoriten-Symbole) hinzugefügt, ohne diese Zeile nachzuziehen, und die ad9-Zählung lag um eins zu niedrig. Diese Änderung selbst: −2 gebunden und −1 System in `dashboard-images.service.ts` — der Bootstrap-Umzug ist entfernt (sein `systemPrisma.dashboardImage.findMany` und sein je Zeile gebundenes `update`), und der Upload legt die Zeile gleich MIT `storagePath` an (UUID vom Dienst), das nachträgliche `update` entfällt. Übrig in `dashboard-images.service.ts`: 7 gebundene Rohtreffer (`findMany`, `count`, `create`, `delete` beim Zurücknehmen, zweimal `findUnique`, `delete`). Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
||||
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
||||
| favorites | 0 | 12 | 0 | **Nachgemessen quick-260924-m4n: 12 gebundene Rohtreffer** — die Zeile nannte 8; die vier weiteren `tenantPrisma.favoriteLink.`-Rohtreffer kamen mit quick-260923-lrr (Favoriten-Symbol hochladen/ausliefern/entfernen) in `favorites.service.ts` hinzu, ohne dass die Zeile nachgezogen wurde. Vorher: **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
||||
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
|
||||
| **Summe** | **61** | **208** | **7** | **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
| **Summe** | **61** | **213** | **6** | **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare)
|
||||
|
||||
@@ -691,9 +691,10 @@ werden.
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
|
||||
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | **quick-260924-m4n:** Stand zurück von `system-gebunden` auf `gebunden`. Stufe 2 der Umstellung (Migration 20260924120000_dashboard_image_drop_data) löscht die Spalte `data` und macht `storagePath` zur Pflicht; der Bootstrap-Umzug hatte auf allen Servern gearbeitet und ist samt seinem einzigen `forSystem()`-Aufruf entfernt (Eintrag aus `FORSYSTEM_ALLOWED_CALL_SITES` gestrichen). Dieselbe Migration entfernt die `system_read_policy` auf "DashboardImage" — auf der Tabelle bleibt allein `tenant_isolation_policy` (Mandant UND Benutzer). Alle vier Anfragewege laufen wie bisher ausschließlich über `forTenant(this.prisma, tenantId, userId)`; der Upload vergibt die UUID jetzt selbst und legt die Zeile gleich mit Pfad an. Vorher: **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Kacheln eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). quick-260923-ad9 (Task 1): `getWidgets`/`addWidget` filtern/schreiben ueber `dashboardId` statt `userId` (D-02) — `assertOwnedDashboard` prueft vorher, dass der Reiter dem Aufrufer gehoert. |
|
||||
@@ -829,9 +830,13 @@ werden.
|
||||
Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext
|
||||
(Etappe 3c) — erledigt (260914-eym):** Migration
|
||||
`20260914120000_rls_system_context_read` (`is_system_context()`,
|
||||
`system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260922-hk4
|
||||
kommt "DashboardImage" als sechste dazu, angelegt in der Migration
|
||||
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder),
|
||||
`system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260923-dhh
|
||||
kommt "ProxmoxServer" als sechste dazu, Migration 20260923140000.
|
||||
"DashboardImage" trug die Regel nur vorübergehend — angelegt in
|
||||
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder, mit
|
||||
Stufe 2 der Umstellung wieder entfernt, Migration
|
||||
20260924120000_dashboard_image_drop_data, quick-260924-m4n; gemessen
|
||||
24.09.2026 lokal: sechs Tabellen mit der Regel),
|
||||
Schwesterhelfer
|
||||
`forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste;
|
||||
siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||
|
||||
@@ -104,6 +104,7 @@ export const WIDGET_TYPES = [
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
'proxmox',
|
||||
] as const;
|
||||
|
||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||
@@ -114,8 +115,11 @@ export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
|
||||
* serverseitig in `DashboardService.getWidgets` (fail-closed).
|
||||
*
|
||||
* Heute bewusst leer: alle neun Kacheln sind Plattform-Kacheln ohne
|
||||
* Modulbezug. Die erste modulgebundene Kachel (Proxmox) traegt hier ihren
|
||||
* Slug ein.
|
||||
* Die neun Kacheln von vor quick-260924-i8v sind Plattform-Kacheln ohne
|
||||
* Modulbezug. Proxmox ist die erste modulgebundene Kachel; ihr Slug ist
|
||||
* derselbe wie `@UseModule('proxmox')` im Controller und `slug: 'proxmox'`
|
||||
* in `proxmox.seed.ts`.
|
||||
*/
|
||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
|
||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {
|
||||
proxmox: 'proxmox',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user