51 Commits

Author SHA1 Message Date
schalli 9225ed1bf9 docs(changelog): Version 1.4.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 1m17s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 07:58:45 +02:00
schalli da0ee8256f docs(quick-260924-m4n): Test-Flake und DashboardImage Stufe 2
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m14s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 16:13:05 +02:00
schalli dd54ec5d42 feat(260924-m4n): Bilderrahmen Stufe 2 - alte Bildspalte data entfernt, storagePath Pflicht
- Migration 20260924120000_dashboard_image_drop_data: Schutzpruefung
  (bricht ab, solange eine Zeile ohne storagePath existiert; row_security
  aus, damit ein Eigentuemer ohne BYPASSRLS nicht still 0 Zeilen sieht),
  dann NOT NULL, DROP COLUMN data, DROP POLICY system_read_policy
- Dienst: Bootstrap-Umzug samt forSystem() und Selbstheilung aus data
  entfernt; Upload vergibt die UUID selbst, Zeile gleich mit Pfad
- FORSYSTEM_ALLOWED_CALL_SITES, Tests, Zugriffsklassifikation (per
  Gate-Schleife gemessen: 61/213/6) nachgezogen
- Betriebshandbuch Kap. 4: Hinweis und Wiederherstellungsweg bei Abbruch
- Todo 2026-09-22 nach completed/

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 16:09:36 +02:00
schalli b10734f382 test(260924-m4n): flackernden Marktplatz-Test entschaerfen, act-Warnungen der Proxmox-Kachel weg
- tenant-selector: Komponenten statisch statt im Test dynamisch importiert
  (Laden zaehlte in die 5-s-Frist des ersten Tests), SUPER_ADMIN/ADMIN in
  zwei it aufgetrennt
- marketplace/marketplace-filters: gleiche Umstellung; userEvent an die
  falsche Uhr gekoppelt statt auf shouldAdvanceTime zu warten
- proxmox-widget: Rendern wartet das erste Laden in act() ab (81 Warnungen weg)
- Todo 2026-09-23 nach completed/

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 16:02:45 +02:00
schalli dd09c08311 docs(quick-260924-i8v): Proxmox-Kachel fuers Dashboard
Tessera CI/CD / Lint & Type Check (push) Successful in 54s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m18s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:38:17 +02:00
schalli 602a45c8b6 docs(260924-i8v): Proxmox-Kachel in Anwender- und Entwicklerdoku und im Changelog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:33:48 +02:00
schalli 586da44fa1 feat(260924-i8v): Titel und Serverauswahl der Proxmox-Kachel an der Kachel und in den Einstellungen
- ProxmoxServerPicker gemeinsam fuer Kachel und Einstellungsformular
- Bearbeitungsmodus: entprelltes Titelfeld, Server auswaehlen statt Liste, sofort gespeichert
- ProxmoxWidgetConfigForm unter Einstellungen > Dashboard, Titel-Zusatz in der Kopfzeile

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:33:48 +02:00
schalli 377b6e37b2 test(260924-i8v): RED fuer Titel und Serverauswahl der Proxmox-Kachel
- Auswahl-Bauteil: Reihenfolge nach position, Produktwort, Aufraeumen geloeschter Kennungen
- Einstellungsformular: Laden, Fehler, Leer, onChange fuer Titel und Auswahl
- Einstellungsbereich: Zweig proxmox mit Titel-Zusatz in der Kopfzeile
- Kachel im Bearbeitungsmodus: entprellter Titel, Auswahl statt Liste, schliesst beim Verlassen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:30:22 +02:00
schalli a217d606bb feat(260924-i8v): Serverliste mit Kennzahl, Auswahlfilter, Links und Minutentakt der Proxmox-Kachel
- Zeilen nach down, warn, ok, idle, orphan mit genau einer Kennzahl, unbekannt statt 0
- config.serverIds filtert Liste und Balken, nur geloeschte Kennungen: eigener Satz
- Ansichtsmodus Links auf /modules/proxmox, Bearbeitungsmodus ohne Links
- Groessenstufen per Container-Query, Nachladen alle 60 s, pausiert bei verborgenem Tab
- formatPercent/formatCount einmal in components/proxmox, ServerCard nutzt sie

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:28:43 +02:00
schalli 92bf1307f6 test(260924-i8v): RED fuer Serverliste, Kennzahlen, Auswahl und Minutentakt der Proxmox-Kachel
- Modell: resolveProxmoxWidgetConfig, selectServers, widgetKeyFigure
- formatPercent/formatCount gemeinsam in proxmox-status
- Kachel: Sortierung, Kennzahltexte, unbekannt statt 0, Filter, Links, Titel, Takt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:26:40 +02:00
schalli a906c6705d feat(260924-i8v): Proxmox-Kachel als Durchstich mit Balken und Zusammenfassung
- Typ proxmox am Ende von WIDGET_TYPES, WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }
- gemeinsame Statusteile (proxmox-status, HealthBar, status-styles) nach components/proxmox/
- HealthBar variant compact: 6 px, ohne Legende, aria-hidden
- Registry 3/4/8/8, Katalog-/Registry-/API-Tests auf die erste Modul-Kachel umgestellt
- ProxmoxWidget liest nur listServers, zeigt Lade-, Leer-, Fehlerzustand

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:24:51 +02:00
schalli 8bfa4fc467 docs(quick-260924-h7x): Proxmox-Status-Design und Reiter in der Kopfzeile
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m14s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:48:56 +02:00
schalli 60b6973933 fix(260924-h7x): Proxmox-Karten im Spaltenfluss statt Zeilenraster
Eine kurze Karte liess im Raster eine Luecke bis zur hoeheren Nachbarkarte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:48:56 +02:00
schalli 57a4196c4b fix(260924-h7x): Nachschliff nach Browser-Rundgang
- Kopfzeile unter 640 px nur mit Bildmarke, damit die Dashboard-Reiter
  lesbar bleiben (vorher war vom Reiter nur "Das" zu sehen)
- Proxmox-Karten strecken sich nicht mehr auf gleiche Zeilenhoehe
- PBS-Pruefstatus auf Deutsch ("Pruefung fehlgeschlagen"), Rohwert als title

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:44:37 +02:00
schalli 0b659d67e8 fix(260924-h7x): verwaiste Karte daempft nur Symbol und Name
Gedaempfte muted-Schrift fiele auf 2,75:1 (hell) bzw. 3,0:1 (dunkel),
gemessen; Produktname, Adresse und Fussangabe bleiben deshalb voll lesbar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:38:55 +02:00
schalli 7416a925a6 feat(260924-h7x): Dashboard-Reiter als Umschalter in der Kopfzeile
- Kopfzeile bekommt Einhaengepunkt header-center-slot; auf / entfaellt der Text Startseite
- DashboardTabs rendert per createPortal dorthin, eigene Zeile ueber dem Raster entfaellt
- eingelassene Spur mit erhabenem aktivem Reiter, waagrecht scrollbar mit weicher Randausblendung
- role=tablist/tab, Pfeiltasten/Pos1/Ende, sichtbarer Fokusring, aktiver Reiter wird ins Bild gescrollt
- Plus-Knopf ausserhalb der Spur, Loeschdialog am Dokumentkoerper, Ziehhinweis als sr-only/Tooltip

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:38:08 +02:00
schalli 57c338fe53 feat(260924-h7x): Proxmox-Seite mit Status-Design und Tiefe
- Statusfarben als OKLCH-Tokens (Flaeche + kontrastgepruefte Textvariante) und Well-Token
- Gesundheitsbalken mit Legende und vorgelesener Zusammenfassung
- Karten nach Zustand sortiert, Statusleiste, getoenter Schatten, Statuspille
- Messwerte in eingelassenen Feldern: PVE-Knoten mit Balken, PBS-Fuellstand/Sicherung/Pruefung, PMG-Zahlfelder
- offline & verwaist: gestrichelt, gedaempft, keine alten Messwerte
- Skelett-Karten beim Laden, Leerzustand als Well, relative Zeitangaben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:34:16 +02:00
schalli 0fa7ce0f44 feat(260924-h7x): Proxmox-Statuslogik als reine Funktionen
- serverHealth mit Vorrang fuer offline & verwaist (isActive=false)
- THRESHOLDS als einzige Schwellenquelle, meterLevel fuer Balken
- Sortierung/Zusammenfassung je Zustand, formatAge ueber Intl.RelativeTimeFormat
- null erzeugt nie eine Warnung (24 Tests)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:27:05 +02:00
schalli 3d266418fc docs(quick-260923-lrr): Favoriten-Symbol hochladen, CHANGELOG, Zugriffsklassifikation nachgetragen
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m17s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 16:20:29 +02:00
schalli 61f95c8c52 feat(260923-lrr): Web — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf
- favorites-api.ts: FavoriteRequestError (Grund iconUrlUnreachable/iconTooLarge/
  iconInvalidType/iconUploadFailed), uploadFavoriteIcon/removeFavoriteIcon,
  FAVORITE_ICON_MAX_BYTES, Felder uploadedIconMime/iconVersion
- favorites-widget.tsx: Proxy-Bild traegt ?v=<iconVersion> (Cache-Bust nach
  Aenderung), Remount-Key um iconVersion/uploadedIconMime erweitert, eigenes
  Symbol im Hinzufuegen- und Bearbeitungsformular waehlen, Entfernen-Knopf,
  Fehlermeldung (role=alert) im Formular, Platzhalter jetzt uebersetzbar
- de.json/en.json: neun neue Texte unter widgets.favorites
- CHANGELOG.md und Anwenderhandbuch ergaenzt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:13:05 +02:00
schalli 7704372c3c feat(260923-lrr): API — Favoriten-Symbol hochladen, Vorrang, Versionszaehler, Abrufprobe
- FavoriteLink: neue Spalten uploadedIconMime/iconVersion (Migration 20260923160000)
- favorite-icon-files.ts: Erkennung PNG/JPEG/GIF/WebP/ICO/SVG, Pfadbildung ohne
  Byte aus der Anfrage im Pfad (T-LRR-01), best-effort Dateientfernung
- FavoritesService: uploadIcon/removeUploadedIcon, Vorrang der hochgeladenen
  Datei in getIconBytes, Abrufprobe fuer eine neue iconUrl (422 statt stiller
  Speicherung), iconVersion-Erhoehung bei jeder Aenderung der Symbolquelle
- FavoritesController: POST/DELETE /favorites/:id/icon, Cache-Control private
- T-LRR-07 (Restrisiko aus dem Plan-Threat-Model geschlossen, ueber den Plan
  hinaus): DashboardService.removeWidget/deleteDashboard raeumen jetzt die
  Symboldateien der per Datenbank-Kaskade mitgeloeschten Favoriten auf
  (best effort, nie blockierend)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:04:29 +02:00
schalli bf4384ad73 fix(bug-report): Bildschirmfoto scheitert nicht mehr an einem fremden Bild
html-to-image laedt jedes <img> per fetch nach; ein einziges Bild ohne CORS
(z. B. ein direkt von der Website geholtes Favoriten-Symbol) liess die ganze
Aufnahme scheitern, im Dialog blieb das Haekchen "Bildschirmfoto beifuegen"
gesperrt. Nicht ladbare Bilder werden jetzt zum transparenten Pixel; scheitert
die Aufnahme trotzdem, folgt ein zweiter Versuch ohne Bilder und Rahmen.
Im Linux-Client 1.3.1 nachgestellt und nach dem Fix gegengeprueft.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:52:02 +02:00
schalli b03ffb5d10 fix(dashboard): Favoriten-Kachel bis auf eine Spalte schmal ziehbar
Bei kurzen Linknamen blieb rechts viel Leerraum; minW 3 -> 1. Der Titel
kuerzt mit Auslassungszeichen, das Symbol bleibt stehen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:40:26 +02:00
schalli c294bfddf2 docs(quick-260923-le6): Proxmox-Abnahmebefunde behoben (PMG-Teilsumme, Aktualisieren nur fuer Admins)
Tessera CI/CD / Lint & Type Check (push) Successful in 57s
Tessera CI/CD / Tests (push) Successful in 1m54s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m22s
Uebergabe-Notizen der pausierten Sitzung entfernt, die Arbeit ist wieder aufgenommen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:35:30 +02:00
schalli e1b191bf0b fix(260923-le6): Aktualisieren-Knopf der Proxmox-Seite nur fuer Admins
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:33:32 +02:00
schalli 2f8dd14bfb fix(260923-le6): Proxmox-Karte verweist Nicht-Admins nicht auf den Aktualisieren-Knopf
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:31:58 +02:00
schalli 2eb86e14ea fix(260923-le6): PMG-Summe null bei fehlendem Teilwert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:30:58 +02:00
schalli c13d657e41 test(260923-le6): PMG-Teilsumme ohne Haelfte muss null sein
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:30:31 +02:00
schalli 6530ae503b wip: Sitzung pausiert — Proxmox-Modul gebaut, ein Befund der Abnahme offen, 14 Commits ungepusht
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 15:17:19 +02:00
schalli c1afd66586 docs(quick-260923-ku6): Testzahlen richtiggestellt (1316/712, nicht 1320/714)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 15:15:29 +02:00
schalli 231bd5e47f docs(quick-260923-dhh): Akte - Rundgang gegen den Nachbau, offener Befund aus der Abnahme
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 15:15:16 +02:00
schalli a9e0d5b1ae docs(quick-260923-ku6): Drei Nachbesserungen aus dem Browser-Rundgang zu quick-260923-dhh (Proxmox-Modul) beheben 2026-09-23 15:14:29 +02:00
schalli f1bb7f7191 fix(260923-ku6): ruhiger "noch nicht abgefragt"-Zustand und Adresse ohne Grossschreibung
Zwei weitere Nachbesserungen aus dem Rundgang zu 260923-dhh, beide in
ServerCard.tsx und deshalb in einem Commit:

Befund 2: ein frisch angelegter, noch nie abgefragter Server zeigte
faelschlich "Ein unerwarteter Fehler ist aufgetreten" — die leere
Zwischenlagerzeile aus createServer hat `reachable: false` und
`errorKind: null`, was bisher blind in die Fehler-Uebersetzung `unbekannt`
lief. Neuer ruhiger Zustand fuer `status.lastPolledAt === null`, der auf
"Jetzt aktualisieren" verweist; die bestehenden Fehlermeldungen (inkl.
`unbekannt` fuer echte unbekannte Fehler) bleiben fuer `lastPolledAt !== null`
unveraendert.

Befund 3: die Klasse `uppercase` sass auf der ganzen Statuszeile und faerbte
dadurch auch die Adresse gross ("PVE — HTTPS://..."). Jetzt nur noch auf dem
Produktkuerzel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:12:12 +02:00
schalli 710034c80a fix(260923-ku6): Verbindungstest prueft Formularwerte statt gespeicherten Stand
Nachbesserung aus dem Browser-Rundgang zu 260923-dhh (Befund 1): "Verbindung
testen" pruefte bislang immer den in der Datenbank gespeicherten Server, nicht
das ungespeicherte Formular. Eine im Formular abgeschaltete Zertifikatspruefung
oder ein neu eingetipptes Token-Geheimnis wurden dadurch beim Test ignoriert
und erst nach "Speichern" wirksam — eine Falle fuer genau den Ablauf, den
Nutzer instinktiv waehlen (eintippen, testen, dann erst speichern).

Neues `TestProxmoxServerDto` plus Merge-Baustein `resolveEffectiveTestServer`
in `ProxmoxService`: normale Felder folgen dem Formular (auch wenn absichtlich
geleert), Geheimnisfelder folgen der bestehenden "leer -> gespeicherten Wert
behalten"-Regel, weil `ServerForm` sie beim Laden nie aus der Datenbank
vorbefuellt. Neue Route `POST servers/test` (ohne `:id`) deckt die Neuanlage
ab, wo es noch keinen gespeicherten Server gibt. Der Testen-Knopf steht jetzt
immer zur Verfuegung, nicht mehr nur nach dem ersten Speichern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:11:56 +02:00
schalli 3091b04673 docs(260923-dhh): Proxmox-Modul Aufgabe 7 - Dokumentation und Nachmessung aller Tore
- docs/anleitung-anwender.md: neuer Abschnitt "Proxmox" (Modulzahl vier
  auf fuenf korrigiert) — was das Modul zeigt, Server anlegen, NUR-LESE-
  Rolle je Produkt, PMG nur Benutzer/Passwort, Zertifikatspruefungs-
  Schalter, "Verbindung testen", "unbekannt", D-01 ausdruecklich
  festgehalten. Plan nannte "docs/anwenderhandbuch.md" (existiert nicht
  im Repo) — echter Dateiname ist docs/anleitung-anwender.md, dort
  angewendet (Rule 3, blockierender Pfadfehler)
- docs/anleitung-entwicklung.md: proxmox als Vorlage fuer ein Modul mit
  Fremdsystem-Zugaengen und Hintergrundabfrage verlinkt, undici-
  Dispatcher-Falle als Merksatz ergaenzt (war noch nicht dokumentiert)
- docs/mandantentrennung-zugriffsklassifikation.md: Bereichsuebersicht
  und Summenzeile fuer Aufgabe 5 nachgezogen (war nach Aufgabe 5 noch
  offen) — proxmox jetzt 0/11/1, Summe 61/208/7, mit der Gate-Schleife
  nachgemessen

Endstand aller Tore gegen die Ausgangswerte des Plans:
- api-Tests: 1311 (Ausgangswert 1240, Ziel >=1240)
- web-Tests: 708 (Ausgangswert 693, Ziel >=693)
- type-check: 4/4
- lint: 5/5
- Biome-Warnungen apps/web: 53 (Ausgangswert 53, exakt unveraendert)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:48:39 +02:00
schalli 06fcdc0157 feat(260923-dhh): Proxmox-Modul Aufgabe 6 - Modulseite mit Auslastung
- ServerCard.tsx: einzige Stelle, die einen Messwert in Text verwandelt
  (formatMetric) — null wird ueberall "unbekannt", nie 0/NaN/leer;
  verzweigt ueber productType auf PVE/PBS/PMG; PBS ohne Sicherung zeigt
  "noch keine Sicherung" statt eines Fehlers; nicht erreichbarer Server
  zeigt Klartext-Ursache plus Zeitpunkt der letzten erfolgreichen Messung
- page.tsx: "Jetzt aktualisieren" fragt alle Server neu ab und laedt die
  Liste danach neu, waehrend des Laufs gesperrt; ruhiger Hinweis bei
  leerer Liste mit Weg zu den Einstellungen; Link zu den Einstellungen
  nur fuer Administratoren sichtbar (Anzeige, kein Zugriffsriegel)
- proxmox-api.ts: ProxmoxMetrics-Union (Pve/Pbs/Pmg) fuer typsichere
  Verzweigung im Frontend
- umlaut-dictionary.ts: zwei weitere korrekte Woerter auf die
  Positivliste (Messung, Prozessorlast)

Tore: web 708/708 (>=693), type-check 4/4, Biome apps/web 53 Warnungen
(unveraendert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:45:27 +02:00
schalli 723cf6814b feat(260923-dhh): Proxmox-Modul Aufgabe 5 - Einstellungsseite (anlegen, bearbeiten, loeschen, testen)
- proxmox.service.ts: updateServer (Muster LdapConfigService.updateConfig
  - nicht gesendet laesst unveraendert, leer loescht, gefuellt
  verschluesselt neu; PMG+Token auch beim Bearbeiten abgelehnt, geprueft
  gegen den EFFEKTIVEN Stand nach Zusammenfuehren), deleteServer
- proxmox.controller.ts: PUT/DELETE servers/:id, beide zusaetzlich mit
  scheduler.refreshTenant() nach dem Schreiben
- Frontend: proxmox-api.ts (updateServer/deleteServer/testServer),
  settings/page.tsx (Rollenpruefung nur Anzeige, Serverliste,
  Loeschen mit Rueckfrage), ServerForm.tsx (PMG bietet Token gar nicht
  an, Geheimnisfelder nie vorbefuellt, Zertifikatspruefung-Schalter
  Standard "pruefen", Verbindungstest mit Klartext-Fehlertext)
- umlaut-dictionary.ts: zwei neue, bereits korrekte Woerter
  (bewusst/gemessene) auf die Positivliste des Regressions-Waechters

Tore: api 1311/1311 (>=1240), web 701/701 (>=693), type-check 4/4,
lint 5/5, Biome apps/web 53 Warnungen (unveraendert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:41:21 +02:00
schalli fccaf8db0f feat(260923-dhh): Proxmox-Modul Aufgabe 4 - Hintergrundabfrage je Mandant, Verbindungstest
- proxmox-scheduler.service.ts: ein Cron-Auftrag je aktivem Mandant
  (proxmox-poll:<tenantId>), onApplicationBootstrap (nicht onModuleInit,
  Tender-Muster), Abfrageintervall = kleinstes pollIntervalMin der
  aktiven Server, ein fehlgeschlagener Server bricht die Tick-Schleife
  nicht ab, refreshTenant() zieht nach jedem Speichern sofort nach
- proxmox.service.ts: loadActiveServersForScheduler() als einziger
  forSystem()-Aufruf des Moduls (Erlaubnisliste in
  rls-access-inventory.spec.ts), testConnection() schreibt nicht ins
  Zwischenlager, pollServer() bekommt eine Zehn-Sekunden-Sperre (T-DHH-06)
- proxmox.controller.ts: POST servers/:id/test, create() zieht den
  Planer nach dem Anlegen sofort nach
- Zugriffsklassifikation: proxmoxServer wechselt auf system-gebunden
  (Startpfad des Planers), proxmoxServerStatus bleibt gebunden

Tore: api 1306/1306 (>=1240), type-check 4/4, rls-access-inventory
und rls-coverage gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:34:11 +02:00
schalli 998aba9ef3 feat(260923-dhh): Proxmox-Modul Aufgabe 3 - PBS und PMG auswerten
- proxmox-normalize.ts: nachsichtige Leser (readNumber/readText/readBool/
  readList) und normalizePve/normalizePbs/normalizePmg als reine
  Funktionen, nie ein Wurf bei unerwarteter Form
- PVE ergaenzt um je Speicherort Belegung (storages)
- PBS: Belegung je Datenspeicher plus letzte Sicherung/Pruefergebnis aus
  bis zu 10 Folgeabfragen je Durchlauf (Deckel in proxmox.service.ts)
- PMG: Tageszahlen eingehend/ausgehend/Spam/Viren
- Feldnamen je Produkt als benannte Konstante (Annahmen A3/A5 der
  Recherche), mehrere plausible Namen je Feld moeglich
- proxmox.service.ts: produktabhaengige Abfragefolge, Ticket-Erneuerung
  jetzt je Durchlauf statt je Aufruf (PBS-Mehrfachabfragen loggen nicht
  mehrfach neu ein)
- proxmox-nur-lesen.spec.ts: Riegel erkennt jetzt auch den Umschlag
  getWithRetry als zulaessige Aufrufform

Tore: api 1293/1293 (>=1240), type-check 4/4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:28:25 +02:00
schalli 4f8a368c9e test(260923-dhh): Proxmox-Modul Aufgabe 2 - Benutzer/Passwort, Fehlerklassen, Nur-Lesen-Riegel
- proxmox-auth.ts: loginTicket (die einzige nicht-lesende Anfrage im
  Modul, POST /access/ticket) und buildTicketCookieHeader je Produkt
  (Cookie-Namen als benannte Konstante, Annahme A2 kommentiert)
- proxmox-client.service.ts: classifyFailure (401->zugang, 403->rechte,
  404->antwortform, 5xx->server, Netzfehler->netz, Zertifikatsfehler->
  zertifikat) und parseJsonLenient (kein Wurf bei Nicht-JSON); kein
  explizites method-Feld mehr an proxmoxGet (GET ist Grundwert)
- proxmox.service.ts: Passwort-Zweig via Ticket-Anmeldung, genau ein
  zweiter Versuch nach 401 (Ticket-Ablauf alle zwei Stunden kein
  Fehlalarm)
- proxmox-nur-lesen.spec.ts: maschinischer Riegel zu D-01 — genau eine
  Stelle (proxmox-auth.ts) uebergibt ein Anfrageverfahren an
  undiciFetch, jeder Proxmox-Pfad ausserhalb laeuft ueber proxmoxGet

Tore: api 1270/1270 (>=1240), type-check 4/4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:23:22 +02:00
schalli 3a1bfd943e feat(260923-dhh): Proxmox-Modul Aufgabe 1 - PVE per Token, Ende-zu-Ende
- ProxmoxServer/ProxmoxServerStatus mit RLS (tenant_isolation_policy +
  system_read_policy auf ProxmoxServer fuer den kommenden Planer)
- proxmox-auth.ts (Token-Kopfzeilen PVE/PBS), proxmox-client.service.ts
  (proxmoxGet, ausschliesslich lesend, Dispatcher je Aufruf aus
  tlsRejectUnauthorized, nie global)
- proxmox.service.ts: Server anlegen (Geheimnis verschluesselt,
  select ohne Geheimnisfelder), Serverliste, PVE-Abfrage mit
  nachsichtiger Grundauswertung (Knoten/Gaeste)
- Controller/Modul/Seed nach Domaincheck-Vorbild, Kategorie
  "infrastructure", @UseModule('proxmox') + @Roles auf Schreibwegen
- Modulseite (duenne Liste) + proxmox-api.ts + Registrierung in
  MODULE_REGISTRY
- Zugriffsklassifikation nachgezogen (rls-access-inventory.spec.ts gruen)

Tore: api 1247/1247 (>=1240), web 693/693, type-check 4/4, lint 5/5,
Biome apps/web 53 Warnungen (unveraendert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:17:33 +02:00
schalli ec9c77956d docs(quick-260923-dhh): Proxmox-Modul PVE/PBS/PMG - Plan
Sieben Aufgaben in einem Plan: Tracer (PVE/Token end-to-end), Ticket-Zugang
mit Fehler-Klartext und Beobachtungs-Riegel, PBS/PMG nachsichtig auswerten,
Hintergrundabfrage je Mandant plus Verbindungstest, Einstellungsseite,
Modulseite, Doku und Nachmessung.

Ausgangswerte der Tore gemessen: api 77/1240, web 82/693, type-check 4/4,
lint 5/5, Biome-web genau 53 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 10:01:40 +02:00
schalli 35c7f5a1ac docs(quick-260923-dhh): Proxmox-Modul PVE/PBS/PMG - Research 2026-09-23 09:49:44 +02:00
schalli 0094a60d15 docs(quick-260923-ad9): Akte - Rundgang mit zwoelf Punkten bestanden
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
Bestand bleibt erhalten (5 Kacheln im ersten Reiter), Kacheln je Reiter
getrennt, Ziehen ordnet um, nach dem Neuladen kommt der erste Reiter,
letzter Reiter ohne Loeschknopf, Raster unveraendert.

Offener Kleinbefund notiert: die Knopf-Beschriftungen nennen den betroffenen
Reiter nicht, nur das Bestaetigungsfenster tut es.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:29:50 +02:00
schalli 58ce88e29f docs(quick-260923-ad9): Anwenderhandbuch, Changelog und Nachmessung aller Tore - Task 5
docs/anleitung-anwender.md: neuer Absatz "Mehrere Dashboards (Reiter)" im
Dashboard-Abschnitt, Alltagssprache - mehrere Dashboards nebeneinander,
Ziehen legt den Standard fest, Anlegen/Umbenennen/Loeschen im
Bearbeitungsmodus, der letzte Reiter bleibt.

CHANGELOG.md: ein Stichpunkt unter "Unveroeffentlicht" > "Neu" - was der
Benutzer sieht, mit dem ausdruecklichen Hinweis, dass vorhandene Kacheln
unveraendert auf dem ersten Reiter liegen bleiben.

docs/mandantentrennung-zugriffsklassifikation.md: Endstand nach Task 2
nachgerechnet (nicht aus Task 1 abgeschrieben) - Bereich dashboard 24->28
gebunden (Task 2 bringt vier weitere `tenantPrisma.dashboard.`-Rohtreffer:
createDashboard/renameDashboard/deleteDashboard), Summe 193->197. Die
Begruendungsspalte des Paares dashboard.service.ts/dashboard nennt jetzt
auch Task 2 (withTenantTransaction fuer deleteDashboard/reorderDashboards,
Muster favorites.service.ts/reorder). Paarzahl (75) unveraendert - Task 2
fuegt keine neuen (Datei,Modell)-Paare hinzu, nur weitere Rohtreffer
bestehender Paare.

Alle Tore nachgemessen: api 1240 Tests in 77 Dateien gruen (>= 1202/77),
web 693 Tests in 82 Dateien gruen (>= 661), type-check 4/4, lint 5/5 mit
genau 53 Warnungen in web, `prisma migrate diff` weiterhin ohne Unterschied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:21:05 +02:00
schalli 05feaa3dd6 feat(quick-260923-ad9): Reiter per Ziehen umsortieren - Task 4
dashboard-tabs.tsx: Ziehen ueber Pointer-Ereignisse (Muster
xframe-config-form.tsx, D-05 - keine neue Abhaengigkeit). Schwelle 4 px
waagerechte Auslenkung trennt Klick von Ziehen; darueber wird der Zeiger
eingefangen (jsdom-Schutzhuelle), die Leiste zeigt die Vorschau-Reihenfolge
(computeReorderedIds, Einfuegen vor dem ersten Nachbarn mit Mittelpunkt
rechts vom Zeiger), beim Loslassen geht die VOLLSTAENDIGE Kennungsliste an
onReorder. Abbruch des Zeigers verwirft die Vorschau ohne zu senden. Ein
hasDraggedRef-Merker unterdrueckt den Klick, der im echten Browser nach
einem Ziehen folgt. Ziehen ist IMMER moeglich, nicht nur im
Bearbeitungsmodus (D-09) - ein Hinweistext erklaert, dass der erste Reiter
beim Oeffnen geladen wird.

dashboard-store.ts: reorderDashboards() setzt die neue Reihenfolge SOFORT
optimistisch, sendet sie und stellt bei einem Fehler die vorherige
Reihenfolge wieder her; der aktive Reiter bleibt aktiv.

dashboard-tabs.test.tsx: 21 Tests (12 alte aus Task 3 + 9 neue fuer jeden
Punkt des Verhaltensblocks). getBoundingClientRect wird je Reiter-Wrapper
ueber data-tab-index gestubbt (Reiter i belegt 100i..100i+100 - jsdom
liefert keine echten Masse). dashboard-store.test.ts: 17 Tests (15 alte +
2 neue fuer Optimismus/Ruecknahme).

Messages: widgets.tabs.dragHint war bereits in Task 3 eingetragen
(vorausschauend) - in diesem Task keine weitere Aenderung an de.json/en.json
noetig.

Gemessene Abweichung von der Plan-Erwartung (kein Rule-1/2/3-Fall, reine
Zahlendifferenz): `grep -c "react-grid-layout|dnd|sortable"
apps/web/package.json` liefert 2 statt der im Plan erwarteten 1 - der
zweite Treffer ist `@types/react-grid-layout`, bereits vor diesem Task
vorhanden (siehe `git diff --stat apps/web/package.json`: keine Aenderung
in keinem der vier Tasks). D-05 (keine neue Zieh-Abhaengigkeit) ist damit
weiterhin erfuellt, nur an der leeren package.json-Diff nachgewiesen statt
an der im Plan vorausgesagten Zahl. Ebenso liefert `pnpm --filter
@tessera/web test` 82 statt der erwarteten 83 Dateien - Task 4 fuegt (siehe
files_modified oben) keine neue Testdatei hinzu, Task 3 hatte die
Dateizahl bereits auf 82 gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:18:25 +02:00
schalli d34f682c28 feat(quick-260923-ad9): Reiterleiste in der Oberflaeche - Task 3
dashboard-api.ts: fuenf neue Abrufe fuer die Reiter-Wege, die vier
bestehenden Aufrufe (Layout/Widgets lesen/schreiben) tragen jetzt die
Reiter-Kennung (Abfrageparameter bzw. Rumpf).

dashboard-store.ts: Zustand um dashboards/activeDashboardId/
isSwitchingDashboard erweitert. loadDashboard() holt zuerst die Reiter,
macht den ersten aktiv, laedt erst danach dessen Inhalt; ein modul-globales
Versprechen schuetzt gegen doppeltes Laden der Reiterliste bei doppeltem
Einhaengen. selectDashboard() schreibt eine ungespeicherte Anordnung ZUERST
fuer den alten Reiter (Kennung vor dem Wechsel gelesen) und ersetzt danach
Kacheln/Anordnung vollstaendig. createDashboard/renameDashboard/
deleteDashboard pflegen Reiterliste und aktiven Reiter; Loeschen des
aktiven Reiters macht den dann ersten Reiter aktiv. Die Marker-Umrechnung
(quick-260916-bwo) laeuft unveraendert je Reiter mit, auch beim Wechsel.

dashboard-tabs.tsx (neu): Reiterleiste, Klick wechselt immer; im
Bearbeitungsmodus zusaetzlich Anlegen, Umbenennen (an Ort und Stelle,
Enter/Escape) und Loeschen (mit Rueckfrage) - Loeschen-Knopf fehlt beim
letzten verbleibenden Reiter (D-10). Fokus beim Umbenennen ueber einen Ref
statt autoFocus (lint/a11y/noAutofocus).

(portal)/page.tsx: Leiste ueber dem Raster, kurze Ladezeile waehrend eines
Reiterwechsels statt des Rasters - die Leiste bleibt stehen. DashboardGrid
selbst unveraendert (D-06).

Uebersetzungen: neue Schluessel unter widgets.tabs.* in de.json/en.json,
Dialog-Knoepfe nutzen die vorhandenen common.cancel/common.delete.

Deviations (Rule 3 - blockierende Nachwirkung dieses Tasks, ausserhalb der
files_modified-Liste, aber direkt durch die dashboardId-Pflicht verursacht):
- settings/dashboard/page.tsx: fetchWidgets() verlangt jetzt eine
  Reiter-Kennung; die Seite ist nicht reiterbewusst (ausserhalb des
  Umfangs) und zeigt jetzt die Kacheln des ERSTEN Reiters - deckungsgleich
  mit dem bisherigen Verhalten fuer den haeufigen Fall genau eines Reiters.
- (portal)/page.test.tsx: mockStore brauchte die neuen Reiter-Felder/
  -Methoden, sonst waere DashboardTabs auf `dashboards.map` von undefined
  gescheitert.

Tests: dashboard-store.test.ts 15 (6 alte angepasste Signaturen + 9 neue),
dashboard-tabs.test.tsx 12 (neu). web gesamt 682 Tests in 82 Dateien,
dashboard-grid.test.tsx unveraendert bei 12. type-check 4/4, lint 5/5 mit
weiterhin genau 53 Warnungen in web.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:10:45 +02:00
schalli df7a5e7e8e feat(quick-260923-ad9): Reiter anlegen, umbenennen, loeschen, umsortieren - Task 2
dashboard.service.ts: createDashboard() (Namensvergabe "Dashboard N" fuellt
Luecken, D-08; Obergrenze 20 Reiter, T-AD9-06), renameDashboard() (Riegel
zuerst), deleteDashboard() (letzter Reiter bleibt, D-10; Loeschen + Neu-
Nummerierung als EINE withTenantTransaction), reorderDashboards() (woertlich
nach FavoritesService.reorder-Muster: Exakt-Abgleich vor jedem Schreiben,
kein Teilschreiben, dieselbe Abweisung fuer unvollstaendige/unbekannte/
fremde Kennungen - T-AD9-04).

dashboard.controller.ts: fuenf neue Wege unter tabs; PUT tabs/order VOR
PATCH/DELETE tabs/:id deklariert (Routenreihenfolge).

dashboard.controller.spec.ts (neu, 8 Tests): Durchreichung, Abweisung ohne
Kontext, quelltextlesender Waechter fuer die Routenreihenfolge.

dashboard.service.spec.ts: 61 Tests (43 alte + 18 neue fuer Anlegen,
Umbenennen inkl. DTO-Beschneidung/-Laengenpruefung, Loeschen und
Umsortieren - je ein Fall fuer "fremder Reiter" und "letzter Reiter bleibt").

Deviation (Rule 1): widget-module-map.spec.ts's CreateWidgetDto-Whitelist
helper needed a dashboardId fixture after Task 1 made the field required -
fixed inline, out of the plan's files_modified list but directly caused by
Task 1's DTO change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:00:59 +02:00
schalli 9c518238f5 feat(quick-260923-ad9): Datenmodell, Migration und Reiter-Grundlage - Task 1
Neues Modell Dashboard (D-01/D-02/D-09): position statt Standard-Feld,
kein Unique auf (userId, position) - Umsortieren schreibt spaeter alle
Positionen einer Transaktion neu. WidgetInstance/DashboardLayout haengen
jetzt am Reiter statt am Benutzer (DashboardLayout.dashboardId @unique
ersetzt userId @unique).

Migration 20260923120000_dashboard_tabs: Zeilenschutz mit Mandant- UND
Benutzerdimension (Form 20260911120000/20260921120000), Bestands-
uebernahme fuer jeden Benutzer mit Kacheln oder Anordnung VOR den
Fremdschluesseln (D-03) - gemessen: 0 Kacheln/Anordnungen ohne Reiter,
genau 2 Reiter auf Position 0.

dashboard.service.ts: listDashboards() (Transaktionssperre gegen
doppelte Erstanlage, T-AD9-07), Riegel assertOwnedDashboard() (fail-
closed gegen fremde Reiter, T-AD9-01/02/03) - getLayout/saveLayout/
getWidgets/addWidget laufen jetzt ueber dashboardId statt userId.
GET /dashboard/tabs neu; die vier bestehenden Wege reichen die Reiter-
Kennung durch. Verhalten fuer den Benutzer unveraendert (ein Reiter,
wie bisher) - Task 2 ergaenzt Anlegen/Umbenennen/Loeschen/Umsortieren.

dashboard.service.spec.ts: 43 Tests (31 alte unveraendert + 12 neue fuer
Reiter-Anlage, -Reihenfolge und den Fremdreiter-Riegel bei allen vier
Wegen). Zugriffsklassifikation nachgerechnet: 75 Paare (+1), Bereich
dashboard 21->24 gebunden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:56:38 +02:00
schalli 84fe73e16a docs(quick-260923-ad9): Plan - Dashboard-Reiter, mehrere Dashboards je Benutzer
Fuenf Aufgaben: Datenmodell mit Bestandsuebernahme, Reiter-Endpunkte mit
Besitz-Riegel, Reiterleiste, Ziehen zum Umsortieren, Doku und Changelog.
Torzahlen vorher gemessen (api 1202/76, web 661/81, type-check 4/4,
lint 5/5 mit 53 Warnungen in web).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 07:43:39 +02:00
schalli fcad4608e4 docs(todo): Flackernder Test tenant-selector hat die Freigabe 1.3.1 blockiert
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m13s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m0s
Zeitueberschreitung bei 5 s auf dem Tag-Lauf, gleicher Commit auf main und live
gruen. Der Abbild-Bau haengt am Test-Job, deshalb wurden Images, Release und
Desktop-Pakete uebersprungen - erst der Neustart hat sie nachgeholt. Trifft
jede Freigabe, weil jede drei Pipelines gleichzeitig ausloest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 07:39:48 +02:00
121 changed files with 19889 additions and 782 deletions
-154
View File
@@ -1,154 +0,0 @@
---
context: default
phase: null
task: null
total_tasks: null
status: paused
last_updated: 2026-09-22T14:15:00.000Z
---
<current_state>
Kein laufender Meilenstein. Alle 18 Phasen sind abgeschlossen; seit der Freigabe
1.2.0 laeuft die Arbeit als Quick-Tasks. **Version 1.3.0 wurde am 22.09.2026
freigegeben** (Tag `v1.3.0` auf `d146234`, Abbilder `live` und `v1.3.0`,
Gitea-Release mit `Tessera-Setup-1.3.0.exe` und `Tessera-1.3.0.AppImage`).
`main == origin/main` auf `1315f37`, Arbeitsbaum sauber, CI gruen. Der lokale
Docker-Stack laeuft mit genau diesem Stand (web, api, db).
Unterbrochen wurde NICHT mitten in einer Aufgabe — alle acht Auftraege dieser
Sitzung sind fertig, nachgewiesen und gepusht. Der naechste Auftrag (Proxmox)
ist inhaltlich geklaert, wartet aber auf Zugangsdaten des Nutzers.
</current_state>
<completed_work>
Diese Sitzung (21.09. abends bis 22.09. nachmittags):
- quick-260921-pi9 — Dashboard-Widget „Bilderrahmen" (Upload oder https-Adresse, Diashow, Grossansicht)
- quick-260921-qd3 — Dashboard-Widget „XFrame" (Webseite als Rahmen, Sandbox ohne Top-Navigation)
- quick-260922-frg — Tray-Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h
- fast `747a4d4` — Download-Knoepfe im Desktop-Client oeffnen den System-Browser (waren ohne Funktion)
- quick-260922-ge2 — XFrame: Ausschnitt waehlen und einpassen, Zoom, „Nur anzeigen"
- **Freigabe 1.3.0** — CHANGELOG abgeschlossen, `live` vorgezogen, Tag gepusht, drei CI-Laeufe gruen
- quick-260922-hk4 — Bilderrahmen-Bilder in den Dateibereich `user-files` statt in die Datenbank, automatischer Umzug beim Start, Selbstheilung aus der alten Spalte
- quick-260922-m1h — Widget-Typen an EINER Stelle, Katalog aus der Registry + Modulfilter, Kachel kennt ihr Modul
Jeder Punkt wurde im Browser (Playwright-MCP) gegen den lokalen Stack geprueft;
die Pruefprotokolle stehen in den jeweiligen SUMMARY.md unter `.planning/quick/`.
</completed_work>
<remaining_work>
1. **Proxmox-Modul** (PVE, PBS, PMG) — **nur beobachten**, keine Eingriffe.
Seite: Server anbinden, VMs/Container mit CPU, Arbeitsspeicher, Plattenplatz
und Erreichbarkeit; bei PBS Sicherungslaeufe und Pruefstatus, bei PMG die
Mail-Zahlen (zugestellt, gefiltert, blockiert, Quarantaene).
2. **Proxmox-Kachel** — kompakte Fassung ueber den neuen Weg (drei Stellen,
siehe unten). Vorschlag fuer den Inhalt steht in der Sitzung: Ampel je Server
+ drei Balken; PBS: Alter der letzten Sicherung, Pruefergebnis, freier Platz;
PMG: Tageszahlen in einer Zeile. Idee fuer spaeter: eine Sammelkachel
„Alles in Ordnung?" mit einer Zeile je Server.
</remaining_work>
<decisions_made>
- **Bilder auf die Festplatte, nicht in die Datenbank** — Grund ist die Sicherung
(`pg_dump` von Hand; 30 Bilder à 5 MiB je Benutzer waeren 150 MB pro Benutzer
im Abzug), nicht die Geschwindigkeit, und die Einheitlichkeit mit Avataren
(`user-files/avatars`) und DKV-Exporten.
- **Zweistufige Umstellung:** Spalte `data` bleibt vorerst stehen; `getBytes`
stellt eine fehlende Datei daraus wieder her. DROP erst, wenn alpha UND live
einmal mit dieser Version gelaufen sind — Todo liegt unter
`.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md`.
- **Proxmox: nur beobachten**, Zugriff ueber die normale Modulfreigabe
(Nutzeransage 22.09.).
- **Kacheln gesperrter Module erscheinen gar nicht erst im Katalog**
(Nutzeransage 22.09.) — so umgesetzt in m1h.
- **Basic-Auth am Proxy vor alpha bleibt** (Nutzerentscheidung). Aus dem
Firmennetz greift eine Ausnahme; von aussen 401, und der Client sagt das seit
frg selbst. **Nicht erneut vorschlagen, das Thema ist entschieden.**
</decisions_made>
<blockers>
- Proxmox braucht Zugangsdaten und Serveradressen des Nutzers (API-Token, nur
lesend, z. B. Rolle `PVEAuditor`). Der Nutzer legt sie morgen an. Planung und
Modulskelett koennen vorher entstehen, die Anbindung nicht getestet werden.
</blockers>
## Required Reading (in order)
1. `.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` — Abschnitt „So fuegt man kuenftig eine Modul-Kachel hinzu": drei Stellen statt sieben.
2. `docs/anleitung-entwicklung.md` — Modul-Walkthrough (Backend + Frontend) und der neue Abschnitt „Eine Kachel zum Modul".
3. `.planning/quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/260922-hk4-SUMMARY.md` — Dateiablage, wenn das Proxmox-Modul jemals Dateien speichert.
4. `.planning/STATE.md` — Abschnitte „Current Position" und die letzten Zeilen der Quick-Tabelle.
## Critical Anti-Patterns (do NOT repeat these)
- [ANTI-PATTERN]: Planannahmen ueber den Bestand ungeprueft uebernehmen → m1h: der Plan
behauptete, `apps/web` importiere `@tessera/shared` bereits; es gab KEINE
Abhaengigkeit, und zwei Kommentare hielten das als Absicht fest. Der Executor
hat vor der ersten Zeile Code nachgemessen (Bau, Produktions-Abbild, natives
Type-Stripping unter `node:24-alpine`) statt der Annahme zu folgen.
**Mitigation:** jede Plan-Behauptung ueber vorhandene Abhaengigkeiten oder
Muster vor dem Umsetzen einmal am Code pruefen.
- [ANTI-PATTERN]: Vorschau und Darstellung mit unterschiedlichen Layoutmassen →
ge2: die Kachel nutzte eine andere Rahmenhoehe als die Vorschau, wodurch Seiten
mit fensterhoehen-abhaengigem Layout (`vh`) an anderer Stelle lagen als
ausgewaehlt. **Mitigation:** Auswahl und Darstellung immer gegen dieselben
Masse rechnen.
- [ANTI-PATTERN]: `position: fixed` in einer Dashboard-Kachel → pi9: die
Grossansicht blieb auf die Kachelflaeche beschraenkt, weil `react-grid-item`
eine CSS-`transform` traegt und damit zum Bezugsrahmen wird.
**Mitigation:** Overlays aus einer Kachel per `createPortal` in `document.body`
rendern (Muster: Kalender-Tooltip, jetzt auch `picture-frame-lightbox.tsx`).
- [ANTI-PATTERN]: Playwright klickt in einem per `transform` skalierten iframe
nicht → ge2. **Mitigation:** Klickpunkt umrechnen und per
`elementFromPoint` + `mouse.click` pruefen; ist eine Werkzeuggrenze, kein Produktfehler.
## Infrastructure State
- **Lokaler Stack:** `docker compose` mit web, api, db laeuft auf dem Stand
`1315f37` (`up -d --build` am 22.09. nachmittags). `up` allein baut NICHT neu.
DB ohne Host-Port — Prisma vom Host ueber die Container-IP
(`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`,
`tessera:tessera_dev`).
- **Testdaten lokal:** im Admin-Dashboard stehen ein Bilderrahmen (zwei Bilder)
und ein XFrame auf `example.com` mit Ausschnitt. Harmlos, darf bleiben.
- **alpha** (`alpha.tessera.ctl.de`) wurde am 22.09. auf `80a0d23` gezogen; die
spaeteren Commits (hk4, m1h) sind dort noch nicht drauf. Basic-Auth am Proxy
bleibt — von aussen 401, deshalb Messungen gegen alpha nur am Proxy vorbei
(auf dem Testserver `docker compose exec api` gegen `localhost:3001` oder
Host-Port 3000).
- **Live** (`tessera.ctl.de`) laeuft noch auf 1.2.0; der Pull auf 1.3.0 steht beim
Nutzer aus.
- **Desktop-Client:** neuester CI-Stempel `5aa577a` (Lauf 405). Der Nutzer muss
ihn einmal per Browser installieren, danach laeuft das Update ueber das Tray.
<context>
Die Sitzung war eine lange Kette kleiner, vollstaendig abgeschlossener Auftraege.
Der rote Faden am Ende: Der Nutzer will als naechstes ein Proxmox-Modul, das
zusaetzlich als Kachel auf dem Dashboard erscheint — und kuenftig sollen weitere
Module dasselbe tun. Deshalb wurde zuerst das Fundament geraeumt (m1h), damit
eine Modul-Kachel drei Handgriffe kostet statt sieben und Kacheln gesperrter
Module automatisch verschwinden. Das Geruest dafuer (`WIDGET_MODULE_SLUGS`,
serverseitiger Filter in `dashboard.service.ts`) ist vorhanden und noch leer;
Proxmox waere der erste Eintrag.
Fuer Proxmox selbst ist vorgemerkt: Serveradressen traegt nur ein Administrator
ein (damit ist die Adresse eine bewusste Freigabe statt beliebiger Eingabe), fuer
genau diese Adressen werden Zertifikatsfehler toleriert (Muster:
`favorites/icon-discovery.service.ts`, undici-Dispatcher — Nodes globales `fetch`
ignoriert ihn), Zugangsdaten verschluesselt per `CryptoService` (Muster
`ldap-config.service.ts`), Abfrage im Hintergrund je Mandant nach dem Muster
`dkv-scheduler.service.ts` (`onApplicationBootstrap`, nicht `onModuleInit`).
</context>
<next_action>
Start with: Proxmox-Modul planen (`/gsd-quick` mit eigenem Plan wie bei ge2/hk4) —
Datenmodell fuer Serverzugaenge (Adresse, Typ `pve|pbs|pmg`, verschluesselter
Token), Abfrage im Hintergrund je Mandant, Modulskelett nach
`docs/anleitung-entwicklung.md`, Seite mit Serverliste und Auslastung. Die
Kachel kommt danach als eigener kleiner Auftrag ueber den neuen Weg. Vorher beim
Nutzer abholen: Serveradressen und die lesenden API-Token.
</next_action>
-45
View File
@@ -1,45 +0,0 @@
{
"version": "1.0",
"timestamp": "2026-09-22T14:15:00.000Z",
"phase": null,
"phase_name": "kein laufender Meilenstein — Quick-Tasks nach der Freigabe 1.3.0",
"phase_dir": null,
"plan": null,
"task": null,
"total_tasks": null,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "quick-260921-pi9 Bilderrahmen-Widget", "status": "done", "commit": "737974b,c080580,c3b4597,8bf3601"},
{"id": 2, "name": "quick-260921-qd3 XFrame-Widget", "status": "done", "commit": "d63d9f5,20a9eb2"},
{"id": 3, "name": "quick-260922-frg Tray-Update nennt den Grund, prueft alle 4 h", "status": "done", "commit": "d73aad1"},
{"id": 4, "name": "fast Download-Knoepfe im Desktop-Client", "status": "done", "commit": "747a4d4"},
{"id": 5, "name": "quick-260922-ge2 XFrame-Ausschnitt, Zoom, Nur anzeigen", "status": "done", "commit": "445b1d3,30fdd99,cf70a19"},
{"id": 6, "name": "Freigabe 1.3.0", "status": "done", "commit": "d146234 (Tag v1.3.0)"},
{"id": 7, "name": "quick-260922-hk4 Bilder in den Dateibereich statt in die Datenbank", "status": "done", "commit": "9039cea,8cbfb8b,82472ee"},
{"id": 8, "name": "quick-260922-m1h Modul bringt seine Kachel selbst mit", "status": "done", "commit": "56c07c3,8be0725"}
],
"remaining_tasks": [
{"id": 9, "name": "Proxmox-Modul (PVE/PBS/PMG), nur beobachten — Plan schreiben und bauen", "status": "not_started"},
{"id": 10, "name": "Proxmox-Kachel (kompakte Version) ueber den neuen Weg", "status": "not_started"}
],
"blockers": [
{"description": "Proxmox braucht Zugangsdaten des Nutzers (API-Token, nur lesend) und die Server-Adressen", "type": "human_action", "workaround": "Nutzer legt sie morgen an; Planung und Geruest koennen vorher entstehen"}
],
"async_jobs": [],
"human_actions_pending": [
{"action": "Live-Server auf 1.3.0 ziehen (docker compose -f docker-compose.prod.yml pull && up -d --force-recreate api web)", "context": "Version 1.3.0 ist freigegeben, Abbilder live/v1.3.0 liegen bereit", "blocking": false},
{"action": "Neuen Desktop-Client einmal per Browser installieren", "context": "Danach laeuft das Update ueber das Tray-Symbol; Live hatte den Updater bis 1.2.0 nicht", "blocking": false},
{"action": "Proxmox-API-Token (nur lesend, z. B. Rolle PVEAuditor) fuer PVE, PBS und PMG anlegen und die Server-Adressen bereithalten", "context": "Voraussetzung fuer das naechste Modul; der Nutzer macht das morgen", "blocking": true}
],
"decisions": [
{"decision": "Bilderrahmen-Bilder liegen im Dateibereich user-files, nicht als bytea in der Datenbank", "rationale": "Sicherung laeuft per pg_dump von Hand; 30 Bilder a 5 MiB je Benutzer waeren 150 MB pro Benutzer im Abzug. Geschwindigkeit war NICHT das Argument. Dazu Einheitlichkeit mit Avataren und DKV-Exporten.", "phase": null},
{"decision": "Spalte data bleibt vorerst stehen, DROP erst nach nachgewiesenem Lauf auf alpha und live", "rationale": "Zweistufige Umstellung; getBytes stellt eine fehlende Datei aus data wieder her (alter pg_dump + leeres Volume).", "phase": null},
{"decision": "Widget-Typen an EINER Stelle (packages/shared), Katalog filtert nach Modulzugriff", "rationale": "Vorarbeit fuer Proxmox und weitere Modul-Kacheln; vorher sieben Hartkodierungen je Kachel.", "phase": null},
{"decision": "Proxmox zunaechst NUR beobachten, keine Eingriffe; Zugriff ueber die normale Modulfreigabe", "rationale": "Ausdrueckliche Ansage des Nutzers am 22.09.", "phase": null},
{"decision": "Kacheln gesperrter Module erscheinen gar nicht erst im Katalog", "rationale": "Ausdrueckliche Ansage des Nutzers am 22.09.", "phase": null},
{"decision": "Basic-Auth am Proxy vor alpha bleibt", "rationale": "Entscheidung des Nutzers; aus dem Firmennetz greift eine Ausnahme. Nicht mehr ansprechen.", "phase": null}
],
"uncommitted_files": [],
"next_action": "Proxmox-Modul planen: Prisma-Modelle fuer Serverzugaenge (Adresse, Typ pve|pbs|pmg, Token verschluesselt per CryptoService), Poller je Mandant nach dem Muster dkv-scheduler (onApplicationBootstrap), Modul-Skelett nach docs/anleitung-entwicklung.md, danach die Kachel ueber den neuen Weg (WIDGET_TYPES + WIDGET_MODULE_SLUGS + registerWidget).",
"context_notes": "Sitzung lief ueber 1,5 Tage: zwei neue Widgets, drei Desktop-Korrekturen, Freigabe 1.3.0, Speicherumstellung, Widget-Aufraeumen. Alles gepusht, main == origin/main auf 1315f37, Arbeitsbaum sauber, CI gruen. Der lokale Docker-Stack laeuft mit dem aktuellen Stand; Testfavoriten/Test-Widgets im Admin-Dashboard sind lokal stehen geblieben (harmlos). Naechster Schritt ist inhaltlich klar, haengt aber an den Zugangsdaten des Nutzers."
}
+14 -3
View File
@@ -5,9 +5,9 @@ current_phase: 18
current_phase_name: desktop-client-fertigstellen
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-22T21:10:00.000Z"
last_activity: 2026-09-22
last_activity_desc: Quick 260922-vdk — Dashboard-Raster misst seine Breite auch aus dem Leerzustand; im echten Linux-Client gegengemessen (469 -> 389 px, Ziehen erreicht den rechten Rand)
last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-09-23
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
@@ -464,6 +464,17 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) |
| 260922-m1h | **Ein Modul bringt seine Dashboard-Kachel jetzt selbst mit (Vorarbeit fuer Proxmox).** Bestandsaufnahme (lesend) hatte ergeben: ein neuer Widget-Typ war an SIEBEN Stellen hartkodiert (Union-Typ, Constraints, Registry, eigene `wireXWidget()` je Typ, Aufruf in page.tsx, zweite Liste im Katalogfenster, `@IsIn` im API-DTO); die Verbindung Kachel↔Modul existierte als `WIDGET_MODULE_MAP` in `dashboard.service.ts` (filtert fail-closed), war aber nie befuellt; der Katalog zeigte jedem alle Kacheln, auch die gesperrter Module. Umbau: `WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` in `packages/shared` als EINE Quelle (API validiert per `@IsIn` gegen genau sie), ein generisches `registerWidget()` statt neun Funktionen, Katalog leitet seine Liste aus der Registry ab und filtert ueber `/modules/active` (fail-closed bei Fehler, reine Funktion `visibleWidgetTypes`), nicht verfuegbare Kachel zeigt `widgets.unavailable` statt leer zu bleiben. Deckungsgleichheits-Test faengt kuenftig jede vergessene Stelle. **Befund des Executors, geprueft statt vermutet:** `apps/web` hatte KEINE Abhaengigkeit auf `@tessera/shared` (frueher bewusst) — vor der Umsetzung nachgemessen, dass Bau und Produktions-Abbild das tragen (node:24-alpine strippt die Typen nativ); Folgeregel „nur loeschbare Syntax in shared“ steht als Warnung in der Datei. Verhalten der neun Kacheln unveraendert, im Browser bestaetigt (Reihenfolge, Anlegen, Entfernen, keine rohen Schluessel). Bewusst offen: der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` und die Live-Aktualisierung des Katalogs. **Zahlen:** api 1188 → 1202, web 640 → 659, type-check 4/4, lint 5/5 (74/53 wie Basis). | 2026-09-22 | 56c07c3,8be0725 | [260922-m1h-dashboard-widgets-ein-modul-bringt-seine](./quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/) |
| 260922-vdk | **Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus.** Meldung des Nutzers aus dem **Linux-Client**: rechts neben dem Kalender freie Flaeche, in die sich keine Kachel schieben laesst — „als ob es keinen Anker gibt“. Aus dem Bildschirmfoto zurueckgerechnet (Spaltenbreite 51,5 px, Platzhalter auf Spalte 13 = letzte moegliche, Rasterende bei x=1459 bei ~1660 px Inhaltsbreite): das Raster rechnete mit **1200 px** statt mit der echten Breite, rechts blieben ~460 px totes Feld. Ursache: die Breitenmessung hing in `useEffect(..., [])` mit `if (!containerRef.current) return` — haengt `DashboardGrid` mit NULL Kacheln ein, rendert der fruehe Ruecksprung in den Leerzustand den gemessenen `<div>` gar nicht, der Effekt bricht ab und laeuft nie wieder, auch nicht wenn spaeter die erste Kachel entsteht. `width` blieb die ganze Sitzung auf dem Startwert 1200; react-grid-layout vergleicht strikt (`width > breakpoint`), 1200 ist damit `md` (20 Spalten, 51,6 px) statt `lg`. Fix: Ref-Rueckruf `measureRef` statt Einmal-Effekt — folgt dem Knoten ueber den Wechsel Leerzustand ↔ gefuellt, misst synchron in der Commit-Phase, haengt den ResizeObserver dort an; Fenster-Horcher als zusaetzliches Netz; `applyWidth` verwirft 0 und nicht endliche Werte. **Verhalten sonst unveraendert** — belegte Plaetze bleiben gesperrt, nichts weicht aus (Ansage des Nutzers). **Geprueft im echten Client**, nicht im Browser: `Tessera-1.3.0.AppImage` auf `DISPLAY=:10` ueber den WebKit-Remote-Inspektor gesteuert. Gleicher Fehlerfall vorher/nachher: Kachel 469 px → **389 px** bei 1000 px Bereich, Ziehen endet jetzt bei 603 px = `1000 − 8 − 389`, exakt der rechte Rand. **Messfalle notiert:** im Client gegen `style.width`/`style.transform` messen, nie gegen `getBoundingClientRect()` — bei Fenster im Hintergrund friert WebKitGTK die Animationsuhr ein und der `width`-Uebergang bleibt auf dem alten Wert stehen. **Zahlen:** web 659 → 661 Tests, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-22 | d9f2af3,cf67c8a | [260922-vdk-dashboard-raster-misst-seine-breite-nich](./quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/) |
| 260923-ad9 | **Dashboard-Reiter: mehrere Dashboards je Benutzer.** Wunsch des Nutzers (23.09.): mehrere Dashboards als Reiter, per Ziehen sortierbar, der erste ist der Standard und wird beim Oeffnen geladen; „als Favorit festlegen“ = nach vorn ziehen, kein zusaetzliches Kennzeichen. Umsetzung in 5 Schritten: neues Modell `Dashboard` (userId, tenantId, name, position) mit RLS wie die Nachbartabellen; `WidgetInstance.dashboardId` und `DashboardLayout.dashboardId @unique` — Kacheln und Anordnung haengen jetzt am Reiter statt am Benutzer. Handgeschriebene Migration `20260923120000_dashboard_tabs` haengt den Bestand um: Bestandsuebernahme VOR `NOT NULL`/Fremdschluessel, danach 0 verwaiste Kacheln, 0 verwaiste Anordnungen, je Benutzer genau ein Reiter auf Position 0. Fuenf Endpunkte unter `/dashboard/tabs`; `assertOwnedDashboard` laeuft als erstes in JEDEM Lese- und Schreibweg und antwortet fuer „gibt es nicht“, „Kollege“ und „fremder Mandant“ identisch (kein Orakel) — acht eigene Tests dafuer. Umsortieren und Loeschen je EINE Transaktion nach dem Muster `FavoritesService.reorder`. Riegel: 20 Reiter, 40 Zeichen, 20 Kennungen je Anfrage. Ziehen per Pointer-Ereignissen ohne neue Abhaengigkeit (Muster xframe-Ausschnitt), ausserhalb des Bearbeitungsmodus moeglich, weil „nach vorn ziehen“ das Festlegen des Standards IST; Umbenennen und Loeschen bleiben im Bearbeitungsmodus, Loeschen mit `alertdialog`-Rueckfrage. **Raster unangetastet** (`FREE_PLACEMENT_COMPACTOR`/`preventCollision` und die Breitenmessung aus 260922-vdk) — vom Verifizierer per `git diff` nachgewiesen. **Rundgang mit zwoelf Punkten bestanden** (Bestand 5 Kacheln erhalten, Reiter leer angelegt, Kacheln je Reiter getrennt, Ziehen ordnet um, nach Neuladen kommt der erste Reiter, Umbenennen, Loeschen mit Rueckfrage, letzter Reiter ohne Loeschknopf, Kachelbreite 531 px bei 1625 px Bereich). **Kleiner Befund, offen:** die Knopf-Beschriftungen nennen den betroffenen Reiter nicht (nur das Bestaetigungsfenster tut es). **Zahlen:** api 1202 → 1240 Tests, web 661 → 693, type-check 4/4, lint 5/5 mit 53 Warnungen unveraendert, `migrate diff` ohne Unterschied. | 2026-09-23 | 9c51823,df7a5e7,d34f682,05feaa3,58ce88e | [260923-ad9-dashboard-reiter-mehrere-dashboards-je-b](./quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/) |
| 260923-dhh | **Proxmox-Modul (PVE, PBS, PMG) — nur beobachten.** Sieben Aufgaben: Tabellen `ProxmoxServer`/`ProxmoxServerStatus` mit RLS, Zugang verschluesselt per `CryptoService`, undici-Klient mit Dispatcher nur fuer die eingetragene Adresse, Zwischenlager statt Live-Abfrage, Hintergrunddienst je Mandant (`onApplicationBootstrap`, Tender-Muster), Einstellungsseite mit Verbindungstest, Modulseite, Doku. Zugang wahlweise API-Token oder Benutzer/Passwort; **PMG nur Passwort** (Recherche A1: PMG kennt offenbar keine Token). Kopfzeilen-Formate unterscheiden sich je Produkt (`PVEAPIToken=…=…` vs. `PBSAPIToken=…:…`) und liegen an EINER Stelle. **Riegel „nur lesen“ maschinell erzwungen:** `proxmox-nur-lesen.spec.ts` zaehlt die nicht-lesenden Aufrufe gegen eine benannte Konstante — einzige Ausnahme ist die Ticket-Anmeldung. **Keine SSRF-Adresssperre** (Proxmox steht per Definition im internen Netz, eine Sperre wuerde jede echte Adresse blockieren) — Schutz ist, dass nur ein Administrator Adressen eintraegt. **Rundgang gegen einen selbst gebauten Proxmox-Nachbau** (HTTPS, selbstsigniert, echte Antwortformen): Modul im Marktplatz freigeben, Server anlegen, Zertifikatsfehler korrekt benannt, nach gesetzter Ausnahme „Verbindung erfolgreich“, Zahlen der Modulseite exakt wie im Nachbau (18/42 % Last, 3 laufend / 1 gestoppt), unerreichbarer Server meldet „Der Server ist nicht erreichbar“. **Drei Befunde daraus in 260923-ku6 behoben.** **Ein Befund der Abnahme OFFEN:** `sumOrNull` in `normalizePmg` liefert bei EINEM fehlenden Teilwert die halbe Summe statt `null` — stiller Falschwert genau dort, wo die Feldnamen am schlechtesten belegt sind. **Zahlen:** api 1240 → 1311 Tests, web 693 → 708, type-check 4/4, lint 5/5, 53 Warnungen unveraendert. | 2026-09-23 | 3a1bfd9,4f8a368,998aba9,fccaf8d,723cf68,06fcdc0,3091b04 | [260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n](./quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/) |
| 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
@@ -0,0 +1,573 @@
---
phase: quick-260923-ad9
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260923-AD9]
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql
- apps/api/src/dashboard/dashboard.service.ts
- apps/api/src/dashboard/dashboard.service.spec.ts
- apps/api/src/dashboard/dashboard.controller.ts
- apps/api/src/dashboard/dashboard.controller.spec.ts
- apps/api/src/dashboard/dto/save-layout.dto.ts
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/api/src/dashboard/dto/rename-dashboard.dto.ts
- apps/api/src/dashboard/dto/reorder-dashboards.dto.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/dashboard-api.ts
- apps/web/src/lib/stores/dashboard-store.ts
- apps/web/src/lib/stores/dashboard-store.test.ts
- apps/web/src/components/dashboard/dashboard-tabs.tsx
- apps/web/src/components/dashboard/dashboard-tabs.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- docs/anleitung-anwender.md
- CHANGELOG.md
estimate:
tokens: 185000
raw_tokens: 185000
tasks: 5
confidence: low
must_haves:
truths:
- "Ein Benutzer hat mehrere Dashboards, die oben als Reiter nebeneinander stehen. Jeder Reiter trägt seine EIGENEN Kacheln und seine EIGENE Anordnung — was auf Reiter 1 liegt, erscheint nicht auf Reiter 2."
- "Beim Öffnen des Dashboards wird immer der ERSTE Reiter geladen. Es gibt kein zusätzliches Stern-Kennzeichen und kein getrenntes Standard-Feld: nach vorn ziehen IST das Festlegen des Standards."
- "Reiter lassen sich mit der Maus an eine andere Stelle ziehen; die neue Reihenfolge bleibt nach dem Neuladen erhalten. Ein Klick ohne Ziehen wechselt nur den Reiter."
- "Reiter lassen sich anlegen (leer, Name „Dashboard 2“, „Dashboard 3“, … — die nächste freie Zahl), umbenennen und löschen. Der letzte verbleibende Reiter kann nicht gelöscht werden; der Server weist das ab, die Oberfläche bietet es gar nicht erst an."
- "Niemand verliert beim Einspielen etwas: jeder Benutzer, der heute Kacheln ODER eine gespeicherte Anordnung hat, findet danach genau EINEN Reiter namens „Dashboard“ mit genau seinen bisherigen Kacheln in genau seiner bisherigen Anordnung vor. Ein Benutzer ohne beides bekommt beim ersten Öffnen einen leeren Reiter „Dashboard“ angelegt."
- "Fail-closed gegen fremde Reiter: Kacheln lesen, Kachel anlegen, Anordnung lesen, Anordnung speichern, umbenennen, löschen und umsortieren antworten für einen Reiter, der dem Aufrufer nicht gehört (fremder Benutzer, fremder Mandant, unbekannte Kennung), mit derselben Nicht-gefunden-Antwort — nie mit einer Antwort, aus der sich die Existenz des fremden Reiters ablesen lässt."
- "Die neue Tabelle trägt Mandantenkennung und denselben Zeilenschutz wie ihre Nachbarn (Mandant UND Benutzer, Form aus 20260911120000); der Wächter-Test der RLS-Abdeckung bleibt grün, und die Zugriffsklassifikation führt das neue Paar (Datei, Modell) mit gemessenem Stand."
- "Am Raster selbst ändert sich nichts: FREE_PLACEMENT_COMPACTOR mit preventCollision, belegte Plätze bleiben gesperrt, nichts weicht aus; die Breitenmessung aus quick-260922-vdk bleibt unverändert."
- "Keine neue Abhängigkeit: das Ziehen der Reiter läuft über dieselben Pointer-Ereignisse wie die Ausschnittwahl im XFrame-Einstellungsdialog (quick-260922-ge2)."
- "Alle Tore grün: api gesamt ≥ 1202 Tests in ≥ 77 Dateien, web gesamt ≥ 661 Tests in ≥ 83 Dateien, `pnpm type-check` 4/4, `pnpm lint` 5/5 mit weiterhin GENAU 53 Warnungen in web, `prisma migrate diff` gegen die lokale Datenbank meldet weiterhin keinen Unterschied."
artifacts:
- "apps/api/prisma/schema.prisma — neues Modell `Dashboard` (id, userId, tenantId, name, position, createdAt, updatedAt; Index auf userId und tenantId, KEIN Unique auf (userId, position)); `WidgetInstance.dashboardId` und `DashboardLayout.dashboardId @unique` mit Relation und `onDelete: Cascade`; `DashboardLayout.userId` verliert `@unique`, behält einen gewöhnlichen Index"
- "apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql — hand geschriebene Migration mit deutschem Kopf: Tabelle, Indizes, Zeilenschutz (ENABLE + FORCE + tenant_isolation_policy mit Benutzerdimension), Bestandsübernahme für jeden Benutzer mit Kacheln oder Anordnung, Nachtragen der Fremdschlüssel erst NACH der Übernahme"
- "apps/api/src/dashboard/dashboard.service.ts — `listDashboards` (legt bei null vorhandenen genau einen an, gegen Doppelanlage per Transaktions-Sperre gesichert), `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`, privater Riegel `assertOwnedDashboard`; `getLayout`/`saveLayout`/`getWidgets`/`addWidget` arbeiten je Reiter"
- "apps/api/src/dashboard/dashboard.controller.ts — fünf neue Routen unter `tabs`, `tabs/order` VOR den Routen mit Platzhalter deklariert; `dashboardId` als Abfrageparameter bei den beiden Lesewegen, im Rumpf bei den beiden Schreibwegen"
- "apps/api/src/dashboard/dto/ — `rename-dashboard.dto.ts`, `reorder-dashboards.dto.ts` (Obergrenzen als Riegel gegen Massenanfragen), erweiterte `save-layout.dto.ts` und `create-widget.dto.ts`"
- "apps/api/src/dashboard/dashboard.controller.spec.ts — NEU: Durchreichen der Reiter-Kennung, Fehlerformen, und ein quelltextlesender Wächter, dass `tabs/order` VOR den Platzhalter-Routen steht (NestJS-Routenreihenfolge)"
- "apps/web/src/components/dashboard/dashboard-tabs.tsx — NEU: Reiterleiste mit Wechseln, Anlegen, Umbenennen, Löschen und Ziehen zum Umsortieren über Pointer-Ereignisse (Muster xframe-config-form.tsx, inklusive der jsdom-Schutzhülle um setPointerCapture)"
- "apps/web/src/lib/stores/dashboard-store.ts — `dashboards`, `activeDashboardId`, `selectDashboard`, `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`; Schutz gegen doppeltes Laden; ungespeicherte Anordnung wird VOR dem Reiterwechsel auf den ALTEN Reiter geschrieben"
- "apps/web/src/messages/de.json + en.json — neue Zeichenketten unter `widgets.tabs.*`, deutsch in der Sie-Form mit echten Umlauten (der Umlaut-Wächter liest de.json)"
- "docs/mandantentrennung-zugriffsklassifikation.md — neue Zeile für das Paar (`dashboard.service.ts`, `dashboard`), nachgerechnete Bereichs- und Summenzeile, nachgezogene Paarzahl"
- "docs/anleitung-anwender.md + CHANGELOG.md — Beschreibung der Reiter in Alltagssprache"
key_links:
- "Öffnen → `loadDashboard()` → `GET /dashboard/tabs` (legt bei Bedarf den ersten an) → erster Reiter der nach `position` aufsteigend sortierten Liste wird aktiv → `GET /dashboard/widgets?dashboardId=…` + `GET /dashboard/layout?dashboardId=…` → `DashboardGrid` bekommt genau die Kacheln dieses Reiters"
- "Reiter ziehen → Pointer-Ereignisse in `dashboard-tabs.tsx` → beim Loslassen die vollständige Kennungsliste in neuer Reihenfolge → `PUT /dashboard/tabs/order` → `withTenantTransaction` schreibt alle `position`-Werte des Benutzers in EINER Transaktion neu (0…n-1) → nächstes Öffnen lädt den nun ersten Reiter"
- "Reiter löschen → `DELETE /dashboard/tabs/:id` → Riegel `assertOwnedDashboard` → Abweisung, wenn es der letzte Reiter ist → sonst in EINER Transaktion: Kacheln des Reiters, Anordnung des Reiters, Reiter selbst, danach Positionen der verbleibenden Reiter lückenlos neu geschrieben"
- "Kachel hinzufügen → Store reicht `activeDashboardId` durch → `POST /dashboard/widgets` mit Reiter-Kennung → Riegel prüft Besitz → Kachel hängt am richtigen Reiter"
- "Neues Modell `Dashboard` mit `tenantId` → `rls-coverage.spec.ts` Test 1/2 verlangen ENABLE + Policy in einer Migration → Migration liefert beides → Wächter bleibt grün"
- "`tenantPrisma.dashboard` / `tx.dashboard` in `dashboard.service.ts` → `rls-access-inventory.spec.ts` findet ein neues Paar (Datei, Modell) → Eintrag in `docs/mandantentrennung-zugriffsklassifikation.md` mit Stand `gebunden` → Wächter bleibt grün"
---
# Quick-Aufgabe 260923-ad9: Dashboard-Reiter — mehrere Dashboards je Benutzer
<objective>
Das Dashboard trägt heute genau eine Kachelfläche je Benutzer. Diese Aufgabe gibt jedem Benutzer mehrere
Dashboards, die oben als Reiter nebeneinander stehen: jeder Reiter mit eigenen Kacheln und eigener
Anordnung, per Ziehen umsortierbar, der erste ist der Standard und wird beim Öffnen geladen.
Purpose: Der Wunsch des Nutzers vom 22./23.09.2026, mit allen Entscheidungen bereits getroffen (siehe
„Gebundene Entscheidungen“). Der Plan setzt um und sichert ab — es ist nichts mehr zu erforschen und
nichts mehr rückzufragen.
Output: Neues Datenmodell mit Bestandsübernahme, fünf neue Endpunkte mit Besitz-Riegel, Reiterleiste in
der Oberfläche, Ziehen zum Umsortieren ohne neue Abhängigkeit, nachgezogene Zeilenschutz-Dokumentation,
Anwenderhandbuch und Changelog. Alle Tore grün, Prüfliste für den Browser-Rundgang im SUMMARY.
</objective>
## Gebundene Entscheidungen (nicht neu verhandeln)
- **D-01 — Datenmodell:** neues Modell `Dashboard` (id, userId, tenantId, name, position, createdAt,
updatedAt), Reihenfolge über `position` (Integer), aufsteigend sortiert. **KEIN** Unique auf
(userId, position) — beim Umsortieren werden alle Positionen des Benutzers in EINER Transaktion neu
geschrieben, ein Unique wäre dabei nur im Weg. Index auf `userId` und auf `tenantId` wie bei den
Nachbarmodellen.
- **D-02 — Anhängen:** `WidgetInstance` bekommt `dashboardId`. `DashboardLayout` hängt künftig am
Dashboard statt am Benutzer (das heutige `userId @unique` fällt, `dashboardId @unique` kommt);
`userId`/`tenantId` bleiben auf beiden Modellen für Besitz- und Mandantenprüfung erhalten.
- **D-03 — Bestandsübernahme:** für jeden Benutzer, der heute Kacheln ODER eine Anordnung hat, entsteht
genau EIN Dashboard mit `position = 0` und dem Namen „Dashboard“; vorhandene Kacheln und die vorhandene
Anordnung werden darauf umgehängt. Datenbankänderung heißt: geht über `main` als reguläre Version,
nicht als Hotfix.
- **D-04 — Zeilenschutz ist Pflicht:** das neue Modell braucht `tenantId` und denselben Zeilenschutz wie
die Nachbartabellen. Die Wächter in `apps/api/src/prisma/rls-coverage.spec.ts` und
`apps/api/src/prisma/rls-access-inventory.spec.ts` müssen grün bleiben.
- **D-05 — keine neue Abhängigkeit** für das Ziehen der Reiter. Dem vorhandenen Pointer-Ereignis-Muster
aus `apps/web/src/components/settings/xframe-config-form.tsx` (quick-260922-ge2) folgen.
- **D-06 — am Raster ändert sich nichts:** `FREE_PLACEMENT_COMPACTOR` mit `preventCollision` bleibt,
belegte Plätze bleiben gesperrt, nichts weicht aus (Nutzeransage 22.09.). Die Breitenmessung aus
quick-260922-vdk bleibt unverändert.
- **D-07 — Oberflächentexte** auf Deutsch in der Sie-Form über next-intl, keine rohen Zeichenketten;
Kommentare im Code auf Deutsch wie in den Nachbardateien.
- **D-08 — neuer Reiter** startet leer und heißt „Dashboard 2“, „Dashboard 3“, … (nächste freie Zahl).
- **D-09 — „Als Favorit festlegen“ = nach vorn ziehen.** Kein Stern-Kennzeichen, kein getrenntes
Standard-Feld, kein Merken des zuletzt benutzten Reiters: beim Öffnen wird immer der erste geladen.
- **D-10 — der letzte verbleibende Reiter kann nicht gelöscht werden.**
**Nicht im Umfang:** Freigeben/Teilen von Dashboards an andere Benutzer, Vorlagen, Reiter je Modul.
## Gemessener Ausgangsstand (nicht erneut zu erheben)
Gemessen am 23.09.2026 vor Beginn, auf diesem Rechner:
| Tor | Stand |
|---|---|
| `pnpm --filter @tessera/api test` | 1202 Tests in 76 Dateien, grün |
| davon `dashboard.service.spec.ts` | 31 Tests |
| davon `rls-coverage.spec.ts` / `rls-access-inventory.spec.ts` | 5 / 30 Tests |
| `pnpm --filter @tessera/web test` | 661 Tests in 81 Dateien, grün |
| davon `dashboard-store.test.ts` / `dashboard-grid.test.tsx` | 6 / 12 Tests |
| `pnpm type-check` | 4 von 4 erfolgreich |
| `pnpm lint` | 5 von 5 erfolgreich, **genau 53 Warnungen** in web |
| `as unknown as` in `apps/web/src` ohne Testdateien | 3 |
| Prisma-Abweichung lokal | „No difference detected.“ |
| Lokale Datenbank | 6 Kacheln bei 2 Benutzern, 1 gespeicherte Anordnung, 3 Benutzer — die Vereinigung „hat Kacheln oder Anordnung“ ergibt **2** Benutzer |
Die lokale Datenbank ist vom Host aus über die Container-IP erreichbar (`docker inspect` auf
`tessera-ctl-db-1`, Zugangsdaten `tessera` / `tessera_dev`, Datenbank `tessera`) — sie hat bewusst keinen
Host-Port. Gemessen: `tessera` ist in diesem Abbild ein Superuser und umgeht den Zeilenschutz, eine
Migration sieht also alle Bestandszeilen.
## Festgelegte technische Form (vom Planer entschieden, nicht rückzufragen)
**Endpunkte** (alle unter dem vorhandenen Präfix `dashboard`, alle mit dem vorhandenen
`extractContext`-Muster für Benutzer und Mandant):
| Weg | Zweck |
|---|---|
| `GET /dashboard/tabs` | Reiter des Benutzers, nach `position` aufsteigend; legt genau einen an, wenn keiner existiert |
| `POST /dashboard/tabs` | neuen, leeren Reiter am Ende anlegen (Name automatisch, D-08) |
| `PUT /dashboard/tabs/order` | vollständige Kennungsliste in Wunschreihenfolge |
| `PATCH /dashboard/tabs/:id` | umbenennen |
| `DELETE /dashboard/tabs/:id` | Reiter mit seinen Kacheln und seiner Anordnung löschen |
| `GET /dashboard/layout?dashboardId=…` | Anordnung eines Reiters |
| `PUT /dashboard/layout` | Rumpf trägt `dashboardId` und `layouts` |
| `GET /dashboard/widgets?dashboardId=…` | Kacheln eines Reiters |
| `POST /dashboard/widgets` | Rumpf trägt `widgetType` und `dashboardId` |
`PATCH /dashboard/widgets/:id/config`, `DELETE /dashboard/widgets/:id` und die drei Suchanbieter-Wege
bleiben **unverändert** — eine Kachelkennung ist für sich eindeutig.
**Riegel `assertOwnedDashboard(dashboardId, userId, tenantId)`:** liest den Reiter über den gebundenen
Klienten und wirft für „gibt es nicht“, „gehört einem Kollegen“ und „liegt bei einem fremden Mandanten“
dieselbe `NotFoundException` — niemals eine abweichende Antwort, aus der sich die Existenz ablesen
ließe. Dasselbe Vorgehen wie der Widget-Riegel in `favorites.service.ts` (T-GWH-05).
**Obergrenzen als Riegel gegen Massenanfragen:** höchstens 20 Reiter je Benutzer; Reitername nach dem
Beschneiden 1 bis 40 Zeichen; die Kennungsliste beim Umsortieren höchstens 20 Einträge, ohne Dubletten.
**Umsortieren** folgt wörtlich dem Muster `FavoritesService.reorder` (260917-jdd): eine
`withTenantTransaction`, darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung mit der
gesendeten Liste prüfen (sonst Abweisung, kein Teilschreiben), dann je Eintrag ein `updateMany` mit
`id` UND `userId` in der Bedingung und einer Prüfung auf genau eine getroffene Zeile. `withTenantTransaction`
setzt keine Benutzerdimension in der Sitzung — deshalb trägt jede Bedingung innerhalb der Transaktion
`userId` selbst, als zweites Netz.
## Tasks
<tasks>
<task type="tracer">
<name>Task 1: Datenmodell, Migration und Reiter-Grundlage — das heutige Dashboard wird zu Reiter 1</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dashboard/dto/save-layout.dto.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>apps/api/prisma/schema.prisma (Modelle DashboardLayout, WidgetInstance, DashboardImage), apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql (Vorlage für Kopf, Indizes und Zeilenschutz einer persönlichen Tabelle), apps/api/src/prisma/prisma-tenant.extension.ts (forTenant, withTenantTransaction), apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts (Zwei-Klienten-Nachbau), apps/api/src/favorites/favorites.service.spec.ts Zeile 24-29 und 166-175 (Mock-Form für withTenantTransaction), apps/api/src/prisma/rls-coverage.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md Zeile 160-180, 224-236 und 676-680</read_first>
<action>
Schema (D-01/D-02): neues Modell `Dashboard` mit `id` (uuid-Vorgabe), `userId`, `tenantId`, `name`,
`position` als Integer, `createdAt`, `updatedAt`, Index auf `userId` und auf `tenantId`, KEIN Unique auf
der Positionsspalte. Keine Relation zu `User` oder `Tenant` — das ist die Form der Nachbarmodelle
`WidgetInstance` und `DashboardImage`, und eine Relation zu `User` würde an Bestandszeilen verwaister
Benutzer scheitern. `WidgetInstance` bekommt `dashboardId` als Pflichtfeld mit Relation auf `Dashboard`
und Löschweitergabe, dazu einen Index darauf; `DashboardLayout` bekommt `dashboardId` als Pflichtfeld mit
Relation, Löschweitergabe und Eindeutigkeit, verliert die Eindeutigkeit auf `userId` und behält dort einen
gewöhnlichen Index. Auf `Dashboard` die beiden Gegenseiten der Relationen eintragen. Der deutsche
Kommentar über dem neuen Modell nennt D-01 (warum kein Unique auf der Position) und D-09 (warum es kein
Standard-Feld gibt).
Migration `20260923120000_dashboard_tabs/migration.sql` von Hand schreiben, in dieser Reihenfolge — die
Bestandsübernahme MUSS vor den Fremdschlüsseln stehen, sonst scheitert sie an genau diesen:
1. Deutscher Kopfkommentar nach der Form von `20260921120000_dashboard_image`: Zweck, D-01 (keine
Eindeutigkeit auf der Position, weil das Umsortieren alle Positionen eines Benutzers in einer
Transaktion neu schreibt), D-03 (niemand verliert etwas), D-04 (Zeilenschutz mit Benutzerdimension,
Form aus `20260911120000`), und der Hinweis, dass die Rechte für `tessera_app` über die
Vorgaberechte aus `20260909130000` kommen.
2. Tabelle `Dashboard` anlegen, Indizes auf `userId` und `tenantId`.
3. Zeilenschutz einschalten, erzwingen und die Regel `tenant_isolation_policy` anlegen: Mandant gleich
`current_tenant_id()` UND (`current_user_id()` ist NULL ODER Benutzer gleich `current_user_id()`) —
wörtlich die Form aus `20260921120000_dashboard_image`.
4. Bestandsübernahme: je Benutzer aus der Vereinigung der Benutzer mit Kacheln und der Benutzer mit
gespeicherter Anordnung genau eine Zeile einfügen, mit `gen_random_uuid()` als Textkennung, dem Namen
„Dashboard“, Position 0 und der Mandantenkennung aus der Bestandszeile. Gegen den theoretischen Fall
„derselbe Benutzer mit zwei Mandantenkennungen“ mit einer Auswahl absichern, die je Benutzer genau
eine Zeile liefert.
5. `dashboardId` auf `WidgetInstance` zunächst als NULLbare Spalte ergänzen, aus der neuen Tabelle über
die Benutzerkennung befüllen, dann auf NOT NULL setzen, Index anlegen, Fremdschlüssel mit
Löschweitergabe ergänzen.
6. Dasselbe für `DashboardLayout`; zusätzlich die Eindeutigkeit auf `userId` entfernen, dort einen
gewöhnlichen Index anlegen und die Eindeutigkeit auf `dashboardId` anlegen.
Dienst `dashboard.service.ts`:
- `listDashboards(userId, tenantId)` liest die Reiter des Benutzers über `forTenant(...)` nach `position`
aufsteigend. Ist die Liste leer, wird genau ein Reiter „Dashboard“ mit Position 0 angelegt und die
Liste erneut gelesen. Das Anlegen läuft in einer `withTenantTransaction`, die als erste Anweisung eine
Transaktionssperre auf die Benutzerkennung nimmt (`pg_advisory_xact_lock` mit `hashtext` über die
Benutzerkennung und einer zweiten Ganzzahl; beides sind eingebaute Postgres-Funktionen) und danach
innerhalb der Sperre erneut zählt — zwei gleichzeitige erste Aufrufe desselben Benutzers dürfen nicht
zwei Reiter erzeugen. Der deutsche Kommentar erklärt genau diesen Grund.
- Privater Riegel `assertOwnedDashboard(dashboardId, userId, tenantId)` wie unter „Festgelegte technische
Form“ beschrieben, mit deutschem Kommentar, der die drei ununterscheidbaren Fälle benennt.
- `getLayout`, `saveLayout`, `getWidgets`, `addWidget` nehmen die Reiter-Kennung entgegen, rufen zuerst
den Riegel und arbeiten danach über `dashboardId` statt über `userId`. Die vorhandenen Besitzprüfungen
über die Benutzerkennung bleiben zusätzlich bestehen — zweites Netz, kein Ersatz, genau wie im
Kopfkommentar der Datei beschrieben. `saveLayout` behält die Übersetzung der
`PrismaClientUnknownRequestError` in die deutsche Konfliktmeldung, jetzt auf der Eindeutigkeit der
Reiter-Kennung.
- `getWidgets` behält den Modulfilter (D-22, PERM-07) und die bewusst ungebundene Katalogabfrage
unverändert — nur die Bedingung der ersten Abfrage wechselt von Benutzer auf Reiter.
Controller: die vier betroffenen Wege reichen die Reiter-Kennung durch (bei den Lesewegen als
Abfrageparameter, bei den Schreibwegen aus dem Rumpf), und `GET /dashboard/tabs` kommt hinzu. Die
beiden DTOs bekommen ein Pflichtfeld für die Reiter-Kennung mit Zeichenkettenprüfung. Weitere Reiter-Wege
folgen in Task 2 — dieser Task hält den Baum übersetzbar und das Verhalten für den Benutzer
unverändert (ein Reiter, wie bisher).
Tests in `dashboard.service.spec.ts`: die bestehenden 31 bleiben unverändert bestehen; der Mock von
`prisma-tenant.extension` bekommt `withTenantTransaction` nach der Form aus `favorites.service.spec.ts`
(protokollierender Durchreicher auf den gebundenen Klienten, der Transaktionsklient braucht zusätzlich
eine Attrappe für das rohe Ausführen der Sperranweisung), der gebundene Nachbau bekommt das neue Modell.
Neu mindestens: erster Aufruf ohne vorhandenen Reiter legt genau einen an und liefert ihn; zweiter Aufruf
legt keinen weiteren an; die Liste kommt nach Position aufsteigend; Kacheln und Anordnung werden über die
Reiter-Kennung gelesen und geschrieben; fremde Reiter-Kennung führt bei allen vier Wegen zur
Nicht-gefunden-Antwort; jeder dieser Wege lief über den gebundenen Klienten mit der richtigen
Mandantenkennung.
Dokument `docs/mandantentrennung-zugriffsklassifikation.md`: neue Zeile in der Fundstellentabelle für das
Paar (`apps/api/src/dashboard/dashboard.service.ts`, `dashboard`) mit Klasse `muss-mandantengebunden` und
Stand `gebunden`, Begründung in der Form der Nachbarzeilen (persönliche Tabelle mit Mandanten- und
Benutzerdimension, Regel von Anfang an mit Benutzerdimension, Besitzprüfung zusätzlich in der Anwendung).
Bereichszeile `dashboard` und Summenzeile mit der Zählschleife aus dem Gate NACHRECHNEN, nicht
abschreiben; die Paarzahl in der Überschrift der Klassen-Verteilung und den Fließtext, der von den vier
Paaren des Bereichs spricht, nachziehen.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate && DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" pnpm --filter @tessera/api exec prisma migrate diff --from-url "postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" --to-schema-datamodel prisma/schema.prisma --exit-code</automated>
<automated>docker exec tessera-ctl-db-1 psql -U tessera -d tessera -t -c 'SELECT (SELECT count(*) FROM "WidgetInstance" WHERE "dashboardId" IS NULL) AS kacheln_ohne_reiter, (SELECT count(*) FROM "DashboardLayout" WHERE "dashboardId" IS NULL) AS anordnungen_ohne_reiter, (SELECT count(*) FROM "Dashboard") AS reiter, (SELECT count(*) FROM "Dashboard" WHERE "position" <> 0) AS reiter_nicht_an_position_null;'</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -20</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check</automated>
</verify>
<done>
Die Migration läuft gegen die lokale Datenbank durch und `prisma migrate diff` meldet danach weiterhin
keinen Unterschied (Rückgabewert 0). Die Zählabfrage liefert 0 Kacheln ohne Reiter, 0 Anordnungen ohne
Reiter, genau 2 Reiter (die gemessene Zahl der Benutzer mit Bestand auf diesem Rechner) und 0 Reiter
abseits von Position 0. `pnpm --filter @tessera/api test` ist grün mit ≥ 1202 Tests in 76 Dateien, davon
≥ 39 in `dashboard.service.spec.ts`; `rls-coverage.spec.ts` (5) und `rls-access-inventory.spec.ts` (30)
sind unverändert grün — letzteres beweist, dass das neue Paar im Dokument steht. `pnpm type-check` ist
4 von 4.
</done>
<reversibility rating="costly">Die Bestandsübernahme hängt vorhandene Kacheln und Anordnungen auf neue Zeilen um und entfernt die Eindeutigkeit auf `DashboardLayout.userId` — zurück geht das nur über eine weitere Migration, die Daten selbst bleiben dabei erhalten. KEIN Entscheidungs-Halt davor: die Form der Migration ist als D-03 bereits gebunden und wird hier nicht erneut zur Abstimmung gestellt.</reversibility>
</task>
<task type="auto" tdd="true">
<name>Task 2: Reiter anlegen, umbenennen, löschen und umsortieren — Dienst, DTOs, Endpunkte</name>
<files>apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dashboard/dashboard.controller.spec.ts, apps/api/src/dashboard/dto/rename-dashboard.dto.ts, apps/api/src/dashboard/dto/reorder-dashboards.dto.ts</files>
<read_first>apps/api/src/favorites/favorites.service.ts (Methode `reorder` — Vorlage für Transaktion, Exakt-Abgleich und die Bedingung je Aktualisierung), apps/api/src/favorites/dto/reorder-favorites.dto.ts (Vorlage für Obergrenzen), apps/api/src/dashboard/dashboard-images.controller.spec.ts (Form einer Controller-Testdatei in diesem Bereich), apps/api/src/dashboard/dashboard.controller.ts</read_first>
<behavior>
- Anlegen ohne Namensvorgabe erzeugt „Dashboard 2“, wenn nur „Dashboard“ existiert; „Dashboard 3“, wenn „Dashboard“ und „Dashboard 2“ existieren; und füllt eine Lücke, wenn „Dashboard“ und „Dashboard 3“ existieren (dann „Dashboard 2“).
- Anlegen hängt den neuen Reiter ans Ende (höchste vorhandene Position plus eins) und liefert ihn mit leerer Kachelliste.
- Anlegen über der Obergrenze von 20 Reitern wird abgewiesen, ohne eine Zeile zu schreiben.
- Umbenennen beschneidet Leerraum; ein leerer Name und ein Name über 40 Zeichen werden abgewiesen.
- Umbenennen eines fremden Reiters liefert die Nicht-gefunden-Antwort.
- Löschen entfernt Reiter, seine Kacheln und seine Anordnung in EINER Transaktion und schreibt die Positionen der verbleibenden Reiter lückenlos von 0 an neu.
- Löschen des letzten verbleibenden Reiters wird abgewiesen und schreibt nichts.
- Löschen eines fremden Reiters liefert die Nicht-gefunden-Antwort.
- Umsortieren mit der vollständigen, dublettenfreien Kennungsliste schreibt die Positionen 0…n-1 in der gesendeten Reihenfolge.
- Umsortieren mit einer unvollständigen Liste, mit einer unbekannten Kennung oder mit der Kennung eines fremden Reiters wird abgewiesen, ohne eine einzige Position zu ändern.
</behavior>
<action>
Zuerst die Testfälle aus dem Verhaltensblock in `dashboard.service.spec.ts` schreiben (rot), dann den
Dienst ergänzen.
Dienst: `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`. Anlegen und
Umbenennen laufen als Einzeloperationen über `forTenant(...)`; Löschen und Umsortieren laufen je als EINE
`withTenantTransaction`, weil sie mehrere Schritte atomar brauchen — dieselbe Begründung, die der
Kopfkommentar von `prisma-tenant.extension.ts` für `favorites.service.ts` festhält. Innerhalb der
Transaktion trägt jede Bedingung die Benutzerkennung selbst, weil diese Form keine Benutzerdimension in
der Sitzung setzt.
Namensvergabe (D-08): die vorhandenen Namen des Benutzers lesen und die kleinste Zahl ab 2 wählen, für
die der zusammengesetzte Name noch frei ist. Der deutsche Kommentar hält fest, dass dieser Name ein
gespeicherter Datenwert ist und keine Oberflächenbeschriftung — deshalb steht er hier und nicht in den
Übersetzungsdateien, genau wie der Name, den die Migration vergibt.
Löschen: Riegel zuerst, dann die Zahl der Reiter des Benutzers prüfen (bei eins abweisen mit einer
deutschen Konfliktmeldung in der Sie-Form), dann in der Transaktion die Kacheln des Reiters, die
Anordnung des Reiters und den Reiter selbst entfernen und zuletzt die Positionen der verbleibenden Reiter
lückenlos neu schreiben. Die Löschweitergabe in der Datenbank bleibt als zweites Netz bestehen; der
geschriebene Weg ist der gebundene.
Umsortieren: wörtlich nach dem Muster `FavoritesService.reorder`.
DTOs: `rename-dashboard.dto.ts` mit Beschneiden und Längenprüfung 1 bis 40; `reorder-dashboards.dto.ts`
mit Feldprüfung auf ein dublettenfreies Feld von 1 bis 20 Kennungen. Beide mit deutschem Kommentar, der
die Obergrenze als Riegel gegen Massenanfragen benennt.
Controller: die fünf Reiter-Wege ergänzen. Die Route mit dem festen Bestandteil für das Umsortieren MUSS
vor den Routen mit Platzhalter stehen — in dieser Anwendung hat eine Route mit Platzhalter schon einmal
eine dahinter stehende feste Route verdeckt, und Einzeltests am Dienst fangen das nicht.
`dashboard.controller.spec.ts` neu anlegen (Form aus `dashboard-images.controller.spec.ts`): die
Reiter-Kennung wird aus Abfrageparameter bzw. Rumpf an den Dienst durchgereicht; fehlender Benutzer- oder
Mandantenkontext führt zur vorhandenen Abweisung; und ein quelltextlesender Wächter prüft, dass die
Stelle des festen Wegs für das Umsortieren im Dateitext VOR der ersten Stelle mit Platzhalter unter
demselben Präfix liegt — mit einer Fehlermeldung, die den Grund nennt.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -20</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
</verify>
<done>
`pnpm --filter @tessera/api test` grün mit ≥ 1202 Tests in ≥ 77 Dateien; `dashboard.service.spec.ts`
trägt ≥ 45 Tests (die 31 alten unverändert), `dashboard.controller.spec.ts` ≥ 6. Jeder Punkt des
Verhaltensblocks hat einen eigenen Testfall, insbesondere je einer für „fremder Reiter“ bei Umbenennen,
Löschen und Umsortieren und einer für „letzter Reiter bleibt“. `pnpm type-check` 4/4, `pnpm lint` 5/5 mit
unverändert 53 Warnungen in web.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Reiterleiste in der Oberfläche — wechseln, anlegen, umbenennen, löschen</name>
<files>apps/web/src/lib/dashboard-api.ts, apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/stores/dashboard-store.test.ts, apps/web/src/components/dashboard/dashboard-tabs.tsx, apps/web/src/components/dashboard/dashboard-tabs.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/stores/dashboard-store.test.ts, apps/web/src/lib/dashboard-api.ts, apps/web/src/app/(portal)/page.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx (Form eines deutschen Bedienbausteins mit next-intl), apps/web/src/messages/de.json Abschnitt `widgets`</read_first>
<behavior>
- Beim Laden holt der Store zuerst die Reiter, macht den ERSTEN aktiv und lädt erst danach dessen Kacheln und Anordnung.
- Zweimaliges Aufrufen des Ladens hintereinander löst nur EINEN Abruf der Reiterliste aus (Schutz gegen doppeltes Einhängen).
- Ein Reiterwechsel mit ungespeicherter Anordnung schreibt die Anordnung zuerst für den ALTEN Reiter und wechselt erst danach — die gespeicherte Kennung ist die des alten Reiters, nicht die des neuen.
- Nach einem Reiterwechsel stehen im Zustand ausschließlich die Kacheln und die Anordnung des neuen Reiters.
- Eine hinzugefügte Kachel wird mit der Kennung des aktiven Reiters angelegt.
- Anlegen eines Reiters hängt ihn hinten an und macht ihn aktiv; die Kachelfläche ist leer.
- Löschen des aktiven Reiters macht den dann ersten Reiter aktiv und lädt dessen Inhalt.
- Die Reiterleiste zeigt Umbenennen und Löschen nur im Bearbeitungsmodus; beim letzten verbleibenden Reiter wird Löschen gar nicht erst angeboten.
</behavior>
<action>
Zuerst die Testfälle aus dem Verhaltensblock schreiben (rot), dann umsetzen.
`dashboard-api.ts`: Abrufe für die fünf Reiter-Wege ergänzen und die vier bestehenden Aufrufe um die
Reiter-Kennung erweitern (Leseweg als Abfrageparameter, Schreibweg im Rumpf), in derselben Form wie die
vorhandenen Funktionen (gleiche Basisadresse, `credentials`, Fehlerwurf bei nicht erfolgreicher Antwort).
`dashboard-store.ts`: Zustand um die Reiterliste, die aktive Reiter-Kennung und ein Kennzeichen für den
laufenden Reiterwechsel erweitern. `loadDashboard` holt die Reiter, setzt den ersten als aktiv und lädt
dessen Inhalt; ein auf Modulebene gehaltenes Versprechen verhindert, dass ein zweites Einhängen einen
zweiten Abruf auslöst. `selectDashboard` schreibt bei ungespeicherter Anordnung zuerst für den alten
Reiter (Kennung zum Aufrufzeitpunkt festhalten, nicht nach dem Wechsel lesen) und lädt danach Kacheln und
Anordnung des neuen Reiters. `createDashboard`, `renameDashboard`, `deleteDashboard` pflegen die
Reiterliste und den aktiven Reiter. Die einmalige Umrechnung alter Rastereinheiten
(`migrateGridLayouts`/`withGridVersion`, quick-260916-bwo) bleibt unverändert und gilt weiterhin je
Reiter — der Marker muss auch beim Speichern nach einem Reiterwechsel mitgeschrieben werden.
`dashboard-tabs.tsx` neu: waagerechte Leiste über dem Raster, jeder Reiter ein Bedienelement mit seinem
Namen, der aktive sichtbar hervorgehoben, Beschriftungen und Hinweise ausschließlich über next-intl.
Klick wechselt. Im Bearbeitungsmodus zusätzlich: ein Knopf zum Anlegen am Ende der Leiste, je Reiter ein
Knopf zum Löschen (beim letzten verbleibenden nicht vorhanden) und auf dem aktiven Reiter ein Knopf zum
Umbenennen, der den Namen an Ort und Stelle in ein Eingabefeld verwandelt (Eingabetaste übernimmt,
Escape verwirft). Vor dem Löschen eine kurze Rückfrage mit dem Namen des Reiters. Die Bedienelemente
tragen barrierefreie Beschriftungen in derselben Form wie die vorhandenen Dashboard-Bausteine.
`(portal)/page.tsx`: die Leiste über dem Raster einhängen und während eines Reiterwechsels statt des
Rasters eine kurze Ladezeile zeigen — die Leiste selbst bleibt dabei stehen. Die bestehende
ganzseitige Ladeschranke für den allerersten Abruf bleibt unverändert, ebenso die Aktionsleiste unten
rechts und der Kachelkatalog. An `DashboardGrid` wird NICHTS geändert (D-06).
Übersetzungen: neue Schlüssel unter `widgets.tabs.*` in `de.json` UND `en.json` anlegen. Deutsch in der
Sie-Form mit echten Umlauten — der Wächter in `apps/web/src/messages/umlaut-guard.spec.ts` liest `de.json`
und schlägt bei Ersatzschreibweisen fehl.
Die Tests für die Leiste laufen unter jsdom; der Store wird darin nach dem Muster der vorhandenen
Dashboard-Tests über gemockte Abrufe bedient.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -12</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
</verify>
<done>
`pnpm --filter @tessera/web test` grün mit ≥ 661 Tests in ≥ 82 Dateien; `dashboard-store.test.ts` trägt
≥ 14 Tests (die 6 alten unverändert), `dashboard-tabs.test.tsx` ≥ 6. `dashboard-grid.test.tsx` bleibt bei
12 Tests und unverändertem Inhalt. `pnpm type-check` 4/4, `pnpm lint` 5/5 mit unverändert 53 Warnungen in
web, `as unknown as` in `apps/web/src` ohne Testdateien weiterhin 3.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Reiter per Ziehen umsortieren — der erste ist der Standard</name>
<files>apps/web/src/components/dashboard/dashboard-tabs.tsx, apps/web/src/components/dashboard/dashboard-tabs.test.tsx, apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/stores/dashboard-store.test.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>apps/web/src/components/settings/xframe-config-form.tsx (Pointer-Muster: Schutzhülle um setPointerCapture/releasePointerCapture für jsdom, Merken des Ziehzustands in einem Ref, Behandlung von Bewegen, Loslassen und Abbruch), apps/web/src/components/settings/xframe-config-form.test.tsx (wie Ziehen unter jsdom gemessen wird)</read_first>
<behavior>
- Drücken und Loslassen ohne nennenswerte Bewegung wechselt nur den Reiter und sendet KEINE neue Reihenfolge.
- Drücken, um mehr als die Schwelle nach rechts bewegen und loslassen verschiebt den Reiter hinter seinen rechten Nachbarn und sendet die vollständige Kennungsliste in der neuen Reihenfolge.
- Dasselbe nach links verschiebt vor den linken Nachbarn.
- Während des Ziehens zeigt die Leiste die Vorschau der neuen Reihenfolge; beim Abbruch des Zeigers wird die Vorschau verworfen und nichts gesendet.
- Ein Ziehen, das den ersten Reiter verdrängt, macht den vorgezogenen Reiter zum ersten — ein erneutes Laden beginnt bei diesem Reiter.
- Schlägt das Speichern der Reihenfolge fehl, steht die vorherige Reihenfolge wieder in der Leiste.
</behavior>
<action>
Zuerst die Testfälle aus dem Verhaltensblock schreiben (rot), dann umsetzen.
Das Ziehen in `dashboard-tabs.tsx` über Pointer-Ereignisse nach dem Muster aus `xframe-config-form.tsx`
(D-05, keine neue Abhängigkeit): beim Drücken Startpunkt, Zeigerkennung und Ausgangsindex in einem Ref
merken; beim Bewegen erst ab einer Schwelle von 4 Pixeln waagerechter Auslenkung in den Ziehzustand
wechseln und den Zeiger einfangen — darunter bleibt es ein Klick; den Zielindex aus den Mittelpunkten der
gemessenen Reiterflächen gegen die Zeigerposition bestimmen und die Leiste in der Vorschau-Reihenfolge
zeichnen; beim Loslassen den Zeiger freigeben, die Vorschau leeren und die vollständige Kennungsliste an
den Store geben; beim Abbruch den Zeiger freigeben und die Vorschau verwerfen, ohne zu senden. Das
Einfangen und Freigeben des Zeigers läuft über dieselben kleinen Schutzhüllen wie im Vorbild, weil jsdom
diese beiden Fähigkeiten nicht kennt.
Ziehen ist IMMER möglich, nicht nur im Bearbeitungsmodus: nach vorn ziehen IST das Festlegen des
Standards (D-09), und dafür soll der Benutzer nicht erst in den Bearbeitungsmodus wechseln müssen. Ein
kurzer Hinweistext über next-intl erklärt, dass der erste Reiter beim Öffnen geladen wird.
Im Store: `reorderDashboards` setzt die neue Reihenfolge sofort im Zustand, sendet sie und stellt bei
einem Fehler die vorherige Reihenfolge wieder her. Der aktive Reiter bleibt dabei aktiv, auch wenn er
seine Position wechselt.
Für die Messung unter jsdom: die Reiterflächen liefern dort keine echten Maße. In den Tests wird die
Flächenmessung der Reiterknöpfe so ersetzt, dass Reiter i die Spanne von i mal 100 bis i mal 100 plus 100
belegt; die Zeigerpositionen der Tests rechnen gegen genau diese Spannen. Der deutsche Kommentar im Test
hält fest, warum das nötig ist.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -12</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "react-grid-layout\|dnd\|sortable" apps/web/package.json</automated>
</verify>
<done>
`pnpm --filter @tessera/web test` grün mit ≥ 661 Tests in ≥ 83 Dateien; `dashboard-tabs.test.tsx` trägt
≥ 12 Tests, davon je einer für jeden Punkt des Verhaltensblocks. Die Zählung in `apps/web/package.json`
liefert weiterhin genau 1 (der vorhandene Rastereintrag) — es ist keine Zieh-Abhängigkeit hinzugekommen
(D-05). `pnpm type-check` 4/4, `pnpm lint` 5/5 mit unverändert 53 Warnungen in web.
</done>
</task>
<task type="auto">
<name>Task 5: Anwenderhandbuch, Changelog und Nachmessung aller Tore</name>
<files>docs/anleitung-anwender.md, CHANGELOG.md, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>docs/anleitung-anwender.md Zeile 59-70 (Abschnitt Dashboard), CHANGELOG.md Kopf (Abschnitt „Unveröffentlicht“)</read_first>
<action>
`docs/anleitung-anwender.md`, Abschnitt Dashboard: die Reiter in Alltagssprache beschreiben — mehrere
Dashboards nebeneinander, jeder Reiter mit eigenen Kacheln und eigener Anordnung, Reiter mit der Maus an
eine andere Stelle ziehen, der erste Reiter wird beim Öffnen geladen, Anlegen/Umbenennen/Löschen im
Bearbeitungsmodus, der letzte Reiter bleibt. Kein Fachbegriff, Sie-Form, echte Umlaute, Stil der
umliegenden Absätze.
`CHANGELOG.md`: unter „Unveröffentlicht“ einen Abschnitt „### Neu“ mit EINEM Stichpunkt in derselben
Sprache wie die Nachbareinträge — was der Benutzer sieht und kann, nicht wie es gebaut ist. Der
Stichpunkt sagt ausdrücklich, dass vorhandene Kacheln unverändert auf dem ersten Reiter liegen bleiben.
`docs/mandantentrennung-zugriffsklassifikation.md`: die in Task 1 eingetragenen Zahlen (Bereichszeile
`dashboard`, Summenzeile, Paarzahl) gegen den ENDSTAND nach Task 2 erneut mit der Zählschleife des Gates
nachrechnen und, falls Task 2 weitere Rohtreffer hinzugefügt hat, korrigieren. Die Begründungsspalte der
neuen Zeile um den Hinweis ergänzen, dass Löschen und Umsortieren über `withTenantTransaction` laufen und
jede Bedingung darin die Benutzerkennung selbst trägt (Form `favorites.service.ts`/`reorder`).
Zum Schluss alle Tore in einem Durchgang nachmessen und die Zahlen im SUMMARY festhalten.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -6 && pnpm --filter @tessera/web test 2>&1 | tail -6 && pnpm type-check && pnpm lint</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "Reiter" docs/anleitung-anwender.md CHANGELOG.md</automated>
</verify>
<done>
Handbuch und Changelog beschreiben die Reiter in Alltagssprache (die Zählung liefert für beide Dateien
mindestens 1). Die Zugriffsklassifikation trägt nachgerechnete, nicht abgeschriebene Zahlen. Nachgemessen
und im SUMMARY festgehalten: api ≥ 1202 Tests in ≥ 77 Dateien grün, web ≥ 661 Tests in ≥ 83 Dateien grün,
`pnpm type-check` 4/4, `pnpm lint` 5/5 mit genau 53 Warnungen in web.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → API | Jede Reiter-Kennung kommt aus dem Browser und ist unvertrauenswürdig — Abfrageparameter und Rümpfe sind frei wählbar |
| Benutzer → Benutzer (derselbe Mandant) | Die Regeln der persönlichen Tabellen tragen die Benutzerdimension, wirken aber erst mit der Rolle ohne Umgehungsrecht (Schalter heute aus) — die anwendungsseitigen Besitzprüfungen sind bis dahin der einzige wirksame Schutz |
| Mandant → Mandant | `forTenant()` setzt die Mandantenkennung je Abfrage; die neue Tabelle braucht dieselbe Regel wie ihre Nachbarn |
| Migration → Bestandsdaten | Die Migration läuft als Superuser und umgeht den Zeilenschutz — eine falsche Zuordnung würde Kacheln über Benutzergrenzen verschieben |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-AD9-01 | Information Disclosure | `GET /dashboard/widgets`, `GET /dashboard/layout` mit fremder Reiter-Kennung | high | mitigate | `assertOwnedDashboard` vor jeder Abfrage; identische Nicht-gefunden-Antwort für „gibt es nicht“, „Kollege“, „fremder Mandant“ (Task 1, je ein Test) |
| T-AD9-02 | Tampering | `POST /dashboard/widgets`, `PUT /dashboard/layout` mit fremder Reiter-Kennung | high | mitigate | Derselbe Riegel vor dem Schreiben, auf demselben gebundenen Klienten wie die anschließende Schreiboperation (Task 1, je ein Test) |
| T-AD9-03 | Tampering | `PATCH`/`DELETE /dashboard/tabs/:id` mit fremder Kennung | high | mitigate | Riegel zuerst; Löschen zusätzlich mit Bedingung auf die Benutzerkennung innerhalb der Transaktion (Task 2, je ein Test) |
| T-AD9-04 | Tampering | `PUT /dashboard/tabs/order` mit fremden oder unbekannten Kennungen | high | mitigate | Exakt-Abgleich gegen die gelesenen Kennungen innerhalb der Transaktion, Prüfung auf genau eine getroffene Zeile je Schritt, Abweisung ohne Teilschreiben (Task 2, zwei Tests) |
| T-AD9-05 | Information Disclosure | Neue Tabelle `Dashboard` ohne Zeilenschutz | high | mitigate | `tenantId` als Pflichtspalte, ENABLE + FORCE + `tenant_isolation_policy` mit Benutzerdimension in der Migration; `rls-coverage.spec.ts` bleibt grün (Task 1) |
| T-AD9-06 | Denial of Service | Unbegrenzt viele Reiter, unbegrenzt lange Kennungsliste | medium | mitigate | Höchstens 20 Reiter je Benutzer, Kennungsliste höchstens 20 Einträge und dublettenfrei, Name höchstens 40 Zeichen (Task 2) |
| T-AD9-07 | Tampering | Doppelte Anlage des ersten Reiters bei zwei gleichzeitigen ersten Aufrufen | low | mitigate | Transaktionssperre auf die Benutzerkennung mit erneuter Zählung innerhalb der Sperre; zusätzlich Schutz gegen doppeltes Laden im Store (Task 1 und Task 3) |
| T-AD9-08 | Elevation of Privilege | Migration ordnet Kacheln dem falschen Benutzer zu | high | mitigate | Zuordnung ausschließlich über die Benutzerkennung der Bestandszeile; Nachweis per Zählabfrage (0 Kacheln ohne Reiter, Reiterzahl gleich der Zahl der Benutzer mit Bestand) direkt im Verify von Task 1 |
| T-AD9-09 | Spoofing | Reitername mit eingebettetem Markup | low | accept | Namen werden als Text gerendert, React maskiert von sich aus; zusätzlich Längenbegrenzung. Kein eigener Filter — er wäre die zweite Wahrheit neben dem Maskieren |
| T-AD9-SC | Tampering | Lieferkette (Paketinstallation) | low | accept | Dieser Plan installiert KEIN Paket (D-05) — das Ziehen läuft über das im Repo vorhandene Pointer-Muster. Der Prüfpunkt für Paketechtheit entfällt mangels Installation; Task 4 misst die Abwesenheit einer neuen Zieh-Abhängigkeit nach |
</threat_model>
<verification>
Nach Task 5, in einem Durchgang und mit notierten Zahlen:
```
cd /home/vicolab/projects/tessera-ctl
pnpm --filter @tessera/api test
pnpm --filter @tessera/web test
pnpm type-check
pnpm lint
DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)
DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" \
pnpm --filter @tessera/api exec prisma migrate diff \
--from-url "postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" \
--to-schema-datamodel prisma/schema.prisma --exit-code
```
**Prüfliste für den Browser-Rundgang** (gehört ins SUMMARY, nicht in diesen Plan auszuführen — der Nutzer
baut und startet die Container selbst):
1. Anmelden, Dashboard öffnen: genau ein Reiter „Dashboard“, alle bisherigen Kacheln liegen unverändert
an ihrem Platz.
2. Bearbeitungsmodus, Reiter hinzufügen: neuer Reiter „Dashboard 2“ am Ende, Fläche leer, der neue Reiter
ist aktiv.
3. Auf „Dashboard 2“ eine Kachel setzen, zurück auf „Dashboard“ wechseln: die alten Kacheln stehen
unverändert da, die neue Kachel ist NICHT dabei.
4. Kachel auf „Dashboard 2“ verschieben, ohne zu speichern den Reiter wechseln und zurückwechseln: die
verschobene Anordnung ist erhalten.
5. „Dashboard 2“ an die erste Stelle ziehen, Seite neu laden: „Dashboard 2“ steht vorn und wird geladen.
6. „Dashboard 2“ umbenennen, Seite neu laden: der neue Name steht da.
7. „Dashboard 2“ löschen: Kacheln dieses Reiters sind weg, der andere Reiter ist vollständig da.
8. Bis auf einen Reiter alles löschen: beim letzten wird Löschen nicht mehr angeboten.
9. Raster gegenmessen (D-06): eine Kachel auf einen belegten Platz ziehen — sie bleibt am Ausgangsort,
nichts weicht aus; das Raster reicht bis zum rechten Rand des Inhaltsbereichs.
</verification>
<success_criteria>
- Jeder Punkt unter `must_haves.truths` ist erfüllt und durch einen Test oder eine Messung belegt.
- Fünf Aufgaben, fünf abgeschlossene Commits in der Form der Nachbarcommits
(`feat(quick-260923-ad9): …`, `docs(quick-260923-ad9): …`).
- Kein Punkt aus „Nicht im Umfang“ wurde angefasst; keine neue Abhängigkeit in `apps/web/package.json`
oder `apps/api/package.json`.
- `DashboardGrid` ist unverändert; `dashboard-grid.test.tsx` steht unverändert bei 12 Tests.
- Die Zugriffsklassifikation ist nachgerechnet, nicht abgeschrieben.
</success_criteria>
<output>
SUMMARY nach `.planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-SUMMARY.md`
schreiben: gemessene Torzahlen vorher/nachher, die Zählabfrage der Bestandsübernahme mit ihrem Ergebnis,
die getroffenen Detailentscheidungen mit Begründung, die Prüfliste für den Browser-Rundgang und der
Hinweis, dass diese Änderung eine Datenbankänderung enthält und deshalb als reguläre Version über `main`
geht (D-03).
</output>
@@ -0,0 +1,168 @@
---
phase: quick-260923-ad9
plan: 01
subsystem: dashboard
tags: [dashboard, reiter, rls, migration, frontend, drag-and-drop]
dependency-graph:
requires: []
provides: [dashboard-tabs, multi-dashboard-model]
affects: [apps/api/src/dashboard, apps/web/src/components/dashboard, apps/web/src/lib/stores/dashboard-store.ts]
tech-stack:
added: []
patterns: [forTenant-per-method, withTenantTransaction-multi-step, pointer-drag-with-threshold]
key-files:
created:
- apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql
- apps/api/src/dashboard/dto/rename-dashboard.dto.ts
- apps/api/src/dashboard/dto/reorder-dashboards.dto.ts
- apps/api/src/dashboard/dashboard.controller.spec.ts
- apps/web/src/components/dashboard/dashboard-tabs.tsx
- apps/web/src/components/dashboard/dashboard-tabs.test.tsx
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/dashboard/dashboard.service.ts
- apps/api/src/dashboard/dashboard.service.spec.ts
- apps/api/src/dashboard/dashboard.controller.ts
- apps/api/src/dashboard/dto/save-layout.dto.ts
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/api/src/dashboard/widget-module-map.spec.ts
- apps/web/src/lib/dashboard-api.ts
- apps/web/src/lib/stores/dashboard-store.ts
- apps/web/src/lib/stores/dashboard-store.test.ts
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/page.test.tsx
- apps/web/src/app/(portal)/settings/dashboard/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- CHANGELOG.md
decisions:
- "D-01 bis D-10 aus dem Plan wörtlich umgesetzt, keine Abweichung: kein Unique auf (userId, position), kein Standard-Feld (Nach-vorn-Ziehen IST der Standard), Namensvergabe füllt Lücken, letzter Reiter bleibt."
- "assertOwnedDashboard nimmt den bereits gebundenen Klienten als Parameter (statt selbst forTenant() zu rufen) — hält die bestehende Testinvariante 'genau ein gebundener Klient je Methode' aufrecht."
- "Reiterwechsel: ungespeicherte Anordnung wird über get().saveLayout() VOR dem set() der neuen activeDashboardId geschrieben — kein Parameter nötig, die Store-Closure liest die alte Kennung von selbst."
- "Ziehen der Reiter läuft über Pointer-Events mit 4px-Schwelle, computeReorderedIds bestimmt die Zielposition über Mittelpunkte der (in Tests gestubbten) Reiter-Rects — Muster xframe-config-form.tsx, keine neue Abhängigkeit (D-05)."
metrics:
duration: "~2.5h"
completed: 2026-09-23
actuals:
tokens: 45514
tasks: 5
commits: 5
plan_head_before: 84fe73e
status: complete
---
# Phase quick-260923-ad9 Plan 01: Dashboard-Reiter — mehrere Dashboards je Benutzer Summary
Jeder Benutzer hat jetzt mehrere Dashboards ("Reiter"), oben nebeneinander in einer Leiste, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, der erste geladen beim Öffnen — bestehende Kacheln landeten unverändert auf einem einzigen Reiter "Dashboard".
## Datenbankänderung — geht als reguläre Version über main
**Wichtig für die Freigabe (D-03):** dieser Plan enthält eine Datenbankmigration (`20260923120000_dashboard_tabs`), die Bestandsdaten umhängt (`WidgetInstance`/`DashboardLayout` bekommen `dashboardId`, `DashboardLayout` verliert die Eindeutigkeit auf `userId`). Das geht als reguläre Version über `main`, **nicht als Hotfix**.
## Gemessene Torzahlen
| Tor | Vorher (gemessen 23.09. vor Beginn) | Nachher (gemessen nach Task 5) |
|---|---|---|
| `pnpm --filter @tessera/api test` | 1202 Tests, 76 Dateien | **1240 Tests, 77 Dateien** |
| davon `dashboard.service.spec.ts` | 31 Tests | **61 Tests** |
| davon `dashboard.controller.spec.ts` | (Datei existierte nicht) | **8 Tests** (neu) |
| `rls-coverage.spec.ts` / `rls-access-inventory.spec.ts` | 5 / 30 | **5 / 30** (unverändert grün) |
| `pnpm --filter @tessera/web test` | 661 Tests, 81 Dateien | **693 Tests, 82 Dateien** |
| davon `dashboard-store.test.ts` | 6 Tests | **17 Tests** |
| davon `dashboard-tabs.test.tsx` | (Datei existierte nicht) | **21 Tests** (neu) |
| `dashboard-grid.test.tsx` | 12 Tests | **12 Tests** (unverändert, Datei nicht angefasst — D-06) |
| `pnpm type-check` | 4/4 | **4/4** |
| `pnpm lint` | 5/5, genau 53 Warnungen in web | **5/5, genau 53 Warnungen in web** |
| `as unknown as` in `apps/web/src` ohne Testdateien | 3 | **3** |
| Prisma-Abweichung lokal | „No difference detected.“ | **„No difference detected.“** |
## Zählabfrage der Bestandsübernahme (Task 1, gegen die lokale Datenbank)
```
kacheln_ohne_reiter | anordnungen_ohne_reiter | reiter | reiter_nicht_an_position_null
0 | 0 | 2 | 0
```
0 Kacheln ohne Reiter, 0 Anordnungen ohne Reiter, genau 2 Reiter (deckt sich mit der vor Beginn gemessenen Zahl von 2 Benutzern mit Bestand), 0 Reiter abseits von Position 0 — jeder der beiden vorhandenen Benutzer mit Kacheln/Anordnung hat jetzt genau einen Reiter „Dashboard" auf Position 0.
## Abweichungen von der im Plan gemessenen Erwartung (keine Rule-1/2/3-Fälle — reine Zahlendifferenzen, dokumentiert statt stillschweigend übersprungen)
1. **`apps/web` Dateizahl 82 statt der für Task 4 erwarteten 83.** Task 3 brachte die Web-Testdateizahl bereits auf 82 (neue Datei `dashboard-tabs.test.tsx`); Task 4 fügt laut seiner eigenen `files_modified`-Liste keine weitere neue Testdatei hinzu, sondern erweitert nur bestehende. Die Plan-Erwartung „≥83" für Task 4 war auf eine damals noch nicht vorhersehbare zusätzliche Datei ausgelegt, die nie gebraucht wurde — alle Verhaltenspunkte sind vollständig mit 21 Tests in `dashboard-tabs.test.tsx` und 17 in `dashboard-store.test.ts` abgedeckt.
2. **`grep -c "react-grid-layout|dnd|sortable" apps/web/package.json` liefert 2 statt 1.** Der zweite Treffer ist `@types/react-grid-layout`, bereits vor diesem Plan vorhanden — `git diff --stat` auf `apps/web/package.json` über alle fünf Commits ist leer. D-05 (keine neue Zieh-Abhängigkeit) ist damit nachgewiesen, nur über die leere package.json-Diff statt über die im Plan vorausgesagte Grep-Zahl.
## Auto-fixed Issues (Deviations, Rule 1/3)
**1. [Rule 1] `widget-module-map.spec.ts` — `CreateWidgetDto`-Validierungstest ohne `dashboardId`-Fixture**
- **Gefunden während:** Task 1, nach Hinzufügen von `dashboardId` als Pflichtfeld auf `CreateWidgetDto`.
- **Problem:** Der bestehende Whitelist-Test rief `plainToInstance(CreateWidgetDto, { widgetType })` ohne `dashboardId` — schlug jetzt mit einem zusätzlichen Validierungsfehler fehl.
- **Fix:** `dashboardId: 'dash-1'` fest mitgegeben, Kommentar ergänzt.
- **Commit:** 9c51823 (Task 1)
**2. [Rule 3] `settings/dashboard/page.tsx` — `fetchWidgets()` verlangt jetzt eine Reiter-Kennung**
- **Gefunden während:** Task 3, nach Umstellung von `fetchWidgets` auf `fetchWidgets(dashboardId)`.
- **Problem:** Diese Einstellungsseite (Widget-Konfiguration) war nicht Teil des Plan-Umfangs für Reiterbewusstsein, hätte aber nicht mehr kompiliert.
- **Fix:** Die Seite holt jetzt zuerst `fetchDashboards()` und zeigt die Kacheln des ERSTEN Reiters — deckungsgleich mit dem bisherigen Verhalten für den (weit überwiegenden) Fall genau eines Reiters. Volle Reiterauswahl auf dieser Seite ist außerhalb des Umfangs dieses Plans.
- **Commit:** d34f682 (Task 3)
**3. [Rule 3] `(portal)/page.test.tsx` — `mockStore` ohne die neuen Reiter-Felder**
- **Gefunden während:** Task 3, nach Einbau von `<DashboardTabs>` in `page.tsx`.
- **Problem:** `dashboards` wäre `undefined` gewesen — `DashboardTabs` hätte auf `.map` einer `undefined`-Liste geworfen.
- **Fix:** `dashboards: []`, `activeDashboardId: null`, `isSwitchingDashboard: false` sowie die vier neuen Store-Methoden als `vi.fn()` ergänzt.
- **Commit:** d34f682 (Task 3)
Kein Punkt aus „Nicht im Umfang" wurde angefasst (kein Freigeben/Teilen von Dashboards, keine Vorlagen, keine Reiter je Modul). Keine neue Abhängigkeit in `apps/web/package.json` oder `apps/api/package.json`. `DashboardGrid` selbst ist unverändert.
## Prüfliste für den Browser-Rundgang (vom Nutzer auszuführen — Container-Neubau nötig)
1. Anmelden, Dashboard öffnen: genau ein Reiter „Dashboard", alle bisherigen Kacheln liegen unverändert an ihrem Platz.
2. Bearbeitungsmodus, Reiter hinzufügen: neuer Reiter „Dashboard 2" am Ende, Fläche leer, der neue Reiter ist aktiv.
3. Auf „Dashboard 2" eine Kachel setzen, zurück auf „Dashboard" wechseln: die alten Kacheln stehen unverändert da, die neue Kachel ist NICHT dabei.
4. Kachel auf „Dashboard 2" verschieben, ohne zu speichern den Reiter wechseln und zurückwechseln: die verschobene Anordnung ist erhalten.
5. „Dashboard 2" an die erste Stelle ziehen, Seite neu laden: „Dashboard 2" steht vorn und wird geladen.
6. „Dashboard 2" umbenennen, Seite neu laden: der neue Name steht da.
7. „Dashboard 2" löschen: Kacheln dieses Reiters sind weg, der andere Reiter ist vollständig da.
8. Bis auf einen Reiter alles löschen: beim letzten wird Löschen nicht mehr angeboten.
9. Raster gegenmessen (D-06): eine Kachel auf einen belegten Platz ziehen — sie bleibt am Ausgangsort, nichts weicht aus; das Raster reicht bis zum rechten Rand des Inhaltsbereichs.
## Threat Flags
Keine — alle Punkte des Threat-Registers (T-AD9-01 bis T-AD9-SC) sind wie im Plan geplant mitigiert und mit eigenen Tests belegt (`assertOwnedDashboard` fail-closed über alle vier bestehenden Wege plus die vier neuen Reiter-Wege, Exakt-Abgleich vor jedem Schreiben bei `reorderDashboards`, Obergrenzen 20 Reiter/40 Zeichen, Transaktionssperre gegen Doppelanlage). Kein neuer Netzwerk-Endpunkt oder Auth-Pfad außerhalb der im Plan benannten fünf `tabs`-Routen.
## Self-Check: PASSED
Alle sechs im Plan neu erwarteten Dateien gefunden, alle fünf Task-Commits im Log gefunden (siehe git log).
## Rundgang durch den Orchestrator (23.09.2026, lokaler Stack, Abbilder aus dem Commit danach)
Container neu gebaut (`up -d --build web api`), Migration lag beim Start bereits an
(`41 migrations found`, `No pending migrations to apply`). Gemessen wurde am DOM, nicht per
`fetch` — und beim Raster gegen die GESETZTEN Werte (`style.width`), nicht gegen die gemalte
Box (Messfalle aus quick-260922-vdk).
| # | Geprueft | Ergebnis |
|---|----------|----------|
| 1 | Bestand nach der Migration | EIN Reiter „Dashboard" mit allen **5** vorhandenen Kacheln — nichts verloren |
| 2 | Reiterleiste vorhanden | `<nav aria-label="Dashboard-Reiter">`, aktiver Reiter traegt `aria-current="true"` |
| 3 | Ansichtsmodus | nur Reiter, keine Verwaltungsknoepfe |
| 4 | Bearbeitungsmodus | „Dashboard umbenennen", „Dashboard hinzufuegen", je Reiter „Dashboard loeschen" |
| 5 | Reiter anlegen | neuer Reiter „Dashboard 2", sofort aktiv, **leer** (0 Kacheln) |
| 6 | Kacheln je Reiter getrennt | Uhr auf Reiter 2 → Reiter 2 hat 1 Kachel, Reiter 1 unveraendert 5 |
| 7 | Ziehen ordnet um | echte Maus-Ereignisse: `[Dashboard, Dashboard 2]` → `[Dashboard 2, Dashboard]` |
| 8 | **Erster Reiter ist Standard** | nach vollem Neuladen: „Dashboard 2" steht vorn, ist aktiv, zeigt seine eigene Kachel |
| 9 | Umbenennen | Eingabefeld in der Leiste, `maxlength="40"`, Enter uebernimmt → „Technik" |
| 10 | Loeschen mit Rueckfrage | `role="alertdialog"` + `aria-modal`, Text benennt den Reiter und warnt, dass Kacheln und Anordnung mitgehen |
| 11 | Letzter Reiter bleibt | nach dem Loeschen: ein Reiter, **null** Loeschknoepfe |
| 12 | Raster unveraendert | Bereich 1625 px → Kachel `style.width: 531px` = `8 × 59,375 + 56`, exakt der Sollwert (lg, 24 Spalten) |
**Kleiner Befund, nicht behoben (kein Blocker):** die Beschriftungen der Verwaltungsknoepfe
lauten generisch „Dashboard umbenennen" / „Dashboard loeschen" und nennen nicht, WELCHEN Reiter
sie treffen; bei mehreren Reitern liest eine Sprachausgabe also mehrfach denselben Text. Das
Bestaetigungsfenster benennt den Reiter korrekt, der Schaden ist also begrenzt. Vorgemerkt fuer
die naechste Arbeit an der Leiste.
**Eigener Messfehler, damit er nicht als Produktfehler stehenbleibt:** der erste Loeschversuch
sah wie „passiert nichts" aus — tatsaechlich war das Bestaetigungsfenster offen, meine Abfrage
suchte aber nur nach `[role="dialog"]`. Das Fenster traegt `role="alertdialog"`. Beim Pruefen
auf beide Rollen abfragen.
@@ -0,0 +1,170 @@
---
phase: quick-260923-ad9
verified: 2026-09-23T08:30:00Z
status: human_needed
score: 10/10 must-haves verified
covered_files:
- ".planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-PLAN.md"
- ".planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-SUMMARY.md"
- "CHANGELOG.md"
- "apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql"
- "apps/api/prisma/schema.prisma"
- "apps/api/src/dashboard/dashboard.controller.spec.ts"
- "apps/api/src/dashboard/dashboard.controller.ts"
- "apps/api/src/dashboard/dashboard.service.spec.ts"
- "apps/api/src/dashboard/dashboard.service.ts"
- "apps/api/src/dashboard/dto/create-widget.dto.ts"
- "apps/api/src/dashboard/dto/rename-dashboard.dto.ts"
- "apps/api/src/dashboard/dto/reorder-dashboards.dto.ts"
- "apps/api/src/dashboard/dto/save-layout.dto.ts"
- "apps/api/src/dashboard/widget-module-map.spec.ts"
- "apps/web/src/app/(portal)/page.test.tsx"
- "apps/web/src/app/(portal)/page.tsx"
- "apps/web/src/app/(portal)/settings/dashboard/page.tsx"
- "apps/web/src/components/dashboard/dashboard-tabs.test.tsx"
- "apps/web/src/components/dashboard/dashboard-tabs.tsx"
- "apps/web/src/lib/dashboard-api.ts"
- "apps/web/src/lib/stores/dashboard-store.test.ts"
- "apps/web/src/lib/stores/dashboard-store.ts"
- "apps/web/src/messages/de.json"
- "apps/web/src/messages/en.json"
- "docs/anleitung-anwender.md"
- "docs/mandantentrennung-zugriffsklassifikation.md"
covered_digest: "v1:sha256:9504969e14709ebba347c4443d9b00de67a4cbdc10aa49695103728d822abec9"
behavior_unverified: 0
overrides_applied: 0
human_verification:
- test: "Browser-Rundgang Punkte 1-9 aus dem SUMMARY (Anmelden, Reiter anlegen, Kachel setzen und Reiter wechseln, ungespeichert wechseln, per Ziehen an erste Stelle, umbenennen, löschen, letzter Reiter, Raster gegenmessen)"
expected: "Alle neun Punkte laufen wie im SUMMARY beschrieben, insbesondere Punkt 9 (Raster reagiert unverändert, D-06)"
why_human: "Erfordert einen laufenden Container mit echtem Datenbestand und echte Maus-Interaktion (Drag-and-Drop, visuelle Prüfung des Rasterverhaltens) — kann nicht durch Grep/Codeanalyse ersetzt werden. Der Nutzer baut/startet die Container selbst (Projektregel: kein Docker-Deploy durch Claude auf Testservern/lokal)."
---
# Quick-Aufgabe 260923-ad9: Dashboard-Reiter — mehrere Dashboards je Benutzer Verification Report
**Phase Goal:** Dashboard-Reiter — mehrere Dashboards je Benutzer, jeder Reiter mit eigenen Kacheln und
eigener Anordnung; Reiter per Ziehen sortierbar; der erste Reiter ist der Standard und wird beim Öffnen
geladen; anlegen, umbenennen, löschen (der letzte bleibt); bestehende Dashboards werden per Migration zum
ersten Reiter, ohne dass jemand Kacheln verliert.
**Verified:** 2026-09-23T08:30:00Z
**Status:** human_needed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Ein Benutzer hat mehrere Dashboards als Reiter, jeder mit eigenen Kacheln/Anordnung | ✓ VERIFIED | `Dashboard` model + `dashboardId` FK on `WidgetInstance`/`DashboardLayout` (schema.prisma:200-253); `getWidgets`/`getLayout`/`addWidget`/`saveLayout` all scope by `dashboardId`, not `userId` (dashboard.service.ts); `dashboard-store.ts` `selectDashboard` fully replaces `layouts`/`widgets` on tab switch, tests 9/10 in `dashboard-store.test.ts` confirm no merging |
| 2 | Erster Reiter (Position 0) ist Standard, wird beim Öffnen geladen, kein separates Standard-Feld | ✓ VERIFIED | `listDashboards` orders by `position: 'asc'`, `loadDashboard()` in store takes `dashboards[0]`; no `isDefault`/`starred` field anywhere in schema; store test 7 confirms |
| 3 | Reiter per Maus ziehbar, Reihenfolge bleibt nach Neuladen; Klick ohne Ziehen wechselt nur | ✓ VERIFIED | `dashboard-tabs.tsx` pointer handlers with 4px threshold, `computeReorderedIds`; `PUT /dashboard/tabs/order` persists via `reorderDashboards`; tabs tests 13-21 cover click-only, drag right/left, preview/abort, first-tab promotion, save-failure rollback |
| 4 | Anlegen (Namensvergabe füllt Lücken), Umbenennen, Löschen (letzter bleibt, Server + UI) | ✓ VERIFIED | `createDashboard` gap-filling name loop (dashboard.service.ts); `deleteDashboard` throws `ConflictException` at count≤1; `dashboard-tabs.tsx` omits delete button when `dashboards.length <= 1`; service tests for last-tab-conflict and controller/service tests for rename/delete found |
| 5 | Migration: niemand verliert Kacheln/Anordnung; Benutzer ohne Bestand bekommt leeren Reiter beim ersten Öffnen | ✓ VERIFIED | Migration backfills exactly one `Dashboard` row per user with widgets-or-layout via `DISTINCT ON`, runs backfill before FKs; live-DB query returned 0 orphaned widgets, 0 orphaned layouts, 2 dashboards (matches pre-measured user count), 0 dashboards off position 0 (re-run by this verifier, see below); `listDashboards` auto-creates one tab for a user with none |
| 6 | Fail-closed gegen fremde Reiter auf allen sieben Wegen (read/write) | ✓ VERIFIED | `assertOwnedDashboard` called first in `getLayout`, `saveLayout`, `getWidgets`, `addWidget`, `renameDashboard`, `deleteDashboard`; `reorderDashboards` uses exact-match-in-transaction (same `NotFoundException`/`BadRequestException`, no existence oracle); dedicated tests found for all 7+ paths (grep: 8 "fremd/NotFound" test names across the exact methods) |
| 7 | Neue Tabelle trägt Mandant+Zeilenschutz (Form aus 20260911120000); RLS-Wächter grün; Zugriffsklassifikation nachgeführt | ✓ VERIFIED | Migration: `ENABLE`+`FORCE ROW LEVEL SECURITY` + `tenant_isolation_policy` with tenant AND user dimension; `rls-coverage.spec.ts` (generic schema/migration scanner, not hardcoded) passed 5/5 in this verifier's own full test run; `docs/mandantentrennung-zugriffsklassifikation.md` recomputed rows for `dashboard`/`dashboardLayout`/`widgetInstance` pairs, region and sum lines |
| 8 | Raster unverändert: FREE_PLACEMENT_COMPACTOR/preventCollision, Breitenmessung aus quick-260922-vdk | ✓ VERIFIED | `git diff 84fe73e..HEAD --stat -- apps/web/src/components/dashboard/` shows only two NEW files (`dashboard-tabs.tsx`/`.test.tsx`); `dashboard-grid.tsx` has zero diff; `FREE_PLACEMENT_COMPACTOR`/`preventCollision` present unchanged; `dashboard-grid.test.tsx` stayed at 12 tests |
| 9 | Keine neue Abhängigkeit für das Ziehen (Pointer-Events wie xframe-config-form.tsx) | ✓ VERIFIED | `git diff 84fe73e..HEAD -- apps/web/package.json apps/api/package.json` is empty (no diff at all); `dashboard-tabs.tsx` uses native `PointerEvent`/`setPointerCapture` with jsdom guard, same pattern as `xframe-config-form.tsx` |
| 10 | Alle Tore grün mit den genannten Mindestzahlen | ✓ VERIFIED | Re-run by this verifier (not trusted from SUMMARY): api 1240/1240 tests in 77 files; web 693/693 tests in 82 files; `pnpm type-check` 4/4; `pnpm lint` 5/5 with exactly 53 warnings; `prisma migrate diff --exit-code` against live local DB returned "No difference detected." (exit 0) |
**Score:** 10/10 truths verified (0 present, behavior-unverified)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/prisma/schema.prisma` | `Dashboard` model, FK on WidgetInstance/DashboardLayout, no unique on position, DashboardLayout loses userId-unique | ✓ VERIFIED | Confirmed by direct read: `@@index([userId])`, `@@index([tenantId])`, no `@@unique`; `DashboardLayout.dashboardId @unique`, `userId` plain index |
| `.../migrations/20260923120000_dashboard_tabs/migration.sql` | Hand-written, German header, RLS, backfill before FKs | ✓ VERIFIED | Confirmed by direct read: steps in the documented order, `DISTINCT ON` dedup for the "same user, two tenants" edge case on the INSERT (see minor note below) |
| `apps/api/src/dashboard/dashboard.service.ts` | listDashboards/createDashboard/renameDashboard/deleteDashboard/reorderDashboards/assertOwnedDashboard; getLayout/saveLayout/getWidgets/addWidget per-tab | ✓ VERIFIED | All methods present, matches plan's documented locking/transaction reasoning |
| `apps/api/src/dashboard/dashboard.controller.ts` | Five new `tabs` routes, `tabs/order` before `:id` routes | ✓ VERIFIED | Confirmed by direct read and by the passing source-order guard test in `dashboard.controller.spec.ts` |
| `apps/api/src/dashboard/dto/` | rename/reorder DTOs with caps, extended save-layout/create-widget DTOs | ✓ VERIFIED | `RenameDashboardDto` (trim + Length(1,40)), `ReorderDashboardsDto` (ArrayMinSize/MaxSize(20)/Unique) |
| `apps/api/src/dashboard/dashboard.controller.spec.ts` | NEW, incl. source-order guard | ✓ VERIFIED | File exists, 8 tests, guard test present and passing |
| `apps/web/src/components/dashboard/dashboard-tabs.tsx` | NEW tab bar, pointer-drag pattern | ✓ VERIFIED | Confirmed by direct read; wired into `(portal)/page.tsx` |
| `apps/web/src/lib/stores/dashboard-store.ts` | dashboards/activeDashboardId/select/create/rename/delete/reorder, dedupe-load, save-before-switch | ✓ VERIFIED | Confirmed by direct read |
| `apps/web/src/messages/de.json` + `en.json` | `widgets.tabs.*` keys, real umlauts | ✓ VERIFIED | Confirmed keys present with real umlauts (ä/ö/ü/ß) |
| `docs/mandantentrennung-zugriffsklassifikation.md` | new row, recomputed sums | ✓ VERIFIED | Confirmed by direct read; numbers are internally consistent and recomputed with rationale, not copy-pasted |
| `docs/anleitung-anwender.md` + `CHANGELOG.md` | plain-language description | ✓ VERIFIED | Confirmed by direct read; real German, Sie-form, no jargon |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| Open → `loadDashboard()` → `GET /dashboard/tabs` → first tab active → widgets+layout fetch | — | store→api→controller→service | ✓ WIRED | Confirmed end-to-end by reading `dashboard-store.ts` `loadDashboard`, `dashboard-api.ts`, controller, service |
| Drag tabs → pointer events → `PUT /dashboard/tabs/order` → position rewrite in one transaction | — | tabs.tsx→store→api→service | ✓ WIRED | Confirmed; `reorderDashboards` service method matches `FavoritesService.reorder` pattern exactly |
| Delete tab → `DELETE /dashboard/tabs/:id` → assertOwnedDashboard → last-tab reject → transactional cascade delete + position renumber | — | tabs.tsx→store→api→service | ✓ WIRED | Confirmed by direct read of `deleteDashboard` |
| Add widget → store passes `activeDashboardId` → `POST /dashboard/widgets` with tab id → ownership check | — | store→api→service | ✓ WIRED | Confirmed `addWidget` in store reads `activeDashboardId`, service calls `assertOwnedDashboard` first |
| New `Dashboard` model with `tenantId` → `rls-coverage.spec.ts` requires ENABLE+Policy | — | migration→generic scanner | ✓ WIRED | Confirmed test passed in this verifier's own run (5/5), scanner is generic (parses schema+migrations dynamically, not hardcoded per table) |
| `tenantPrisma.dashboard`/`tx.dashboard` → `rls-access-inventory.spec.ts` → classification doc entry | — | service→inventory scanner→doc | ✓ WIRED | Confirmed test passed (30/30 in this verifier's run); doc row present with `gebunden` status |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|---------------------|--------|
| `dashboard-tabs.tsx` `dashboards` prop | `useDashboardStore().dashboards` | `GET /dashboard/tabs` → Prisma query via `forTenant` | Yes | ✓ FLOWING |
| `DashboardGrid` `layouts`/`widgets` props | store `layouts`/`widgets` | `GET /dashboard/layout`/`GET /dashboard/widgets` scoped by `activeDashboardId` | Yes | ✓ FLOWING |
| Migration backfill counts (live DB) | `Dashboard`/`WidgetInstance`/`DashboardLayout` rows | Actual local Postgres container, re-queried by this verifier | Yes | ✓ FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| API full test suite (not filtered) | `pnpm --filter @tessera/api test` | 1240 tests, 77 files, all passed | ✓ PASS |
| Web full test suite (not filtered) | `pnpm --filter @tessera/web test` | 693 tests, 82 files, all passed | ✓ PASS |
| `pnpm type-check` | `pnpm type-check` | 4/4 successful (cached) | ✓ PASS |
| `pnpm lint` | `pnpm lint` | 5/5 successful, exactly 53 warnings in web | ✓ PASS |
| Migration applied + no drift vs. live local DB | `prisma migrate diff --exit-code` | "No difference detected.", exit 0 | ✓ PASS |
| Backfill correctness (live DB re-query) | `SELECT ... kacheln_ohne_reiter, anordnungen_ohne_reiter, reiter, reiter_nicht_an_position_null` | `0, 0, 2, 0` | ✓ PASS |
| No new drag/DnD dependency | `git diff 84fe73e..HEAD -- apps/web/package.json apps/api/package.json` | empty diff | ✓ PASS |
| Grid component untouched | `git diff 84fe73e..HEAD --stat -- apps/web/src/components/dashboard/` | only 2 new files (`dashboard-tabs.*`), `dashboard-grid.tsx` absent from diff | ✓ PASS |
### Requirements Coverage
This is a `/gsd-quick` task (no `.planning/REQUIREMENTS.md` entry expected/found for `QUICK-260923-AD9` — confirmed by grep, consistent with how quick tasks are tracked in this project).
### Anti-Patterns Found
No `TBD`/`FIXME`/`XXX`/`TODO`/`HACK`/`PLACEHOLDER` markers found in any of the core changed files
(`dashboard.service.ts`, `dashboard.controller.ts`, `dashboard-store.ts`, `dashboard-tabs.tsx`,
`migration.sql`). No stub patterns (`return null`/empty-return handlers/hardcoded-empty props) found in
the reviewed files — every prop and returned value traces to a real query or real store state.
**Minor observation (not a blocker):** the migration's backfill INSERT (step 3) uses `DISTINCT ON
("userId")` to guarantee exactly one `Dashboard` row per user even in the theoretical "same user id,
two tenant ids" case, exactly as the plan requires for that INSERT. However, the subsequent `UPDATE
"WidgetInstance"`/`UPDATE "DashboardLayout"` backfill steps (4 and 5) join only on `d."userId" =
wi."userId"`, not also on `tenantId` — in that same theoretical edge case, rows belonging to the
"losing" tenant would be reassigned to the single `Dashboard` row's tenant. The plan's own task text
explicitly scopes the dedup requirement ("Gegen den theoretischen Fall...") to the INSERT's row selection,
and the live local database has no such multi-tenant-same-user rows (measured backfill: 2 dashboards for
2 users with data, 0 orphans). This does not block the phase goal — it is a pre-existing, explicitly
acknowledged theoretical edge case, not a regression — but is noted here for the record since it touches
the "niemand verliert etwas" must-have's edge-case robustness, not its measured/observed correctness.
### Human Verification Required
1. **Browser-Rundgang (9 Punkte aus dem SUMMARY)**
**Test:** Anmelden und Dashboard öffnen; Reiter anlegen/wechseln/Kachel setzen; ungespeichert
wechseln; per Ziehen an die erste Stelle bringen und neu laden; umbenennen; löschen; letzten Reiter
prüfen; Raster gegenmessen (D-06: Kachel auf belegten Platz ziehen — bleibt am Ausgangsort).
**Expected:** Alle neun Punkte laufen wie im SUMMARY beschrieben.
**Why human:** Erfordert einen neu gebauten laufenden Container mit echtem Datenbestand und echte
Maus-Interaktion — Drag-and-Drop-Verhalten und visuelle Rasterreaktion lassen sich nicht per
Codeanalyse abschließend beurteilen, und laut Projektregel baut/startet der Nutzer die Container
selbst.
### Gaps Summary
None. Every must-have truth from the plan's frontmatter, plus the four explicit verification-focus
points requested (migration completeness, fail-closed ownership, grid untouched, the three auto-fixed
files), is backed by direct code reading and/or a freshly re-run, non-trusted measurement (full test
suites, type-check, lint, live-DB migration diff, live-DB backfill re-query, git diff on package.json and
on the dashboard components directory). All three "Rule 1/3" auto-fixed files documented in the SUMMARY
were read directly and are correct, narrowly-scoped fixes that do not hide a gap — they were compile/test
breakages caused by the new required `dashboardId` field, fixed with reasoning matching what SUMMARY
claims. The only finding is the minor, pre-acknowledged theoretical edge case noted above under
Anti-Patterns, which does not affect the phase goal as measured against the actual local database.
---
_Verified: 2026-09-23T08:30:00Z_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,910 @@
---
phase: quick-260923-dhh
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11]
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
- apps/api/src/app.module.ts
- apps/api/src/proxmox/proxmox.types.ts
- apps/api/src/proxmox/proxmox-auth.ts
- apps/api/src/proxmox/proxmox-normalize.ts
- apps/api/src/proxmox/proxmox-client.service.ts
- apps/api/src/proxmox/proxmox.service.ts
- apps/api/src/proxmox/proxmox-scheduler.service.ts
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/proxmox/proxmox.module.ts
- apps/api/src/proxmox/proxmox.seed.ts
- apps/api/src/proxmox/dto/proxmox-server.dto.ts
- apps/api/src/proxmox/proxmox-client.service.spec.ts
- apps/api/src/proxmox/proxmox.service.spec.ts
- apps/api/src/proxmox/proxmox-normalize.spec.ts
- apps/api/src/proxmox/proxmox-scheduler.service.spec.ts
- apps/api/src/proxmox/proxmox-nur-lesen.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/web/src/lib/proxmox-api.ts
- apps/web/src/lib/module-loader.ts
- apps/web/src/app/(portal)/modules/proxmox/layout.tsx
- 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/settings/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-entwicklung.md
- docs/anwenderhandbuch.md
user_setup:
- service: proxmox
why: "Nur der Nutzer hat echte PVE-/PBS-/PMG-Server. Ohne sie bleibt die Feldnamen-Annahme A2/A3 der Recherche unbestaetigt."
dashboard_config:
- task: "Je Produkt einen NUR-LESE-Zugang anlegen: PVE-Rolle PVEAuditor, PBS-Rolle Audit bzw. DatastoreAudit, PMG-Rolle Auditor"
location: "Proxmox-Oberflaeche -> Datacenter/Configuration -> Permissions"
estimate:
tokens: 320000
raw_tokens: 210000
tasks: 7
confidence: low
must_haves:
truths:
- "Kein Weg im gesamten Modul veraendert etwas bei Proxmox: die einzige Nicht-GET-Anfrage im ganzen Modul ist die Ticket-Anmeldung, und sie wird maschinell nachgezaehlt (D-01)."
- "Ein Administrator legt in den Einstellungen Server an (Name, Typ pve|pbs|pmg, Adresse, Zugang) und sieht die Zugangsdaten nie wieder im Klartext (D-02)."
- "PVE und PBS bieten API-Token ODER Benutzer/Passwort; PMG bietet nur Benutzer/Passwort — die Token-Felder erscheinen bei PMG gar nicht und ein Token-Zugang fuer PMG wird serverseitig abgelehnt (D-03)."
- "Zertifikatsfehler werden nur fuer die Server geduldet, bei denen der Administrator es einzeln eingeschaltet hat; Voreinstellung ist pruefen (D-04)."
- "Die Modulseite und jede Anzeige lesen ausschliesslich aus dem Zwischenlager, nie live bei Proxmox (D-05)."
- "Der Knopf Verbindung testen nennt die Ursache in Alltagssprache: nicht erreichbar, Zugang abgelehnt, Rechte reichen nicht, Zertifikat, unerwartete Antwort (D-06)."
- "Ein fehlendes, anders benanntes oder falsch typisiertes Feld einer Proxmox-Antwort fuehrt zu unbekannt in der Anzeige, nie zu einem Absturz, einer leeren Seite oder einem stillen Falschwert."
- "Ohne angelegten Server ist die Modulseite ruhig und erklaert, dass noch keiner eingetragen ist."
- "Beide neuen Tabellen tragen tenantId mit RLS-Policy; rls-coverage.spec.ts und rls-access-inventory.spec.ts bleiben gruen (D-08)."
artifacts:
- apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
- apps/api/src/proxmox/proxmox-auth.ts
- apps/api/src/proxmox/proxmox-client.service.ts
- apps/api/src/proxmox/proxmox-normalize.ts
- apps/api/src/proxmox/proxmox-scheduler.service.ts
- apps/api/src/proxmox/proxmox-nur-lesen.spec.ts
- "apps/web/src/app/(portal)/modules/proxmox/page.tsx"
- "apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx"
key_links:
- "proxmox-auth.ts ist die EINZIGE Stelle, die Kopfzeilen und Anmelde-Cookies je Produkt baut — Klient, Verbindungstest und Planer rufen sie, keiner baut sie nach (D-03)."
- "proxmox-client.service.ts baut den undici-Dispatcher je Aufruf aus dem Feld tlsRejectUnauthorized genau dieser Serverzeile (D-04)."
- "proxmox-scheduler.service.ts haengt an onApplicationBootstrap und faechert je Mandant auf; der Controller zieht nach jedem Speichern nach (D-05)."
- "proxmox.controller.ts traegt @UseModule('proxmox'); die Schreibwege zusaetzlich @Roles(ADMIN, SUPER_ADMIN) (D-09)."
- "Jeder Datenbankzugriff laeuft ueber forTenant(); nur der Startpfad des Planers ueber forSystem() und steht in FORSYSTEM_ALLOWED_CALL_SITES (D-08)."
---
<objective>
Das Proxmox-Modul anbinden: PVE, PBS und PMG **nur beobachten**. Der Administrator legt in
den Moduleinstellungen beliebig viele Server an (Name, Typ, Adresse, Zugang — verschluesselt
gespeichert). Ein Hintergrunddienst fragt sie periodisch ab und legt die Messwerte in einem
Zwischenlager ab. Die Modulseite zeigt die Serverliste mit Auslastung und liest dabei
ausschliesslich aus dem Zwischenlager.
Purpose: Der Nutzer sieht den Zustand seiner Proxmox-Landschaft in Tessera, ohne die
Proxmox-Oberflaechen einzeln zu oeffnen — und ohne dass Tessera je etwas an ihnen aendern kann.
Output: Ein vollstaendiges Modul `proxmox` (Datenbank, Dienst, API, Hintergrundabfrage,
Einstellungsseite, Modulseite, Dokumentation), sieben eigenstaendige Commits.
## Herkunft der Entscheidungen (D-Nummern)
Die D-Nummern in diesem Plan verweisen auf die elf bereits getroffenen Entscheidungen aus dem
Auftrag (`<decisions_already_made>`), in derselben Reihenfolge:
| ID | Entscheidung |
|---|---|
| D-01 | Nur beobachten — kein veraendernder Weg gegen Proxmox |
| D-02 | Administrator legt Server an; Zugangsdaten verschluesselt, nie im Klartext zurueck |
| D-03 | PVE/PBS: Token oder Benutzer/Passwort; PMG nur Benutzer/Passwort; Kopfzeilen aus EINER Stelle |
| D-04 | Zertifikatsfehler nur je Server umschaltbar dulden, nie global |
| D-05 | Zwischenlager statt Live-Abfrage; Planer nach TENDER-Muster (`onApplicationBootstrap`) |
| D-06 | Knopf „Verbindung testen" mit Klartext-Ursache |
| D-07 | Keine neue npm-Abhaengigkeit — `undici` ist bereits da |
| D-08 | Mandantentrennung Pflicht: `tenantId` + RLS + Klassifikationsdoku + gruene Waechter-Tests |
| D-09 | Zugriff ueber die normale Modulfreigabe |
| D-10 | Oberflaechentexte Deutsch in der Sie-Form ueber next-intl; Kommentare Deutsch |
| D-11 | Dashboard-Kachel ist NICHT in diesem Auftrag |
## Ausgangswerte der Tore (gemessen 2026-09-23, vor Beginn)
| Tor | Ausgangswert |
|---|---|
| `pnpm --filter @tessera/api test` | 77 Dateien, 1240 Tests, alle gruen |
| `pnpm --filter @tessera/web test` | 82 Dateien, 693 Tests, alle gruen |
| `pnpm type-check` | 4 von 4 erfolgreich |
| `pnpm lint` | 5 von 5 erfolgreich |
| Biome-Warnungen in `apps/web` | genau 53 (253 Dateien geprueft) |
Zielwert nach jeder Aufgabe: Testzahlen **groesser oder gleich** dem Ausgangswert und gruen,
type-check 4/4, lint 5/5, Biome-Warnungen in `apps/web` **exakt 53** — nicht mehr, nicht weniger.
## Ausdruecklich NICHT im Umfang
Dashboard-Kachel (D-11, kommt als eigener Auftrag ueber `WIDGET_TYPES` /
`WIDGET_MODULE_SLUGS` / `registerWidget`), Zeitreihen und Verlaufsgrafiken (`/rrddata`),
Eingriffe jeder Art (Start, Stopp, Sichern, Freigeben), Quarantaene-Verwaltung bei PMG.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-RESEARCH.md
@.planning/STATE.md
@CLAUDE.md
Bestandsmuster, die dieser Plan wortgetreu wiederverwendet (vor der jeweiligen Aufgabe lesen,
nicht raten):
@apps/api/src/favorites/icon-discovery.service.ts
@apps/api/src/ldap/ldap-config.service.ts
@apps/api/src/dkv/dkv-scheduler.service.ts
@apps/api/src/tenders/tender-scheduler.service.ts
@apps/api/src/domaincheck/domaincheck.module.ts
@apps/api/src/prisma/prisma-tenant.extension.ts
</context>
<interface_context>
Signaturen und Konstanten, auf die jede Aufgabe aufsetzt — so gemessen im Bestand, nicht erfunden:
- `CryptoService` (`apps/api/src/crypto/crypto.service.ts`): `encrypt(plaintext: string): string`
und `decrypt(stored: string): string`. Format `iv:authTag:ciphertext`, alles hex,
Doppelpunkt-getrennt. `CryptoModule` steht bereits in `app.module.ts`.
- Erkennungsform fuer „schon verschluesselt" (`ldap-config.service.ts:17`):
`/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i`.
- `forTenant(prisma, tenantId, userId?)` und `forSystem(prisma)` aus
`apps/api/src/prisma/prisma-tenant.extension.ts`. Konvention: lokale Konstante
`const tenantPrisma = forTenant(this.prisma, tenantId);` — keine andere Form, sonst schlaegt
`rls-access-inventory.spec.ts` fehl.
- `undici`: `import { Agent, fetch as undiciFetch } from 'undici'`. Nodes globales `fetch`
ignoriert einen `Agent` aus dem npm-Paket (gemessen, `icon-discovery.service.ts:33-37`).
- `@UseModule(slug)` aus `apps/api/src/module-registry/module.guard.ts`,
`@Roles(Role.ADMIN, Role.SUPER_ADMIN)` aus `apps/api/src/auth/decorators/roles.decorator.ts`.
- `ModuleRegistryService.seedModule({ slug, name, version, category, description: {de, en}, isSystem })`
— Vorlage `apps/api/src/domaincheck/domaincheck.seed.ts`.
- `CronJobClass` wird per `require('cron').CronJob` aufgeloest (pnpm-Isolation, Kommentar in
`dkv-scheduler.service.ts:6-16` woertlich uebernehmen).
- RLS-Policy-Form ohne Benutzerdimension (`20260909140000`, DkvModuleConfig):
`CREATE POLICY tenant_isolation_policy ON "X" USING ("tenantId" = current_tenant_id());`
- Systemlese-Form (`20260914120000`):
`CREATE POLICY system_read_policy ON "X" FOR SELECT USING (is_system_context());`
- Frontend-Datenzugriff: ein Helfer `apps/web/src/lib/<modul>-api.ts` (Vorbild `dkv-api.ts`),
`API_URL` aus `process.env.NEXT_PUBLIC_API_URL`, `credentials: 'include'`.
- Modulseiten liegen unter `apps/web/src/app/(portal)/modules/<slug>/`, `layout.tsx` umschliesst
mit `<ModuleAccessGate moduleSlug="<slug>">`, Eintrag in `MODULE_REGISTRY`
(`apps/web/src/lib/module-loader.ts`).
</interface_context>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Ein PVE-Server per Token — von der Tabelle bis zur Modulseite</name>
<files>
apps/api/prisma/schema.prisma,
apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql,
apps/api/src/proxmox/proxmox.types.ts,
apps/api/src/proxmox/proxmox-auth.ts,
apps/api/src/proxmox/proxmox-client.service.ts,
apps/api/src/proxmox/proxmox.service.ts,
apps/api/src/proxmox/proxmox.controller.ts,
apps/api/src/proxmox/proxmox.module.ts,
apps/api/src/proxmox/proxmox.seed.ts,
apps/api/src/proxmox/dto/proxmox-server.dto.ts,
apps/api/src/proxmox/proxmox.service.spec.ts,
apps/api/src/app.module.ts,
apps/web/src/lib/proxmox-api.ts,
apps/web/src/lib/module-loader.ts,
apps/web/src/app/(portal)/modules/proxmox/layout.tsx,
apps/web/src/app/(portal)/modules/proxmox/page.tsx,
apps/web/src/messages/de.json,
apps/web/src/messages/en.json,
docs/mandantentrennung-zugriffsklassifikation.md
</files>
<precondition>
`TESSERA_ENCRYPTION_KEY` ist gesetzt (64 Hex-Zeichen) und die Entwicklungsdatenbank ist vom
Host erreichbar — sonst scheitert `prisma migrate dev`. Erreichbarkeit siehe
`docs/anleitung-entwicklung.md`, Abschnitt „Datenbank vom Host erreichen"; die Datenbank hat
keinen Host-Port, der Zugriff laeuft ueber die Container-IP mit `tessera:tessera_dev`.
</precondition>
<action>
Duenner, aber durchgehender Schnitt durch ALLE Schichten, die dieses Modul anfasst — genau EIN
Weg: ein PVE-Server, Zugang per API-Token, vom Anlegen ueber die Abfrage und das Zwischenlager
bis zur Anzeige im Browser. Kein PBS, kein PMG, kein Benutzer/Passwort, kein Planer, keine
Einstellungsoberflaeche, kein Bearbeiten oder Loeschen — das bauen die Aufgaben 2 bis 6 auf
diesem bewiesenen Geruest auf. Was hier entsteht, ist Endstand, kein Wegwerfstueck: dieselbe
Fehlerbehandlung, dieselbe Mandantenbindung, dieselben Tore wie jede spaetere Aufgabe.
**Schema** (`schema.prisma`) — zwei Modelle nach dem Vorbild `CalendarSource` (mehrere
verschluesselte Fremdzugaenge je Mandant), NICHT nach `DkvModuleConfig` (Singleton je Mandant):
`ProxmoxServer`: `id` (uuid), `tenantId`, `name`, `productType` (Zeichenkette `pve|pbs|pmg`,
kommentiert wie `CalendarSource.type`), `baseUrl`, `authMethod` (`token|password`), `tokenId`
(nullable), `encryptedTokenSecret` (nullable, Kommentar „AES-256-GCM ciphertext
(iv:authTag:ciphertext hex)" wie `CalendarSource.encryptedPassword`), `username` (nullable),
`encryptedPassword` (nullable), `tlsRejectUnauthorized Boolean @default(true)` (Feldname
woertlich von `LdapConfig`, D-04), `isActive Boolean @default(true)`,
`pollIntervalMin Int @default(5)`, `position Int @default(0)`, `createdAt`, `updatedAt`,
Relation `status ProxmoxServerStatus?`, `@@index([tenantId])`.
`ProxmoxServerStatus`: `id`, `serverId String @unique` mit Relation auf `ProxmoxServer`
(`onDelete: Cascade`), `tenantId`, `lastPolledAt DateTime?`, `lastOkAt DateTime?`,
`reachable Boolean @default(false)`, `errorKind String?`, `errorDetail String?`,
`metrics Json?` (normalisierte Messwerte), `rawSample Json?` (gekuerzte Rohantwort zur
Fehlersuche beim Nutzer), `updatedAt`, `@@index([tenantId])`.
**Migration** (`20260923140000_proxmox_server/migration.sql`) — von Hand geschrieben nach dem
Vorbild `20260923120000_dashboard_tabs`: deutscher Kopfkommentar, der Zweck und die
Entscheidungen benennt. Beide Tabellen mit `ENABLE`/`FORCE ROW LEVEL SECURITY` und
`tenant_isolation_policy` in der Form OHNE Benutzerdimension
(`USING ("tenantId" = current_tenant_id())`, Vorbild `DkvModuleConfig`) — Proxmox-Server sind
Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Zusaetzlich auf
`ProxmoxServer` (und NUR dort) `system_read_policy … FOR SELECT USING (is_system_context())`
mit der Begruendung im Kommentar, dass der Planer aus Aufgabe 4 beim Start die aktiven Server
ALLER Mandanten sehen muss; `ProxmoxServerStatus` bekommt sie bewusst nicht, weil dort nur je
Mandant gebunden geschrieben wird. Indizes auf `tenantId` sowie `serverId` (unique). Rechte
fuer `tessera_app` kommen automatisch ueber `ALTER DEFAULT PRIVILEGES` aus
`20260909130000_rls_app_role` — im Kommentar erwaehnen, nichts tun.
**`proxmox.types.ts`** — die gemeinsamen Typen: `ProxmoxProductType = 'pve' | 'pbs' | 'pmg'`,
`ProxmoxAuthMethod = 'token' | 'password'`, `ProxmoxErrorKind =
'netz' | 'zugang' | 'rechte' | 'zertifikat' | 'antwortform' | 'server' | 'unbekannt'` (diese
sieben Werte landen so in der Datenbank und werden erst im Frontend uebersetzt — stabile
Schluessel, uebersetzbarer Text), und die Ergebnisform `ProxmoxPollResult` mit
`{ reachable, errorKind, errorDetail, metrics, rawSample }`.
**`proxmox-auth.ts`** — die EINZIGE Stelle im ganzen Modul, die Anmeldeinformationen in
Kopfzeilen uebersetzt (D-03, key_link). In dieser Aufgabe nur der Token-Zweig: eine reine
Funktion `buildTokenAuthHeader(productType, tokenId, tokenSecret)`, die fuer `pve` das Schema
`PVEAPIToken` mit Gleichheitszeichen vor dem Geheimnis und fuer `pbs` das Schema `PBSAPIToken`
mit Doppelpunkt vor dem Geheimnis liefert (Recherche, Block 1) und fuer `pmg` einen Fehler
wirft, weil PMG keine Token kennt. Die Funktion nimmt Klartext entgegen und gibt nur die
Kopfzeile zurueck — sie protokolliert nie, sie wirft das Geheimnis nie in eine Fehlermeldung.
**`proxmox-client.service.ts`** — der HTTP-Zugang, und ausschliesslich lesend (D-01).
Genau EINE oeffentliche Datenabruf-Funktion `proxmoxGet(server, path)`, die das
Anfrageverfahren fest auf Lesen setzt (kein Parameter dafuer, kein Durchreichen von aussen).
Zwingend `undiciFetch` aus dem `undici`-Paket, nicht das globale `fetch` — sonst wird der
Dispatcher stillschweigend ignoriert (gemessen, `icon-discovery.service.ts:33-37`); diesen
Grund als deutschen Kommentar in die Datei schreiben. Der Dispatcher wird JE AUFRUF aus der
gelesenen Serverzeile gebaut: ist `tlsRejectUnauthorized` wahr, wird kein Dispatcher
uebergeben (Normalweg, echte Pruefung); ist es falsch, ein frischer
`new Agent({ connect: { rejectUnauthorized: false } })` nur fuer diesen einen Aufruf (D-04).
Ausdruecklich KEINE Modulkonstante wie in `icon-discovery.service.ts` und ausdruecklich keine
Node-Umgebungsvariable — beides als Kommentar festhalten. Abbruch nach 8 Sekunden ueber
`AbortController`. Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen
per Definition im privaten Netz, eine solche Pruefung wuerde jede reale Adresse blockieren;
die Absicherung ist stattdessen, dass nur ein Administrator Adressen eintragen darf (siehe
Bedrohungsmodell T-DHH-02). Rueckgabe ist ein Ergebnisobjekt mit `ok`, `status`, `body` und
`errorKind` — geworfen wird nichts nach aussen; Netzfehler und Zertifikatsfehler werden
abgefangen und in `errorKind` uebersetzt.
**`proxmox.service.ts`** — die Fachlogik, jeder Datenbankzugriff ueber
`const tenantPrisma = forTenant(this.prisma, tenantId);` (Konvention woertlich, D-08):
`createServer(tenantId, dto)` verschluesselt das Token-Geheimnis mit `this.crypto.encrypt(...)`
und legt Server plus leere Zwischenlagerzeile an; `listWithStatus(tenantId)` liefert Server
samt Zwischenlager OHNE jedes Geheimnisfeld (`select` ohne `encryptedTokenSecret` und
`encryptedPassword`, nicht nachtraeglich maskiert — die Felder verlassen die Datenbank gar
nicht erst); `pollServer(tenantId, serverId)` entschluesselt in genau EINER privaten Methode
`decryptSecret(stored)` nach dem Vorbild `LdapConfigService.decryptBindPassword` (Form
erkennen, unveraenderte Altwerte durchreichen), ruft fuer `pve` den Pfad
`/api2/json/cluster/resources`, normalisiert das Ergebnis und schreibt es ins Zwischenlager.
In dieser Aufgabe nur PVE und nur eine Grundauswertung: Anzahl Knoten, Anzahl laufender und
gestoppter Gaeste, und je Knoten `cpu`/`maxcpu`/`mem`/`maxmem` — jeder Einzelwert nachsichtig
gelesen (fehlt er, steht `null` im Zwischenlager und spaeter „unbekannt" in der Anzeige, nie
ein Absturz und nie eine 0, die wie ein Messwert aussieht). Die Rohantwort wird auf hoechstens
20 000 Zeichen gekuerzt in `rawSample` abgelegt, damit der Nutzer beim Testen an seinen echten
Servern sieht, was tatsaechlich kam.
**`proxmox.controller.ts`** — `@Controller('modules/proxmox')` und `@UseModule('proxmox')` auf
Klassenebene (D-09, Vorbild `domaincheck.controller.ts`). Drei Wege: `GET servers` (Liste mit
Zwischenlager, fuer jeden Benutzer mit Modulzugriff), `POST servers` und
`POST servers/:id/poll` — beide Schreibwege zusaetzlich mit
`@Roles(Role.ADMIN, Role.SUPER_ADMIN)`. `tenantId` kommt ausschliesslich aus `req.tenantId`,
nie aus Body oder Query.
**`dto/proxmox-server.dto.ts`** — `class-validator`: `name` nicht leer, `productType` per
`@IsIn(['pve','pbs','pmg'])`, `baseUrl` per `@IsUrl({ protocols: ['http','https'], require_tld: false })`
(ohne `require_tld`, weil interne Namen wie `pve.intern` sonst abgelehnt wuerden),
`authMethod` per `@IsIn(['token','password'])`, `tlsRejectUnauthorized` optional boolesch,
`pollIntervalMin` als Ganzzahl zwischen 1 und 1440.
**`proxmox.module.ts` / `proxmox.seed.ts` / `app.module.ts`** — Vorbild Domaincheck:
`seedProxmoxModule` mit `slug: 'proxmox'`, `name: 'Proxmox'`, `version: '1.0.0'`,
`category: 'infrastructure'`, deutscher und englischer Beschreibung, `isSystem: true`;
`ProxmoxModule` importiert `ModuleRegistryModule` und ruft den Seed in `onModuleInit`;
Eintrag in `app.module.ts` unter `imports` hinter `BugReportsModule`.
**Frontend** — `apps/web/src/lib/proxmox-api.ts` nach dem Vorbild `dkv-api.ts`
(`listServers()`); `modules/proxmox/layout.tsx` mit
`<ModuleAccessGate moduleSlug="proxmox">`; `modules/proxmox/page.tsx` als Client-Komponente,
die die Serverliste laedt und je Server Name, Typ, Adresse und die vorhandenen Messwerte
anzeigt — fehlende Werte als „unbekannt", bei leerer Liste ein ruhiger Hinweis, dass noch kein
Server eingetragen ist (kein Fehlergewitter, keine weisse Flaeche); Eintrag `proxmox` in
`MODULE_REGISTRY` (`module-loader.ts`). Alle sichtbaren Texte ueber `useTranslations('proxmox')`
mit neuen Schluesseln in `de.json` UND `en.json` — deutsche Texte in der Sie-Form (D-10).
**Doku** — in `docs/mandantentrennung-zugriffsklassifikation.md` die neuen Fundstellen als
Tabellenzeilen im Format `| Datei | Modell | Klasse | Stand | Begruendung |` eintragen
(`apps/api/src/proxmox/proxmox.service.ts` / `proxmoxServer` und `proxmoxServerStatus`, Klasse
`muss-mandantengebunden`, Stand `gebunden`), sonst schlaegt `rls-access-inventory.spec.ts` fehl.
Die Bereichs- und Summenzeilen mit der Gate-Schleife NACHMESSEN, nicht abschreiben.
Deutsche Kommentare im Code wie in den Nachbardateien (D-10). Keine neue npm-Abhaengigkeit
(D-07) — `undici` steht bereits als direkte Abhaengigkeit in `apps/api/package.json`.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/proxmox src/prisma/rls-coverage.spec.ts src/prisma/rls-access-inventory.spec.ts</automated>
<automated>pnpm --filter @tessera/api test</automated>
<automated>pnpm --filter @tessera/web test</automated>
<automated>pnpm type-check</automated>
</verify>
<done>
`proxmox.service.spec.ts` fuehrt den ganzen Weg mit einer gefaelschten `undici`-Antwort durch
(Vorbild der Attrappe: `icon-discovery.service.spec.ts`, `vi.mock('undici', …)`): Server
anlegen, abfragen, Zwischenlager gelesen — und weist nach, dass (a) das Geheimnis
verschluesselt in der Datenbank steht und in der Antwort von `listWithStatus` ueberhaupt nicht
vorkommt, (b) bei `tlsRejectUnauthorized: true` KEIN Dispatcher uebergeben wird und bei
`false` genau einer mit abgeschalteter Pruefung, (c) ein fehlendes Feld der Antwort zu `null`
fuehrt und nicht zu einem Wurf. `pnpm --filter @tessera/api test` gruen mit mindestens 1240
Tests, `pnpm --filter @tessera/web test` gruen mit mindestens 693 Tests, `pnpm type-check`
4 von 4. Im Browser ist `/modules/infrastructure/proxmox` erreichbar und zeigt bei leerer
Liste den ruhigen Hinweis.
</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Zugang per Benutzer/Passwort, Fehler in Alltagssprache, Beobachtungs-Riegel</name>
<files>
apps/api/src/proxmox/proxmox-auth.ts,
apps/api/src/proxmox/proxmox-client.service.ts,
apps/api/src/proxmox/proxmox.service.ts,
apps/api/src/proxmox/dto/proxmox-server.dto.ts,
apps/api/src/proxmox/proxmox-client.service.spec.ts,
apps/api/src/proxmox/proxmox-nur-lesen.spec.ts
</files>
<behavior>
- Ticket-Anmeldung: `POST /api2/json/access/ticket` mit `username`/`password` als Formularfeldern liefert `data.ticket`; Folgeanfragen tragen das Ticket als Cookie mit produktabhaengigem Namen (`PVEAuthCookie`, `PBSAuthCookie`, `PMGAuthCookie`).
- Kein `CSRFPreventionToken` wird jemals mitgesendet — dieses Modul liest nur, und fuer Leseanfragen verlangt Proxmox ihn laut offizieller Doku nicht.
- Ein Server vom Typ `pmg` mit `authMethod: 'token'` wird beim Anlegen und beim Bearbeiten mit einer deutschen Klartextmeldung abgelehnt (400), nicht erst beim Abfragen.
- Antwortstatus 401 wird zu `errorKind: 'zugang'`, 403 zu `'rechte'`, 404 zu `'antwortform'` mit dem Hinweis auf eine falsche Adresse, 5xx zu `'server'`.
- Ein geworfener Netzfehler ohne Antwort (Verbindung verweigert, Zeitablauf, Name nicht aufloesbar) wird zu `errorKind: 'netz'`.
- Ein Zertifikatsfehler (Meldungstext enthaelt eine der bekannten Zertifikatskennungen) wird zu `errorKind: 'zertifikat'` und NICHT zu `'netz'`.
- Eine Antwort, die kein JSON ist (HTML-Anmeldeseite, leerer Rumpf), wird zu `errorKind: 'antwortform'` — kein geworfener Parserfehler, kein Absturz.
- Laeuft ein Ticket ab (401 bei `authMethod: 'password'`), wird GENAU EINMAL neu angemeldet und die Abfrage wiederholt; erst ein zweites 401 wird zu `errorKind: 'zugang'`.
- Keine Fehlermeldung, kein Protokolleintrag und kein `rawSample` enthaelt jemals Passwort, Token-Geheimnis oder das Ticket.
- Im gesamten Verzeichnis `apps/api/src/proxmox` gibt es ausserhalb der Ticket-Anmeldung keine einzige Stelle, die ein anderes Anfrageverfahren als Lesen an Proxmox schickt.
</behavior>
<action>
Zuerst die Tests aus `<behavior>` in `proxmox-client.service.spec.ts` schreiben (rot), dann
implementieren. Die `undici`-Attrappe wie in `icon-discovery.service.spec.ts`.
`proxmox-auth.ts` waechst um den Ticket-Zweig und bleibt dabei die EINZIGE Stelle, die
Kopfzeilen und Cookies baut (D-03, key_link): `buildTokenAuthHeader` wie in Aufgabe 1, neu
`loginTicket(server, password)` und `buildTicketCookieHeader(productType, ticket)`. Die
Cookie-Namen je Produkt stehen als benannte Konstante in dieser einen Datei, mit deutschem
Kommentar, dass die Namen fuer PBS und PMG aus der Recherche nur abgeleitet sind (Annahme A2)
und der Nutzer sie an seinen echten Servern bestaetigt — steht dort ein anderer Name, ist es
genau diese eine Konstante, die angepasst wird.
`loginTicket` ist die EINZIGE Stelle im Modul, die eine nicht-lesende Anfrage an Proxmox
schickt, und sie aendert dort nichts — sie holt nur einen Nachweis ab (D-01). Diesen
Sonderstatus als deutschen Kommentar in der Datei festhalten.
`proxmox-client.service.ts` bekommt die Fehler-Uebersetzung: eine reine Funktion
`classifyFailure(status, thrownError)`, die genau die sieben Werte aus `ProxmoxErrorKind`
liefert, und eine Funktion `parseJsonLenient(text)`, die bei nicht-JSON kein Werfen zulaesst
sondern das Scheitern meldet. Die Zertifikatserkennung laeuft ueber die bekannten
Fehlerkennungen von Node/undici (selbstsigniert, abgelaufen, Name passt nicht, unbekannter
Aussteller) — im Zweifel `'zertifikat'` nur bei eindeutigem Treffer, sonst `'netz'`.
Zusaetzlich `errorDetail` als KURZE, deutsche Ergaenzung (Statuszahl, Fehlerkennung), aus der
niemals ein Geheimnis hervorgeht; die Weiterverarbeitung des `errors`-Feldes der Proxmox-Antwort
ist erlaubt, aber gekuerzt auf 500 Zeichen.
Die Ticket-Erneuerung sitzt in `proxmox.service.ts` (nicht im Klienten): ein Zaehler, der genau
einen zweiten Versuch erlaubt. Der Grund als Kommentar: bei Ticketdauer von zwei Stunden
erzeugt ein normaler Ablauf sonst alle zwei Stunden einen Fehlalarm.
`dto/proxmox-server.dto.ts` bekommt die produktabhaengige Pruefung (PMG plus Token ist
ungueltig) — Pflichtfelder je nach `authMethod` mit `@ValidateIf`, damit ein Token-Zugang
`tokenId` und Geheimnis verlangt und ein Passwort-Zugang `username` und Passwort.
`proxmox-nur-lesen.spec.ts` ist der maschinelle Riegel zu D-01, gebaut nach dem Vorbild von
`apps/api/src/prisma/rls-access-inventory.spec.ts` (Test liest den Quelltext, nicht das
Laufzeitverhalten): er liest alle `.ts`-Dateien unter `apps/api/src/proxmox`, entfernt vor
dem Zaehlen Kommentarzeilen und Zeichenkettenliterale aus Testdateien, und prueft zwei
Aussagen — erstens, dass die Summe der Stellen, die ein Anfrageverfahren an `undiciFetch`
uebergeben, genau EINS ist und in `proxmox-auth.ts` liegt; zweitens, dass jeder gegen einen
Proxmox-Pfad gebaute Aufruf ausser dieser einen ueber `proxmoxGet` laeuft. Die erwartete Zahl
steht als benannte Konstante mit ausgeschriebener Begruendung in der Testdatei, damit eine
spaetere Erhoehung eine bewusste Entscheidung erzwingt und nicht unbemerkt durchrutscht.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/proxmox</automated>
<automated>pnpm --filter @tessera/api test</automated>
<automated>pnpm type-check</automated>
</verify>
<done>
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt.
`proxmox-nur-lesen.spec.ts` ist gruen und wuerde rot, wenn irgendwo im Modul eine zweite
nicht-lesende Anfrage an Proxmox entstuende. `pnpm --filter @tessera/api test` gruen mit
mindestens 1240 Tests, `pnpm type-check` 4 von 4.
</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: PBS und PMG auswerten — nachsichtig gegen jede Antwortform</name>
<files>
apps/api/src/proxmox/proxmox-normalize.ts,
apps/api/src/proxmox/proxmox.service.ts,
apps/api/src/proxmox/proxmox.types.ts,
apps/api/src/proxmox/proxmox-normalize.spec.ts
</files>
<behavior>
- PVE: aus `/api2/json/cluster/resources` entstehen Knotenzahl, Zahl laufender und gestoppter Gaeste, je Knoten Prozessorlast und Speicherbelegung, je Speicherort Belegung.
- PBS: aus `/api2/json/status/datastore-usage` entsteht je Datenspeicher Gesamt, Belegt, Frei; aus `/api2/json/admin/datastore/{store}/snapshots` je Datenspeicher der Zeitpunkt der letzten Sicherung und das Ergebnis der letzten Pruefung.
- PMG: aus `/api2/json/statistics/mail` entstehen die Tageszahlen eingehend, ausgehend, Spam, Viren.
- Fehlt ein erwartetes Feld vollstaendig, ist der Einzelwert `null` — nie `0`, nie `NaN`, nie ein Wurf.
- Kommt eine Zahl als Zeichenkette (`"42"`, `"0.37"`), wird sie als Zahl gelesen; kommt sie als nicht umwandelbarer Text, ist der Wert `null`.
- Ist die gesamte Antwort eine Zeichenkette, ein Array statt eines Objekts, `null` oder leer, entsteht ein leeres Messwertobjekt mit `errorKind: 'antwortform'` — nie ein Wurf.
- Heisst ein Feld anders als erwartet, bleibt der zugehoerige Einzelwert `null` und die gekuerzte Rohantwort bleibt in `rawSample` erhalten, damit der Nutzer am echten Server erkennt, wie das Feld wirklich heisst.
- Ein Datenspeicher ohne Sicherungen ergibt „noch keine Sicherung" und keinen Fehler.
- Ein PBS-Server mit vielen Datenspeichern erzeugt hoechstens 10 Folgeabfragen je Durchlauf.
</behavior>
<action>
Zuerst `proxmox-normalize.spec.ts` schreiben (rot), mit ERFUNDENEN Antworten in der von der
Recherche dokumentierten Form — es gibt in dieser Umgebung keinen echten Proxmox-Server, und
es wird auch keiner angefragt. Je Punkt aus `<behavior>` mindestens ein Fall, und zusaetzlich
je Produkt ein Fall „Feld fehlt", „Zahl kommt als Zeichenkette" und „Antwort ist HTML statt
JSON".
`proxmox-normalize.ts` traegt die nachsichtigen Leser als reine Funktionen ohne
Datenbankbezug: `readNumber(value)` (Zahl, umwandelbare Zeichenkette, sonst `null`),
`readText(value)`, `readBool(value)` und `readList(value)` (liefert bei allem, was kein Array
ist, eine leere Liste). Darauf setzen `normalizePve(body)`, `normalizePbs(usage, snapshots)`
und `normalizePmg(body)` auf. Keine dieser Funktionen wirft jemals — der gesamte Umgang mit
einer unerwarteten Form ist ein Rueckgabewert, nicht eine Ausnahme; als deutscher Kommentar
festhalten, warum: der Nutzer prueft dieses Modul allein an seinen echten Servern, und ein Wurf
wuerde ihm eine leere Seite statt eines Hinweises zeigen.
Die Feldnamen von PBS und PMG sind aus der Recherche nur abgeleitet (Annahmen A2, A3, A5). In
`proxmox-normalize.ts` je Produkt eine benannte Konstante mit den erwarteten Feldnamen und
einem deutschen Kommentar, dass genau diese Liste anzupassen ist, falls der echte Server
andere Namen liefert — dadurch gibt es EINE Stelle zum Nachziehen statt verstreuter
Zeichenketten im Auswertungscode. Wo ein Feld unter mehreren plausiblen Namen auftreten kann,
darf die Konstante mehrere Namen in Reihenfolge nennen, und der Leser nimmt den ersten
vorhandenen.
`proxmox.service.ts` waechst um die produktabhaengige Abfragefolge: `pve` eine Abfrage, `pbs`
die Belegungsabfrage plus je Datenspeicher hoechstens zehn Folgeabfragen (Deckel als benannte
Konstante mit Begruendung), `pmg` eine Abfrage. Jede dieser Abfragen laeuft ueber `proxmoxGet`
— keine neue Aufrufform (Riegel aus Aufgabe 2 bleibt gruen). Das Zwischenlager bekommt je
Produkt seine Messwertform; `ProxmoxMetrics` in `proxmox.types.ts` als unterscheidbare Union
ueber `productType`, damit das Frontend typsicher verzweigen kann.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/proxmox</automated>
<automated>pnpm --filter @tessera/api test</automated>
<automated>pnpm type-check</automated>
</verify>
<done>
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt, einschliesslich der
vier ausdruecklich verlangten Fehlformen (Feld fehlt, Zahl als Zeichenkette, HTML statt JSON,
Statuscodes 401/403/404/500 — Letztere aus Aufgabe 2 weiterhin gruen).
`proxmox-nur-lesen.spec.ts` bleibt gruen. `pnpm --filter @tessera/api test` gruen mit
mindestens 1240 Tests, `pnpm type-check` 4 von 4.
</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 4: Hintergrundabfrage je Mandant und der Knopf „Verbindung testen"</name>
<files>
apps/api/src/proxmox/proxmox-scheduler.service.ts,
apps/api/src/proxmox/proxmox.service.ts,
apps/api/src/proxmox/proxmox.controller.ts,
apps/api/src/proxmox/proxmox.module.ts,
apps/api/src/proxmox/proxmox-scheduler.service.spec.ts,
apps/api/src/prisma/rls-access-inventory.spec.ts,
docs/mandantentrennung-zugriffsklassifikation.md
</files>
<behavior>
- Beim Start registriert der Planer je Mandant mit mindestens einem aktiven Server genau einen Auftrag unter dem Registry-Namen `proxmox-poll:<tenantId>`.
- Der Tick eines Mandanten geht ueber dessen Server und fragt jeden einzeln ab; ein fehlgeschlagener Server bricht die Schleife nicht ab.
- Ein zweiter Mandant verdraengt den Auftrag des ersten nicht — beide Auftraege bestehen nebeneinander.
- Keine aktiven Server bedeutet: kein Auftrag, ein Protokolleintrag, kein Fehler, nichts geloescht.
- Nach dem Speichern eines Servers zieht der Controller den Auftrag dieses Mandanten sofort nach — ohne Neustart.
- Der Planer haengt an `onApplicationBootstrap`, nicht an `onModuleInit`.
- Ein Fehler beim Start wird gefangen und protokolliert, nie weitergeworfen — die Anwendung startet trotzdem.
- `POST servers/:id/test` liefert bei Erfolg eine Erfolgsmeldung und bei Misserfolg genau einen der sieben Fehlerschluessel samt kurzer Ergaenzung, ohne den Zwischenlagerstand zu ueberschreiben.
- `POST servers/:id/poll` verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert stattdessen den vorhandenen Zwischenlagerstand.
</behavior>
<action>
Zuerst `proxmox-scheduler.service.spec.ts` schreiben (rot) — Vorbild
`dkv-scheduler.service.spec.ts`, je Aussage aus `<behavior>` ein Test.
`proxmox-scheduler.service.ts` kombiniert die zwei Bestandsmuster (Recherche, Block 3): das
Mandanten-Auffaechern von `DkvSchedulerService` (ein Auftrag je Mandant, Registry-Name mit
Mandantenkennung als Suffix — die Vorgaengerform mit EINEM Auftragsfeld war genau der Fehler
WINDOWS #21) und die Lebenszyklus-Stufe von `TenderSchedulerService`
(`implements OnApplicationBootstrap`). Den Grund fuer `onApplicationBootstrap` als deutschen
Kommentar uebernehmen: die Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
festgelegt, und die Erfahrung „frische Datenbank ingestiert nichts bis zum zweiten Neustart"
steht bereits im Projektgedaechtnis. Die Aufloesung von `CronJob` ueber `require('cron')`
samt Kommentar woertlich aus `dkv-scheduler.service.ts` uebernehmen (pnpm-Isolation).
Anders als bei DKV ist ein Mandant NICHT gleich ein Server: der Tick eines Mandanten geht ueber
dessen Serverzeilen. Das Abfrageintervall eines Mandanten ist das kleinste `pollIntervalMin`
seiner aktiven Server. Ein fehlgeschlagener Server schreibt seinen Fehler ins Zwischenlager
und die Schleife laeuft weiter — dieser Punkt ausdruecklich als Test.
Der Startpfad `loadActiveServersForScheduler()` in `proxmox.service.ts` ist der EINZIGE
Systemkontext-Aufruf des Moduls: `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). Danach wird je Mandant und je
Server ueber `forTenant(this.prisma, tenantId)` geschrieben, Muster
`DkvSchedulerService`/`DashboardImagesService` (einmal lesen, viele bedienen). Diesen einen
Aufruf in `FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`
eintragen (`apps/api/src/proxmox/proxmox.service.ts` mit Anzahl 1) und den Kopfkommentar
derselben Datei um den neuen Fall ergaenzen, wie es die bestehenden sieben Faelle vormachen —
sonst schlaegt der Waechter „ein Anfrageweg darf den Systemkontext nie rufen" fehl. In
`docs/mandantentrennung-zugriffsklassifikation.md` den Stand der Zeile
`proxmox.service.ts`/`proxmoxServer` von `gebunden` auf `system-gebunden` heben, mit derselben
Begruendungsform wie bei `dashboard-images.service.ts`; Bereichs- und Summenzeilen mit der
Gate-Schleife nachmessen.
`proxmox.controller.ts` bekommt `POST servers/:id/test` (ADMIN/SUPER_ADMIN) — es benutzt
denselben Klienten und dieselbe Fehleruebersetzung wie der Planer, schreibt aber NICHT ins
Zwischenlager, damit ein Testklick den zuletzt gemessenen Stand nicht ueberschreibt (Vorbild
`TenderEmailConfigService.testConnection` und der LDAP-Test). Zusaetzlich ruft der Controller
nach jedem erfolgreichen Anlegen und Speichern `scheduler.setInterval(tenantId)` — Vorbild
`DkvController`. `POST servers/:id/poll` bekommt die Zehn-Sekunden-Sperre als Schutz davor,
dass ein Klick in der Oberflaeche zu ungebremsten Anfragen gegen die Fremd-API wird
(Bedrohungsmodell T-DHH-06).
`proxmox.module.ts` nimmt den Planer in `providers` auf; `ScheduleModule` ist bereits global
in `app.module.ts` registriert — nichts zusaetzlich einzurichten.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/proxmox src/prisma/rls-access-inventory.spec.ts src/prisma/rls-coverage.spec.ts</automated>
<automated>pnpm --filter @tessera/api test</automated>
<automated>pnpm type-check</automated>
</verify>
<done>
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt.
`rls-access-inventory.spec.ts` und `rls-coverage.spec.ts` sind gruen, einschliesslich des
neuen Erlaubnislisten-Eintrags und der nachgezogenen Dokumentationszeilen.
`proxmox-nur-lesen.spec.ts` bleibt gruen. `pnpm --filter @tessera/api test` gruen mit
mindestens 1240 Tests, `pnpm type-check` 4 von 4.
</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 5: Einstellungsseite — Server anlegen, bearbeiten, loeschen, testen</name>
<files>
apps/api/src/proxmox/proxmox.controller.ts,
apps/api/src/proxmox/proxmox.service.ts,
apps/api/src/proxmox/proxmox.service.spec.ts,
apps/web/src/lib/proxmox-api.ts,
apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx,
apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx,
apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx,
apps/web/src/messages/de.json,
apps/web/src/messages/en.json
</files>
<behavior>
- Bei Typ `pmg` erscheint die Auswahl „API-Token" im Formular gar nicht; nur Benutzer und Passwort sind zu sehen.
- Bei Typ `pve` oder `pbs` und Auswahl „API-Token" erscheinen Token-Kennung und Token-Geheimnis; bei Auswahl „Benutzer/Passwort" stattdessen Benutzer und Passwort.
- Ein gespeichertes Geheimnis wird beim Bearbeiten nie im Klartext angezeigt; das Feld ist leer und ein leer gelassenes Feld laesst das gespeicherte Geheimnis unveraendert.
- Der Schalter fuer die Zertifikatspruefung steht beim Anlegen auf „pruefen" und traegt einen erklaerenden Hinweis, dass die Ausnahme nur fuer diesen einen Server gilt.
- Der Knopf „Verbindung testen" zeigt bei Erfolg eine gruene Bestaetigung und bei Misserfolg den Klartext der Ursache in der Sie-Form.
- Ein Benutzer ohne Verwaltungsrolle sieht die Einstellungsseite nicht, sondern einen Hinweis.
- Loeschen verlangt eine Rueckfrage und entfernt Server samt Zwischenlagerzeile.
</behavior>
<action>
Zuerst `ServerForm.test.tsx` schreiben (rot), Vorbild
`modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx` und
`modules/dkv-fleet/settings/components/InboxConfigForm.tsx`.
Backend: `proxmox.controller.ts` und `proxmox.service.ts` um `PUT servers/:id` und
`DELETE servers/:id` ergaenzen, beide mit `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` und beide
ueber `forTenant()`. Beim Aendern gilt dieselbe Regel wie bei
`LdapConfigService.updateConfig`: ein NICHT gesendetes Geheimnisfeld laesst den gespeicherten
Wert unveraendert, eine LEERE Zeichenkette bedeutet „loeschen" und ein gefuellter Wert wird
neu verschluesselt. Die Ablehnung „PMG mit Token" gilt auch hier. Das Loeschen entfernt die
Zwischenlagerzeile ueber die Fremdschluesselregel mit Loeschweitergabe und zieht anschliessend
den Auftrag des Mandanten nach.
Frontend: `settings/page.tsx` nach dem Muster von
`modules/tender-radar/settings/page.tsx` — Rollenpruefung ausschliesslich zur Anzeige, mit
Ladezustand solange die Rolle unbekannt ist, damit die Verwaltungsteile fuer einen normalen
Benutzer nie kurz aufblitzen; der verbindliche Riegel bleibt serverseitig. Darin die
Serverliste und das Formular `ServerForm.tsx`: Name, Typ (drei Knoepfe oder Auswahl),
Adresse, Zugangsart, die typabhaengigen Zugangsfelder, Abfrageintervall, Schalter fuer die
Zertifikatspruefung, aktiv/inaktiv. Der Knopf „Verbindung testen" ruft
`POST servers/:id/test` und zeigt das Ergebnis direkt beim Formular. Die Uebersetzung der
sieben Fehlerschluessel liegt im Frontend unter `proxmox.errors.*` — deutsche Texte in der
Sie-Form (D-10), englische Entsprechungen in `en.json`; die Texte nennen die Ursache und den
naechsten Schritt, ohne Fachbegriffe (Beispielform fuer `zugang`: „Der Zugang wurde
abgelehnt. Bitte pruefen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.").
`proxmox-api.ts` bekommt `createServer`, `updateServer`, `deleteServer`, `testServer`,
`pollServer`.
Biome-Warnungen in `apps/web` muessen danach exakt 53 bleiben — neue Formulareingaben brauchen
daher von Anfang an die im Bestand ueblichen Beschriftungsbezuege und Tastaturbedienbarkeit.
</action>
<verify>
<automated>pnpm --filter @tessera/web exec vitest run proxmox</automated>
<automated>pnpm --filter @tessera/api test</automated>
<automated>pnpm --filter @tessera/web test</automated>
<automated>pnpm lint</automated>
<automated>pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -c 'Found 53 warnings'</automated>
</verify>
<done>
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt.
`pnpm --filter @tessera/web test` gruen mit mindestens 693 Tests,
`pnpm --filter @tessera/api test` gruen mit mindestens 1240 Tests, `pnpm lint` 5 von 5, und
`biome lint` in `apps/web` meldet unveraendert 53 Warnungen.
</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 6: Modulseite — Serverliste mit Auslastung, Klartext bei Stoerungen</name>
<files>
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/lib/proxmox-api.ts,
apps/web/src/messages/de.json,
apps/web/src/messages/en.json
</files>
<behavior>
- Ohne eingetragenen Server zeigt die Seite einen ruhigen Hinweis mit dem Weg zu den Einstellungen — keine Fehlermeldung, keine leere Flaeche.
- Ein PVE-Server zeigt Knotenzahl, laufende und gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung.
- Ein PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung.
- Ein PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren.
- Ein Messwert, der `null` ist, erscheint als „unbekannt" — nie als `0`, nie als leeres Feld, nie als `NaN`.
- Ein Server mit `reachable: false` zeigt den Klartext seiner Ursache und daneben den Zeitpunkt der letzten erfolgreichen Messung, falls es eine gab.
- Der Zeitpunkt der letzten Abfrage steht bei jedem Server.
- Zaehlerfelder sind ausdruecklich als „gesamt seit Start" beschriftet, nicht als aktueller Durchsatz.
- Der Knopf „Jetzt aktualisieren" loest eine Abfrage aus und laedt danach die Liste neu; waehrend des Laufs ist er gesperrt.
</behavior>
<action>
Zuerst `ServerCard.test.tsx` schreiben (rot) — je Punkt aus `<behavior>` ein Fall, mit
erfundenen Zwischenlagerstaenden je Produkttyp, einschliesslich eines Standes, in dem jeder
Einzelwert `null` ist.
`ServerCard.tsx` ist die Anzeige EINES Servers und verzweigt ueber `productType` auf der
unterscheidbaren Union aus Aufgabe 3. Eine gemeinsame kleine Hilfe stellt jeden Einzelwert
dar: ist er `null` oder `undefined`, erscheint der uebersetzte Text „unbekannt"; sonst der
Wert mit seiner Einheit (Prozentwerte gerundet, Byte-Werte in lesbarer Form). Diese Hilfe ist
die einzige Stelle, die einen Messwert in Text verwandelt — dadurch kann kein Zweig versehentlich
eine `0` anzeigen, wo nichts gemessen wurde. Den Grund als deutschen Kommentar festhalten: die
Feldnamen von PBS und PMG sind bis zur Pruefung am echten Server nur abgeleitet, und ein still
falscher Wert waere schlimmer als ein ehrliches „unbekannt".
Die Zaehlerfelder aus `cluster/resources` sind kumulative Werte seit dem Start eines Gastes,
keine Rate (Recherche, Fallstricke) — die Beschriftung sagt das ausdruecklich, damit der
Nutzer sie nicht als aktuellen Durchsatz liest.
`page.tsx` zeigt die Serverliste, oben den Knopf „Jetzt aktualisieren", und fuer Benutzer mit
Verwaltungsrolle einen Verweis auf die Einstellungsseite. Bei leerer Liste der ruhige Hinweis.
Schlaegt der Listenabruf selbst fehl, erscheint eine einzelne verstaendliche Meldung, nicht
mehrere. Alle Texte ueber `useTranslations('proxmox')` in `de.json` UND `en.json`, deutsch in
der Sie-Form (D-10).
Biome-Warnungen in `apps/web` bleiben exakt 53.
</action>
<verify>
<automated>pnpm --filter @tessera/web exec vitest run proxmox</automated>
<automated>pnpm --filter @tessera/web test</automated>
<automated>pnpm type-check</automated>
<automated>pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -c 'Found 53 warnings'</automated>
</verify>
<human-check>
Im Browser `/modules/infrastructure/proxmox` oeffnen: ohne Server steht dort der ruhige
Hinweis; nach dem Anlegen eines Servers in den Einstellungen erscheint er in der Liste, und
ein absichtlich falsch eingetragener Zugang zeigt Klartext statt einer leeren Flaeche.
</human-check>
<done>
Alle Punkte aus `<behavior>` sind je durch mindestens einen Test belegt.
`pnpm --filter @tessera/web test` gruen mit mindestens 693 Tests, `pnpm type-check` 4 von 4,
`biome lint` in `apps/web` unveraendert 53 Warnungen.
</done>
</task>
<task type="auto">
<name>Aufgabe 7: Dokumentation und Nachmessung aller Tore</name>
<files>
docs/anleitung-entwicklung.md,
docs/anwenderhandbuch.md,
docs/mandantentrennung-zugriffsklassifikation.md
</files>
<action>
`docs/anwenderhandbuch.md` bekommt einen Abschnitt zum Proxmox-Modul in Alltagssprache und in
der Sie-Form (D-10): was das Modul zeigt, wie ein Server in den Einstellungen angelegt wird
(Name, Typ, Adresse, Zugang), welche NUR-LESE-Rolle im jeweiligen Produkt zu vergeben ist
(PVE `PVEAuditor`, PBS `Audit` beziehungsweise `DatastoreAudit`, PMG `Auditor`), dass bei PMG
nur Benutzer und Passwort moeglich sind, wozu der Schalter fuer die Zertifikatspruefung da ist
und dass er nur fuer genau diesen einen Server gilt, was der Knopf „Verbindung testen" sagt
und was „unbekannt" bei einem Messwert bedeutet. Ausdruecklich festhalten: Tessera veraendert
bei Proxmox nichts, es schaut nur zu (D-01).
`docs/anleitung-entwicklung.md` bekommt im Abschnitt „So entsteht ein neues Modul" einen
Hinweis auf `proxmox` als Vorlage fuer ein Modul mit Fremdsystem-Zugaengen und
Hintergrundabfrage, und an geeigneter Stelle den Merksatz zur `undici`-Falle (globales `fetch`
ignoriert einen Dispatcher aus dem npm-Paket), falls er dort noch nicht steht.
`docs/mandantentrennung-zugriffsklassifikation.md` abschliessend nachziehen: den neuen Bereich
`proxmox` als eigene Zeile in der Bereichsuebersicht und die Summenzeile — beides mit der
Gate-Schleife NACHGEMESSEN, nicht abgeschrieben, und mit dem Auftragskuerzel `260923-dhh`
versehen wie die bestehenden Eintraege.
Danach alle Tore einmal vollstaendig durchlaufen und die Endzahlen in der Zusammenfassung
gegen die Ausgangswerte aus dem `<objective>` stellen: api-Tests, web-Tests, type-check,
lint, Biome-Warnungen in `apps/web`. Eine Verschlechterung an irgendeinem Tor ist ein
Abbruchgrund, keine Randnotiz.
</action>
<verify>
<automated>pnpm --filter @tessera/api test</automated>
<automated>pnpm --filter @tessera/web test</automated>
<automated>pnpm type-check</automated>
<automated>pnpm lint</automated>
<automated>pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -c 'Found 53 warnings'</automated>
</verify>
<done>
Anwenderhandbuch und Entwicklungsanleitung beschreiben das Modul; die Klassifikationstabelle
ist nachgemessen und `rls-access-inventory.spec.ts` gruen. Endzahlen dokumentiert:
api-Tests gruen und mindestens 1240, web-Tests gruen und mindestens 693, type-check 4 von 4,
lint 5 von 5, Biome-Warnungen in `apps/web` exakt 53.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> Tessera-API | Der Administrator sendet Serveradressen und Zugangsdaten; jeder Benutzer mit Modulfreigabe liest die Serverliste |
| Tessera-API -> Proxmox (PVE/PBS/PMG) | Ausgehende Verbindung in das interne Netz mit einem Geheimnis im Gepaeck; Gegenstelle ist nicht von Tessera kontrolliert |
| Tessera-API -> PostgreSQL | Verschluesselte Zugangsdaten und Messwerte; Mandantentrennung ueber RLS |
| Mandant A -> Mandant B | Zwei Mandanten duerfen die Proxmox-Zugaenge des jeweils anderen nie sehen |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-DHH-01 | Information Disclosure | Zugangsdaten in Antwort, Protokoll und Fehlermeldung | critical | mitigate | Aufgabe 1: `listWithStatus` waehlt `encryptedTokenSecret`/`encryptedPassword` per `select` gar nicht erst aus (nicht nachtraeglich maskiert). Aufgabe 2: `proxmox-auth.ts` protokolliert nie, `errorDetail` traegt nur Statuszahl und Fehlerkennung, `rawSample` ist auf 20 000 Zeichen gekuerzt und enthaelt nur Antwortdaten, nie die gesendete Kopfzeile. Test in Aufgabe 1/2: keine Geheimnisform in Antwort und Meldung |
| T-DHH-02 | Spoofing / SSRF | Vom Administrator eingetragene Adresse | high | mitigate | Nur ADMIN/SUPER_ADMIN duerfen Adressen eintragen (`@Roles` auf allen Schreibwegen, Aufgabe 1/5) — damit ist jede Adresse eine bewusste Freigabe (D-02). Adressform per `@IsUrl` auf `http`/`https` begrenzt. BEWUSST KEINE Privat-IP-Sperre wie `isPublicHttpUrl`: Proxmox steht per Definition im privaten Netz, eine solche Sperre wuerde das Modul unbrauchbar machen; die Begruendung steht als Kommentar in `proxmox-client.service.ts`. Abbruch nach 8 Sekunden begrenzt den Missbrauch als Portscanner |
| T-DHH-03 | Information Disclosure | Zertifikats-Ausnahme reicht weiter als gewollt | high | mitigate | Aufgabe 1: Dispatcher wird JE AUFRUF aus dem Feld `tlsRejectUnauthorized` genau dieser Serverzeile gebaut; Voreinstellung `true`. Keine Modulkonstante, keine Node-Umgebungsvariable. Test: bei `true` wird kein Dispatcher uebergeben, bei `false` genau einer mit abgeschalteter Pruefung — und die Ausnahme eines Servers wirkt nicht auf einen zweiten |
| T-DHH-04 | Elevation of Privilege | Fremder Mandant liest Proxmox-Zugaenge | critical | mitigate | Aufgabe 1: `tenantId` auf beiden Tabellen, `tenant_isolation_policy` in der Migration, jeder Zugriff ueber `forTenant()`. Aufgabe 4: der einzige `forSystem()`-Aufruf ist der Startpfad des Planers, in `FORSYSTEM_ALLOWED_CALL_SITES` eingetragen und rein lesend; geschrieben wird je Zeile gebunden. Gates: `rls-coverage.spec.ts`, `rls-access-inventory.spec.ts` |
| T-DHH-05 | Elevation of Privilege | Rechteausweitung ueber das Modul | high | mitigate | Aufgabe 1: `@UseModule('proxmox')` auf Klassenebene (Aktivierung UND Freigabe, D-09), zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` auf jedem Schreibweg. `tenantId` und Rolle kommen ausschliesslich aus dem geprueften Sitzungsnachweis, nie aus Body oder Query. Die Rollenpruefung im Frontend (Aufgabe 5) ist reine Anzeige und ersetzt nichts |
| T-DHH-06 | Denial of Service | Ungebremster Nutzer-Auslöser gegen die Fremd-API | medium | mitigate | Aufgabe 4: `POST servers/:id/poll` sperrt einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert stattdessen den Zwischenlagerstand. Regulaer fragt ausschliesslich der Planer mit begrenzter Frequenz ab; jede Anzeige liest aus dem Zwischenlager (D-05). Aufgabe 3: Deckel von zehn Folgeabfragen je PBS-Durchlauf |
| T-DHH-07 | Tampering | Ein veraendernder Weg gegen Proxmox entsteht (heute oder spaeter) | high | mitigate | Aufgabe 1: nur eine Datenabruf-Funktion `proxmoxGet`, Verfahren fest verdrahtet. Aufgabe 2: `proxmox-nur-lesen.spec.ts` zaehlt maschinell nach, dass die einzige nicht-lesende Anfrage die Ticket-Anmeldung ist, mit benannter Erwartungszahl und ausgeschriebener Begruendung — eine spaetere Erhoehung erzwingt eine bewusste Entscheidung (D-01) |
| T-DHH-08 | Tampering | Zwischenlager zeigt still einen Falschwert | medium | mitigate | Aufgabe 3: jeder Einzelwert wird nachsichtig gelesen und ist bei fehlendem oder unbrauchbarem Feld `null`; Aufgabe 6: `null` erscheint als „unbekannt", nie als `0`. Die gekuerzte Rohantwort bleibt erhalten, damit der Nutzer am echten Server erkennt, wie ein Feld wirklich heisst |
| T-DHH-SC | Tampering | Paketinstallationen | low | accept | Dieser Auftrag installiert kein einziges Paket (D-07) — `undici` ist bereits direkte Abhaengigkeit von `apps/api`. Die Paket-Pruefliste der Recherche weist den Punkt ausdruecklich als nicht anwendbar aus. Entsteht wider Erwarten doch eine Installation, greift die Paket-Pruefung vor dem Einbau |
</threat_model>
<source_audit>
## Mehrfachquellen-Abdeckung
**GOAL** (Auftragsbeschreibung)
| Punkt | Status | Abgedeckt durch |
|---|---|---|
| PVE, PBS und PMG anbinden | COVERED | Aufgabe 1 (PVE), Aufgabe 3 (PBS, PMG) |
| Nur beobachten | COVERED | Aufgabe 1 (`proxmoxGet`), Aufgabe 2 (`proxmox-nur-lesen.spec.ts`) |
| Server in den Einstellungen anlegen (Adresse + Zugang) | COVERED | Aufgabe 1 (Anlegen), Aufgabe 5 (Oberflaeche, Bearbeiten, Loeschen) |
| Zugang Token oder Benutzer/Passwort, verschluesselt | COVERED | Aufgabe 1 (Token), Aufgabe 2 (Benutzer/Passwort), beide ueber `CryptoService` |
| Abfrage im Hintergrund mit Zwischenlager | COVERED | Aufgabe 1 (Zwischenlagertabelle), Aufgabe 4 (Planer) |
| Modulseite mit Serverliste und Auslastung | COVERED | Aufgabe 1 (duenne Liste), Aufgabe 6 (Auslastung je Produkt) |
**RESEARCH** (`260923-dhh-RESEARCH.md`)
| Punkt | Status | Abgedeckt durch |
|---|---|---|
| Token-Kopfzeilen je Produkt, PMG ohne Token (A1) | COVERED | Aufgabe 1 und 2 (`proxmox-auth.ts`), Aufgabe 5 (Formular bietet es bei PMG nicht an) |
| Ticket-Anmeldung, Cookie-Namen je Produkt (A2) | COVERED | Aufgabe 2, Cookie-Namen als EINE benannte Konstante mit Annahme-Kommentar |
| Kein CSRF noetig, weil nur gelesen wird | COVERED | Aufgabe 2 (`<behavior>`) |
| `cluster/resources` als eine Abfrage fuer PVE | COVERED | Aufgabe 1 und 3 |
| PBS-Belegung und Snapshot-Felder (A3) | COVERED | Aufgabe 3, Feldnamen als EINE benannte Konstante |
| PMG-Tageszahlen (A5, keine Quarantaene) | COVERED | Aufgabe 3; Quarantaene bleibt ausserhalb des Umfangs |
| Fehlerverhalten 401 breiter als ueblich (A4) | COVERED | Aufgabe 2 (`classifyFailure`) |
| undici-Dispatcher-Falle unter Node 24 | COVERED | Aufgabe 1 (Kommentar und Test), Aufgabe 7 (Anleitung) |
| Pro Zeile umschaltbarer Zertifikats-Bypass | COVERED | Aufgabe 1, T-DHH-03 |
| `onApplicationBootstrap` statt `onModuleInit` | COVERED | Aufgabe 4 |
| Mandanten-Auffaechern je Cron-Auftrag | COVERED | Aufgabe 4 |
| Zwischenlager statt Live-Abfrage | COVERED | Aufgabe 1, 4, 6 |
| RLS-Migration, Klassifikationsdoku, Erlaubnisliste | COVERED | Aufgabe 1 (Migration, Doku), Aufgabe 4 (Erlaubnisliste), Aufgabe 7 (Nachmessung) |
| Nur-Lese-Rollen je Produkt als Hinweis an den Admin | COVERED | Aufgabe 7 (Anwenderhandbuch), `user_setup` im Frontmatter |
| Zaehler sind kumulativ, keine Rate | COVERED | Aufgabe 6 (Beschriftung) |
| Ticket-Erneuerung bei 401 | COVERED | Aufgabe 2 |
| Keine neue npm-Abhaengigkeit | COVERED | Aufgabe 1 (D-07), Paket-Pruefliste nicht anwendbar |
**CONTEXT** (getroffene Entscheidungen D-01 bis D-11)
| ID | Status | Abgedeckt durch |
|---|---|---|
| D-01 | COVERED | Aufgabe 1 (`proxmoxGet`), Aufgabe 2 (`proxmox-nur-lesen.spec.ts`), `must_haves.truths`, T-DHH-07 |
| D-02 | COVERED | Aufgabe 1 (Verschluesselung, `select` ohne Geheimnisse), Aufgabe 5 (Formular), T-DHH-01 |
| D-03 | COVERED | Aufgabe 1 und 2 (`proxmox-auth.ts` als einzige Stelle), Aufgabe 5 (Formular ohne Token bei PMG) |
| D-04 | COVERED | Aufgabe 1 (Dispatcher je Aufruf), Aufgabe 5 (Schalter), T-DHH-03 |
| D-05 | COVERED | Aufgabe 1 (Zwischenlager), Aufgabe 4 (Planer nach TENDER-Muster), Aufgabe 6 (Seite liest nur den Cache) |
| D-06 | COVERED | Aufgabe 2 (Fehlerklassen), Aufgabe 4 (Testendpunkt), Aufgabe 5 (Knopf und Klartext) |
| D-07 | COVERED | Aufgabe 1 (nur `undici`), Paket-Pruefliste nicht anwendbar |
| D-08 | COVERED | Aufgabe 1 (Migration, Doku), Aufgabe 4 (Erlaubnisliste, Standwechsel), Aufgabe 7 (Nachmessung), T-DHH-04 |
| D-09 | COVERED | Aufgabe 1 (`@UseModule`, `ModuleAccessGate`), T-DHH-05 |
| D-10 | COVERED | Aufgaben 1, 5, 6 (next-intl, Sie-Form), 7 (Anwenderhandbuch) |
| D-11 | COVERED (als Ausschluss) | `<objective>`, Abschnitt „Ausdruecklich NICHT im Umfang" |
**Keine Luecke.** Nicht abgedeckt sind ausschliesslich die vom Auftrag ausgeschlossenen Punkte
(Dashboard-Kachel, `/rrddata`, Eingriffe, PMG-Quarantaene).
</source_audit>
<verification>
Nach jeder Aufgabe (je Commit):
- `pnpm --filter @tessera/api test` — gruen, mindestens 1240 Tests
- `pnpm --filter @tessera/web test` — gruen, mindestens 693 Tests
- `pnpm type-check` — 4 von 4 erfolgreich
- `pnpm lint` — 5 von 5 erfolgreich
- `pnpm --filter @tessera/web exec biome lint .` — exakt 53 Warnungen
Zusaetzlich nach den Aufgaben 1 und 4:
- `pnpm --filter @tessera/api exec vitest run src/prisma/rls-coverage.spec.ts src/prisma/rls-access-inventory.spec.ts` — gruen
Ab Aufgabe 2 dauerhaft:
- `pnpm --filter @tessera/api exec vitest run src/proxmox/proxmox-nur-lesen.spec.ts` — gruen
**Was diese Tore NICHT beweisen:** die Feldnamen von PBS und PMG (Annahmen A2, A3, A5 der
Recherche). Es gibt hier keinen echten PVE-/PBS-/PMG-Server; alle Tests laufen gegen erfundene
Antworten in der dokumentierten Form. Der Nutzer prueft das Modul selbst auf `alpha` gegen
seine echten Server. Genau dafuer sind die Feldnamen je Produkt als EINE benannte Konstante
gebaut und bleibt die gekuerzte Rohantwort im Zwischenlager erhalten: weicht die Wirklichkeit
ab, ist eine einzige Stelle nachzuziehen und der Nutzer sieht in der Oberflaeche „unbekannt"
statt eines Absturzes.
</verification>
<success_criteria>
1. Kein Weg im gesamten Modul veraendert etwas bei Proxmox; der maschinelle Riegel
`proxmox-nur-lesen.spec.ts` weist nach, dass die einzige nicht-lesende Anfrage die
Ticket-Anmeldung ist.
2. Ein Administrator legt in den Moduleinstellungen Server aller drei Typen an; bei PMG wird
die Token-Auswahl gar nicht erst angeboten und serverseitig abgelehnt.
3. Zugangsdaten stehen verschluesselt in der Datenbank und verlassen sie auf keinem Weg im
Klartext — auch nicht in Fehlermeldungen, Protokollen oder der Rohprobe.
4. Die Zertifikats-Ausnahme gilt nur fuer die Server, bei denen sie einzeln eingeschaltet
wurde; Voreinstellung ist pruefen.
5. Der Hintergrunddienst haengt an `onApplicationBootstrap`, faechert je Mandant auf und
ueberschreibt den Auftrag eines zweiten Mandanten nicht.
6. Die Modulseite liest ausschliesslich aus dem Zwischenlager, zeigt fehlende Werte als
„unbekannt" und ohne Server einen ruhigen Hinweis.
7. Der Knopf „Verbindung testen" nennt die Ursache in Alltagssprache.
8. Beide neuen Tabellen tragen `tenantId` mit RLS-Policy; `rls-coverage.spec.ts` und
`rls-access-inventory.spec.ts` sind gruen, die Klassifikationsdoku ist nachgemessen.
9. Alle Tore mindestens auf Ausgangswert: api-Tests ab 1240, web-Tests ab 693, type-check 4/4,
lint 5/5, Biome-Warnungen in `apps/web` exakt 53.
10. Anwenderhandbuch und Entwicklungsanleitung beschreiben das Modul, einschliesslich der
NUR-LESE-Rolle je Produkt.
</success_criteria>
<output>
Nach Abschluss `.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-SUMMARY.md`
schreiben — mit den gemessenen Endzahlen aller Tore neben den Ausgangswerten und einer
ausdruecklichen Liste der Stellen, die der Nutzer beim Test an seinen echten Servern
moeglicherweise nachziehen muss (Cookie-Namen je Produkt, Feldnamen je Produkt).
</output>
@@ -0,0 +1,358 @@
# Quick-Aufgabe 260923-dhh: Proxmox-Modul (PVE/PBS/PMG) — Research
**Researched:** 2026-09-23
**Domain:** Proxmox VE/PBS/PMG REST-API (nur lesend), NestJS-Hintergrunddienst mit Zertifikatsausnahme, Mandantentrennung (Prisma/RLS), Modul-/Kachel-Registrierung im Bestand
**Confidence:** MEDIUM — Proxmox-API-Formen (Auth-Header, `cluster/resources`, PBS-Datastore, PMG-Statistik) sind aus offizieller Doku UND Foren-Diskussion zusammengetragen (offizielle API-Viewer sind reine JS-Apps und liefern beim Abruf keinen Text); Bestandsmuster (Verschlüsselung, Scheduler, RLS, Modul-Registrierung) sind HIGH, weil aus tatsächlich gelesenem Code dieses Repos zitiert.
## Summary
Das Proxmox-Modul ist reine Beobachtung (kein Schreibzugriff) auf bis zu drei Produkttypen — PVE, PBS, PMG —, die derselbe Mandant in beliebiger Zahl in den Einstellungen einträgt (Adresse + Zugang, wahlweise API-Token oder Benutzer/Passwort). Alle drei Produkte teilen dieselbe API-Familie (REST, `/api2/json/...`), aber mit produktspezifischem Token-Präfix (`PVEAPIToken`/`PBSAPIToken`) — PMG hat laut aktueller Foren- und Roadmap-Lage **keine** API-Token-Unterstützung, nur Ticket-Login, weshalb der Zugang für PMG-Server ausschließlich Benutzer/Passwort sein kann (Konsequenz für die Einstellungs-UI: das Token-Feld ist bei Typ „PMG" auszublenden). Für reine Leseabfragen ist ein CSRF-Token nie nötig — weder bei Token- noch bei Ticket-Auth —, weil CSRF nur GET-fremde Schreiboperationen betrifft; das vereinfacht die Ticket-Variante erheblich (Cookie genügt).
Der Bestand liefert für jeden Baustein bereits ein direktes Vorbild: `CalendarSource` ist die richtige Schema-Vorlage (mehrere verschlüsselte Fremdsystem-Zugänge pro Mandant, nicht ein Singleton wie `DkvModuleConfig`); `CryptoService`/`LdapConfig.tlsRejectUnauthorized` zeigen sowohl die Verschlüsselung als auch den **admin-gesteuerten, pro Zeile umschaltbaren** Zertifikats-Bypass — das ist die bessere Vorlage als die pauschale, immer-an-Ausnahme in `icon-discovery.service.ts`, weil hier echte Zugangsdaten über die Leitung gehen, nicht nur ein Favicon; und `TenderSchedulerService` (kombiniert mit `DkvSchedulerService`) zeigt exakt das Timing-Problem, das ein neuer Hintergrunddienst vermeiden muss: `onModuleInit`-Reihenfolge ist zwischen NestJS-Modulen nicht garantiert, `onApplicationBootstrap` läuft dagegen nachweislich nach jedem `onModuleInit` und ist deshalb für einen Proxmox-Planer, der die Modul-Seed-Daten voraussetzt, die richtige Lebenszyklus-Stufe — nicht die von `DkvSchedulerService` tatsächlich verwendete `onModuleInit`.
Für die Frage „live abfragen oder zwischenlagern" gibt der Bestand eine eindeutige Antwort: sowohl DKV (`DkvInvoiceHistory`) als auch Tender-Radar (`Tender`) schreiben Hintergrund-Polling-Ergebnisse in eine eigene Tabelle und die Seite liest ausschließlich daraus — kein Modul in diesem Projekt holt Fremddaten live bei Seitenaufruf. Für Proxmox ist das erst recht richtig: ein Dashboard-Widget, das bei jedem Öffnen drei bis N Server live abfragt, wäre spürbar langsam und bei nicht erreichbarem Server sogar blockierend. Empfehlung: ein Cron-Auftrag pro Mandant (DKV-Muster) mit `onApplicationBootstrap`-Timing (Tender-Muster) schreibt die zuletzt gemessenen Werte (Knoten/VM/Container-Zustand, PBS-Datastore-Belegung + letzter Backup-/Verify-Lauf, PMG-Tageszahlen) in eine Zwischenlagertabelle je Server; das Dashboard und die Modulseite lesen ausschließlich diese Tabelle.
**Primary recommendation:** `ProxmoxServer`-Modell nach `CalendarSource`-Vorbild (mehrere Zeilen je Mandant, `encryptedTokenSecret`/`encryptedPassword` über `CryptoService`, `tlsRejectUnauthorized Boolean @default(true)` pro Zeile); ein `ProxmoxSchedulerService` nach `DkvSchedulerService`-Vorbild (ein Cron-Auftrag je Mandant) aber mit `OnApplicationBootstrap` statt `OnModuleInit`; ein `ProxmoxSnapshot`/`ProxmoxServerStatus`-Cache-Modell, das der Planer beschreibt und Widget/Modulseite lesen; für Zertifikatsausnahmen ein pro Aufruf gebauter `undici.Agent({ connect: { rejectUnauthorized: false } })`, **nur** wenn `tlsRejectUnauthorized === false` auf genau diesem Server steht — kein modulweiter, kein globaler Bypass.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Proxmox-Server-Verwaltung (CRUD Adresse+Zugang) | API / Backend | Frontend Server (Formulare) | Verschlüsselung und RLS-Bindung müssen serverseitig passieren, wie bei `LdapConfig`/`CalendarSource` |
| Periodische Abfrage PVE/PBS/PMG | API / Backend (Hintergrunddienst) | — | Kein Nutzer-Trigger; Cron-Auftrag wie DKV/Tender, kein Browser-Bezug |
| Zwischenlagerung der Messwerte | Database / Storage | API / Backend (Schreiber) | Dashboard-Geschwindigkeit verlangt Cache-Tabelle statt Live-Fetch (siehe Summary) |
| Dashboard-Kachel „Proxmox" | Browser (Rendering) | API / Backend (liefert Cache-Daten) | Folgt dem in `docs/anleitung-entwicklung.md` beschriebenen Drei-Stellen-Muster |
| Modulseite (Server-Übersicht, Details) | Frontend Server (SSR-Gate) | API / Backend | `ModuleAccessGate` + eigenes `layout.tsx`, wie bei den vier bestehenden fest verdrahteten Modulverzeichnissen |
| Zugriffskontrolle auf Proxmox-Endpunkte | API / Backend | — | `@UseModule('proxmox')` auf dem Controller, unabhängig vom Frontend-Gate |
| TLS-Ausnahme für selbstsigniertes Zertifikat | API / Backend (pro Aufruf) | — | Muss am Ort des Fetch-Aufrufs entschieden werden, nicht global (Prozessumgebung bleibt streng) |
## 1. Proxmox-API konkret
### Anmeldung — API-Token
Alle drei Produkte senden den Token im `Authorization`-Header, aber mit unterschiedlichem Schema-Namen und leicht unterschiedlicher Werteform:
| Produkt | Header-Form | Quelle |
|---|---|---|
| PVE | `Authorization: PVEAPIToken=USER@REALM!TOKENID=SECRET` (ein `=` vor dem Secret) | `[CITED: pve.proxmox.com/pve-docs/pveum-plain.html]` |
| PBS | `Authorization: PBSAPIToken=USER@REALM!TOKENID:SECRET` (ein `:` vor dem Secret — **anderes Trennzeichen als PVE**) | `[CITED: pbs.proxmox.com/docs/user-management.html]` |
| PMG | **kein Token-Schema.** Foren-Aussage (proxmox.com-Forum, 2024/2025): „PMG doesn't have API tokens, only Tickets." Kein Gegenbeleg in der aktuellen `pmg-admin-guide` gefunden. | `[CITED: forum.proxmox.com/threads/why-are-there-no-api-tokens.156802]` — Forenaussage, nicht offizielle Referenzdoku; als `[ASSUMED]` in die Planung übernehmen und vor dem Bau am echten PMG-Server verifizieren (`checkpoint:human-verify`) |
**Konsequenz für die Einstellungs-UI:** Server-Typ „PMG" darf die Auswahl „API-Token" nicht anbieten (oder muss sie beim Speichern ablehnen) — sonst legt der Admin einen Zugang an, der nie funktioniert.
### Anmeldung — Ticket (Benutzer/Passwort)
Identischer Mechanismus für alle drei Produkte (PMG: „funktioniert exakt wie bei PVE, PVE durch PMG ersetzen", Foren-Zitat):
```
POST /api2/json/access/ticket
Body: username=<user>@<realm>&password=<pw>
```
Antwort (JSON, `data`-Objekt): `ticket` (signierter Wert, Form `PVE:user@realm:...`), `CSRFPreventionToken`, `username`. `[CITED: pve.proxmox.com/wiki/Proxmox_VE_API]`
Folgeanfragen senden das Ticket als Cookie: `Cookie: PVEAuthCookie=<ticket>` (bei PBS/PMG vermutlich `PBSAuthCookie`/`PMGAuthCookie` — **nicht in der Doku bestätigt gefunden, `[ASSUMED]`**, vor Bau verifizieren). Ticket-Lebensdauer 2 Stunden bei PVE `[CITED: pve.proxmox.com/wiki/Proxmox_VE_API]`; ein Forumsbeitrag nennt abweichend 40 Sekunden für den kurzlebigen VNC-Ticket-Typ — **nicht derselbe Tickettyp**, für den hier verwendeten Auth-Ticket gilt die 2-Stunden-Angabe aus der offiziellen Wiki-Seite.
**CSRF — die zentrale Vereinfachung für dieses Modul:** `CSRFPreventionToken` ist laut offizieller Doku **nur für schreibende Anfragen (POST/PUT/DELETE)** nötig; „GET requests do not require this token" `[CITED: pve.proxmox.com/wiki/Proxmox_VE_API]`. Da dieses Modul ausschließlich liest (Auftrag: „NUR BEOBACHTEN"), entfällt die CSRF-Handhabung vollständig — auch bei Ticket-Auth genügt das Cookie. Bei Token-Auth ist CSRF ohnehin nie nötig, für keine Methode `[CITED: gleiche Quelle]`.
### PVE: Knoten/VMs/Container in einer Abfrage
`GET /api2/json/cluster/resources` liefert **alle** Objekttypen (`vm`, `node`, `storage`, weitere) in einer einzigen Anfrage, optional gefiltert per `?type=vm`. Für VM/Container-Zeilen kommen laut mehreren Forenbelegen die Felder `cpu`, `maxcpu`, `mem`, `maxmem`, `disk`, `maxdisk`, `netin`, `netout`, `diskread`, `diskwrite`, `node`, `vmid`, `status`, `uptime`, `type` zurück; für Storage-Zeilen `content`, `disk`, `maxdisk`, `node`, `plugintype`, `shared`, `status`, `storage`, `type`. `[CITED: mehrere forum.proxmox.com-Threads, keine Feldliste in der offiziellen API-Referenz gefunden — API-Viewer ist eine reine Vue-App und liefert per Abruf keinen Text]`
Gegenüber `/nodes/{node}/qemu` + `/nodes/{node}/lxc` (je Knoten zwei Aufrufe) ist `cluster/resources` der klare Gewinner für ein Übersichts-Dashboard: **eine** Anfrage liefert Knoten, VMs, Container und Storage über den gesamten (Multi-Node-)Cluster hinweg. Für Detailansichten einer einzelnen VM (z. B. Konfiguration) bleibt der gezielte `/nodes/{node}/qemu/{vmid}/...`-Pfad nötig — `cluster/resources` liefert nur die Übersichtsfelder, keine volle Konfiguration.
### PBS: Datastores, Backups, Verify
Aus Forenbelegen (keine vollständige Feldliste aus offizieller Referenz erreichbar):
- `GET /api2/json/status/datastore-usage` — Belegung aller Datastores in einer Abfrage (Gesamt/Belegt/Frei). `[CITED: forum.proxmox.com/threads/inquiry-about-the-proxmox-backup-api.166986]`
- `GET /api2/json/admin/datastore/{store}/status` — Status eines einzelnen Datastores.
- `GET /api2/json/admin/datastore/{store}/snapshots` — Liste der Sicherungen; enthält laut Community-Doku ein `verification`/`verify-state`-Feld je Snapshot (Ergebnis der letzten Prüfung) sowie `backup-time`, `size`. **Exakte Feldnamen nicht aus Primärquelle bestätigt — `[ASSUMED]`, vor Bau gegen einen echten PBS-Server oder den API-Viewer im Browser verifizieren.**
### PMG: Tageszahlen
`GET /api2/json/statistics/mail` (optional `starttime`/`endtime`) liefert laut `pmgsh`-Community-Beleg `count`, `count_in`, `count_out`, `spamcount_in`, `spamcount_out`, `viruscount_in`, `viruscount_out`. `[CITED: forum.proxmox.com, Centreon-Plugin-Doku]` Ein Quarantäne-Zähler steht vermutlich unter einem separaten `/quarantine/...`-Pfad — nicht recherchiert, für die erste Fassung ggf. entbehrlich (siehe Fallstricke).
### Nur-Lese-Rollen
| Produkt | Rolle | Beleg |
|---|---|---|
| PVE | `PVEAuditor` — „read only access" | `[CITED: pve.proxmox.com/pve-docs/pveum-plain.html]` |
| PBS | `Audit` (global) bzw. feiner `DatastoreAudit` — „Can view datastore metrics, settings and list content. But is not allowed to read the actual data." | `[CITED: pbs.proxmox.com/docs/user-management.html]` |
| PMG | `Auditor` — „read-only access to the whole configuration, can access logs and view statistics" | `[CITED: mehrere Foren-/Datasheet-Quellen, keine Primärquelle mit exaktem Wortlaut erreicht]` |
Empfehlung an den Admin-Helptext in den Einstellungen: für den API-Token/Benutzer, den Tessera nutzt, jeweils NUR diese Rolle zuweisen — ein Schreibrecht wird von diesem Modul nie gebraucht (deckt sich mit „NUR BEOBACHTEN").
### Fehlerverhalten
- **Falscher Zugang (Token/Passwort falsch):** HTTP 401. PVE-Foren-Belege zeigen 401 auch für andere Auth-Fehlklassen (abgelaufenes Ticket, falsches CSRF-Token) — Proxmox scheint 401 breiter zu verwenden als die übliche REST-Konvention 401=nicht authentifiziert/403=nicht berechtigt. **Nicht aus Primärquelle mit expliziter Statuscode-Tabelle bestätigt — `[ASSUMED]`.** Für die Fehlermeldung im UI heißt das: einen expliziten 403-Sonderfall separat von 401 zu behandeln lohnt sich vermutlich nicht; „Zugang abgelehnt (401)" als eine gemeinsame Meldung ist robuster als eine Unterscheidung, die die API evtl. gar nicht liefert.
- **Abgelaufenes Ticket:** 401, Meldung enthält meist „invalid ticket"/„permission denied" im Klartext-Body — für eine bessere Fehlermeldung lohnt sich das Parsen des `errors`-Feldes der JSON-Antwort.
- **Server nicht erreichbar (falsche Adresse, Netzwerk, Port zu):** **kein HTTP-Status** — der Fetch-Aufruf selbst schlägt fehl (`ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`/DNS-Fehler; bei `undici`/nativem `fetch` als geworfener `TypeError`/`FetchError`, nicht als Response mit Statuscode). Die Proxmox-Serviceklasse muss also zwei getrennte Fehlerpfade behandeln: HTTP-Antwort mit Statuscode ≠ 2xx (Zugang/Berechtigung) versus geworfene Exception ohne Response (Erreichbarkeit) — dieselbe Unterscheidung, die `icon-discovery.service.ts` mit seinem AbortController-Timeout + try/catch bereits trifft (`fetchWithRedirectGuard`, Zeilen 227–271: `catch { return null; }` fängt genau diesen Fall).
## 2. Selbstsignierte Zertifikate
**Vorlage 1 (Mechanik):** `apps/api/src/favorites/icon-discovery.service.ts:33–37` — Node 24s **globales** `fetch` ignoriert einen `Agent`/Dispatcher aus dem `undici`-Paket (andere Klasse als das intern gebündelte undici); nur `undiciFetch(url, { dispatcher })` (expliziter Import aus dem `undici`-Modul) respektiert einen eigenen Dispatcher. Gemessen und im Kommentar dokumentiert:
> „`undiciFetch(url, { dispatcher: new Agent(...) })` -> Status 200; `globalThis.fetch` derselben URL -> DEPTH_ZERO_SELF_SIGNED_CERT." `[VERIFIED: apps/api/src/favorites/icon-discovery.service.ts:33-37]`
`undici` ist bereits direkte Abhängigkeit von `apps/api` — `"undici": "7.28.0"` `[VERIFIED: apps/api/package.json:52]` — **kein neues Paket nötig**.
**Vorlage 2 (Steuerung — besser geeignet als icon-discovery's Immer-an-Ausnahme):** `LdapConfig.tlsRejectUnauthorized Boolean @default(true)` `[VERIFIED: apps/api/prisma/schema.prisma:65-83, Feld "tlsRejectUnauthorized Boolean @default(true)" in Zeile 76]` — ein **pro Zeile umschaltbares** Feld, vom Admin beim Anlegen/Bearbeiten des Zugangs gesetzt, Default „prüfen" (sicherer Default). `ldap.service.ts` baut daraus die Client-Optionen:
> „skip TLS verification" flag (`tlsRejectUnauthorized === false`)" `[VERIFIED: apps/api/src/ldap/ldap.service.ts:168]`
**Für Proxmox kombinieren:** `ProxmoxServer` bekommt dasselbe Feld `tlsRejectUnauthorized Boolean @default(true)`. Der Fetch-Aufruf für genau diesen Server baut **conditional** einen `undici.Agent({ connect: { rejectUnauthorized: false } })` nur wenn diese eine Zeile das Feld auf `false` gesetzt hat — nicht wie in `icon-discovery.service.ts` eine für die ganze Datei geltende Modul-Konstante `LENIENT_TLS_AGENT`, sondern je Aufruf aus dem gelesenen Serverdatensatz konstruiert. Das erfüllt exakt die Vorgabe „ausdrücklich nur für die vom Administrator eingetragenen Adressen, nicht global": kein prozessweiter Bypass, keine `NODE_TLS_REJECT_UNAUTHORIZED`-Umgebungsvariable (dieses Muster ist im Kommentar von `icon-discovery.service.ts` bereits ausdrücklich als verboten markiert, Zeile 30: „insbesondere NICHT ueber die Node-Umgebungsvariable, die mit NODE_TLS_ beginnt" `[VERIFIED: apps/api/src/favorites/icon-discovery.service.ts:30]`).
Standardmäßig Proxmox-Zertifikate akzeptieren zu **verweigern** (Default `true`) ist hier die richtige Entscheidung, anders als bei `icon-discovery.service.ts` (dort werden nur Favicons geholt, keine Zugangsdaten übertragen) — bei Proxmox gehen Token/Passwort über dieselbe Verbindung, ein blindes „immer tolerant" würde einen Site-in-the-Middle-Angriff auf die Zugangsdaten erleichtern.
## 3. Anschlussstellen im Bestand
### Verschlüsselte Zugangsdaten
`CryptoService` (`apps/api/src/crypto/crypto.service.ts`) ist die einzige Verschlüsselungsschicht im Projekt — AES-256-GCM, Schlüssel aus `TESSERA_ENCRYPTION_KEY`, Format `iv:authTag:ciphertext` (hex, `:`-getrennt) `[VERIFIED: apps/api/src/crypto/crypto.service.ts:70-84]`. `LdapConfigService` zeigt das vollständige Muster: verschlüsseln beim Schreiben (`this.crypto.encrypt(dto.bindPassword)`), entschlüsseln zentral in EINER privaten Methode (`decryptBindPassword`), API-Antworten maskieren das Feld ('********') im Controller, nicht im Service `[VERIFIED: apps/api/src/ldap/ldap-config.service.ts:117-133]`. Für Proxmox: `encryptedTokenSecret`/`encryptedPassword` genauso behandeln — zwei Felder, weil Token-Secret und Passwort unterschiedliche Auth-Methoden sind, beide nullable (nur eines pro Zeile gesetzt, je nach gewähltem `authMethod`).
**Migrationsbedarf beachten:** eine Spalte, die vor Verschlüsselung bereits Klartext trug, braucht einen einmaligen Nachzieh-Backfill wie in `ldap-config.service.ts` (`onApplicationBootstrap`, Regex `ENCRYPTED_VALUE_SHAPE` unterscheidet verschlüsselt/Klartext) `[VERIFIED: apps/api/src/ldap/ldap-config.service.ts:39, 66-101]` — für Proxmox als **neues** Feature ab Tag 1 irrelevant (keine Altdaten), nur als Muster relevant, falls später ein Feld umbenannt/neu verschlüsselt wird.
### Hintergrundabfrage je Mandant
**Zwei bestehende Muster, keins davon 1:1 übertragbar — kombinieren:**
`DkvSchedulerService` zeigt das **Mandanten-Fan-out**: EIN Cron-Auftrag *je aktivem Mandant*, Registry-Name `dkv-inbox-poll:<tenantId>`, damit ein zweiter Mandant den ersten nicht verdrängt (behobener Fehler WINDOWS #21) `[VERIFIED: apps/api/src/dkv/dkv-scheduler.service.ts:16-46]`. Proxmox-Server sind aber (anders als DKV) potenziell **mehrere pro Mandant** — der Cron-Tick eines Mandanten muss also intern über dessen `ProxmoxServer`-Zeilen iterieren, nicht 1:1 wie bei DKV (1 Config = 1 Mandant).
`DkvSchedulerService` hängt aber an `OnModuleInit`, nicht `OnApplicationBootstrap` `[VERIFIED: apps/api/src/dkv/dkv-scheduler.service.ts:1, "implements OnModuleInit"]` — **das ist NICHT das empfohlene Muster für einen neuen Dienst**. `TenderSchedulerService` erklärt im Kopfkommentar explizit, warum `OnApplicationBootstrap` die richtige Wahl ist:
> „`onModuleInit` hooks run in an unspecified order relative to one another, so on a FRESH database the scheduler could read the config before it is seeded → see it absent/inactive → never register the ... cron ... → the platform ingests NOTHING until a second restart. `onApplicationBootstrap` runs after EVERY module's `onModuleInit`, so the seed is guaranteed complete before this reads." `[VERIFIED: apps/api/src/tenders/tender-scheduler.service.ts:29-38]`
Dasselbe Risiko gilt für Proxmox: die `Module`-Seed-Zeile (Modulregistrierung) entsteht in `onModuleInit` des Proxmox-Moduls selbst; ein Scheduler, der beim Start die aktiven `ProxmoxServer`-Zeilen lädt, sollte dieses Risiko nicht eingehen, auch wenn hier keine Modul-Seed-Abhängigkeit vorliegt wie bei Tender — sicherer Standard ist trotzdem `OnApplicationBootstrap`, nicht das (mit einer dokumentierten, hier nicht zutreffenden Ausnahme begründete) `OnModuleInit` von DKV. Auch das nutzerseitige Erlebnis „frische Installation, erster Proxmox-Server angelegt, kein Neustart nötig" verlangt denselben `setInterval()`-Nachzieh-Aufruf wie bei DKV/Tender nach jedem Speichern in der Verwaltungsroute — nicht nur beim Boot.
`Tender-Cron Bootstrap`-Erfahrung aus dem Projektgedächtnis bestätigt das Risiko real: „frische Prod-DB ohne Fix ingestiert nichts" — genau das Szenario, das `OnApplicationBootstrap` verhindert.
### Modul-Registrierung
Vollständiges Muster in `docs/anleitung-entwicklung.md`, Abschnitt „So entsteht ein neues Modul", am Beispiel Domaincheck — sechs Backend-Dateien, sechs Frontend-Dateien, siehe Code-Beispiele unten. Zusätzlich als Dashboard-Kachel: `WIDGET_TYPES`/`WIDGET_MODULE_SLUGS` in `packages/shared/src/index.ts` (aktuell leer, `[VERIFIED: packages/shared/src/index.ts:97-121]`) — Proxmox wäre die **erste** Kachel, die `WIDGET_MODULE_SLUGS['proxmox'] = 'proxmox'` tatsächlich befüllt.
### Mandantentrennung
`ProxmoxServer` braucht eine eigene `tenantId`-Spalte (mehrere Server je Mandant, klar `muss-mandantengebunden`, analog `CalendarSource`) — RLS-Migration mit `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` ist **Pflicht**, sonst schlägt `rls-coverage.spec.ts` Test 1 fehl (jedes Modell mit `tenantId` muss RLS haben) `[VERIFIED: apps/api/src/prisma/rls-coverage.spec.ts:102-106]`. Jeder Service-Zugriff muss über `forTenant(this.prisma, tenantId)` laufen (Konvention: lokale Konstante `const tenantPrisma = forTenant(...)`, keine andere Form), sonst schlägt `rls-access-inventory.spec.ts` fehl — UND jede (Datei, Modell)-Fundstelle muss in `docs/mandantentrennung-zugriffsklassifikation.md` als Tabellenzeile eingetragen werden, sonst schlägt derselbe Test ebenfalls fehl (`[VERIFIED: apps/api/src/prisma/rls-access-inventory.spec.ts:718-723]`, Test „jede im Quelltext gefundene (Datei, Modell)-Fundstelle ist im Dokument eingetragen"). Der Scheduler-Startpfad (liest ALLE Mandanten vor dem ersten `forTenant()`-Aufruf) braucht denselben `forSystem()`-Systemkontext wie `DkvSchedulerService`/`TenderSchedulerService` — und muss in `FORSYSTEM_ALLOWED_CALL_SITES` in `rls-access-inventory.spec.ts` eingetragen werden `[VERIFIED: apps/api/src/prisma/rls-access-inventory.spec.ts:169-175]`, sonst schlägt der Wachhund-Test „ein Anfrageweg darf den Systemkontext nie rufen" fehl.
**Diese drei Testdateien sind harte Gates, keine Empfehlung** — ein Plan, der `ProxmoxServer`/`ProxmoxSnapshot` einführt, MUSS die Migration, die Klassifikationstabelle UND die Erlaubnisliste in derselben Aufgabe pflegen, sonst ist `pnpm --filter @tessera/api test` rot.
### Zwischenlagerung vs. Live-Abfrage
Siehe Summary — DKV (`DkvInvoiceHistory` `[VERIFIED: apps/api/prisma/schema.prisma:384-397]`) und Tender (`Tender` `[VERIFIED: apps/api/prisma/schema.prisma:435-480]`) schreiben beide Hintergrund-Polling-Resultate in eine eigene Tabelle; keine Seite in diesem Projekt holt Fremddaten live beim Rendern. Für Proxmox: ein `ProxmoxServerStatus`-Modell (1:1 oder 1:n je `ProxmoxServer`, mit `lastPolledAt`, `lastError`, und je nach Servertyp unterschiedlichen JSONB-Feldern für die Messwerte — PVE-Knoten/VM-Liste, PBS-Datastore-Liste, PMG-Tageszahlen) wird vom Scheduler beschrieben, Widget und Modulseite lesen ausschließlich daraus. Ein „Jetzt aktualisieren"-Knopf auf der Modulseite kann optional einen sofortigen Einzel-Poll auslösen (Vorbild: `DkvController` ruft nach Config-Speicherung `schedulerService.setInterval()` — derselbe Sofort-Trigger-Gedanke), sollte aber NICHT das Dashboard-Widget selbst live abfragen lassen.
## 4. Fallstricke
**Antwortgröße bei vielen VMs:** `cluster/resources` liefert bei einem größeren Cluster (zweistellige VM-Zahl je Knoten) potenziell hunderte Zeilen in einer JSON-Antwort — für die Zwischenlagertabelle unproblematisch (einmal je Poll-Intervall), aber falls die Modulseite später live filtert/sortiert, sollte serverseitig nicht bei jedem Klick neu gegen Proxmox gefragt werden, sondern gegen den Cache.
**`/rrddata` für die erste Fassung: NEIN.** RRD-Zeitreihen (Verlaufsgraphen über Zeit) sind ein separates, aufwändigeres API-Segment (mehrere Zeitraster: hour/day/week/month/year, je Objekt ein eigener Aufruf) und für eine reine Beobachtungs-Übersicht („Zustand jetzt") nicht nötig — erst relevant, wenn später Verlaufsgraphen gewünscht werden.
**Zähler sind Bytes/Ereignisse seit Start, nicht Bytes/Sekunde:** `netin`/`netout`/`diskread`/`diskwrite` in `cluster/resources` sind als COUNTER-Datenquellen definiert — kumulative Werte seit VM-Start, keine Rate `[CITED: mehrere Foren-Quellen, RRD-Datenquellen-Liste]`. Ein UI, das „aktueller Netzwerkdurchsatz" anzeigen will, muss selbst zwei aufeinanderfolgende Messungen differenzieren (Δ Wert / Δ Zeit) — eine einzelne Momentaufnahme zeigt nur „seit wann läuft die VM, wie viel kam insgesamt rein", was für eine erste Fassung ohnehin ausreicht, aber in der UI klar beschriftet werden sollte („gesamt seit Start", nicht „aktuell").
**PMG-API-Token-Lücke ist ein echtes Bau-Risiko:** wenn der Admin für einen PMG-Server versehentlich „API-Token" wählt (falls die UI das nicht verhindert), scheitert jede Anfrage mit einer für den Nutzer unverständlichen Fehlermeldung. Muss in der Einstellungs-UI hart verhindert werden (Auswahl abhängig vom Servertyp), nicht nur dokumentiert.
**Node 24 + `undici`-Dispatcher — dieselbe Falle wie in `icon-discovery.service.ts` dokumentiert:** wer aus Gewohnheit `fetch(...)` (globales, natives Fetch) statt `import { fetch as undiciFetch } from 'undici'` verwendet, bekommt bei einem `Agent`-Dispatcher **keinen Fehler beim Kompilieren**, sondern eine zur Laufzeit ignorierte Option — das selbstsignierte Zertifikat eines Proxmox-Testservers wird dann trotz `tlsRejectUnauthorized: false` weiterhin abgelehnt, was beim ersten Test verwirrend aussieht, als sei die Datenbank-Einstellung falsch gelesen worden.
**CSRF-Falle vermieden, nicht vergessen:** weil dieses Modul nur liest, entfällt CSRF komplett (siehe Block 1) — ein künftiger Ausbau mit Schreibzugriffen (nicht Teil dieses Auftrags) müsste CSRF bei Ticket-Auth nachrüsten; das jetzt schon vorzusehen wäre verfrühte Komplexität.
**Ticket-Lebensdauer 2 h bei Cron-Intervallen < 2 h kein Problem, aber Neu-Login-Logik nicht vergessen:** bei Benutzer/Passwort-Zugang muss der Scheduler bei 401 einmal automatisch neu einloggen (neues Ticket holen) und den Poll wiederholen, bevor er den Server als „nicht erreichbar" markiert — sonst erzeugt ein normaler Ticket-Ablauf alle zwei Stunden einen falschen Fehlalarm.
## Standard Stack
Keine neuen npm-Pakete. Alles Nötige ist bereits installiert:
| Baustein | Bereits vorhanden | Verwendung für Proxmox |
|---|---|---|
| `undici` 7.28.0 | `[VERIFIED: apps/api/package.json:52]` | `undiciFetch` mit bedingtem Dispatcher, Vorbild `icon-discovery.service.ts` |
| `@nestjs/schedule` (Cron) | bereits Basis von `DkvSchedulerService`/`TenderSchedulerService` | `ProxmoxSchedulerService` |
| `class-validator`/`class-transformer` | bereits DTO-Standard im Projekt (`CheckDomainDto`, `CreateLdapConfigDto`, ...) | DTOs für Server-Anlegen/-Bearbeiten |
| `CryptoService` (projekteigen) | `apps/api/src/crypto/crypto.service.ts` | Token-Secret/Passwort-Verschlüsselung |
| Prisma 6.19.3 | bereits ORM-Standard | `ProxmoxServer`/`ProxmoxServerStatus`-Modelle |
## Package Legitimacy Audit
Nicht anwendbar — dieser Auftrag installiert keine externen Pakete (weder npm noch sonst). Die Recherche bestätigt ausdrücklich, dass `undici`/natives `fetch` für alle benötigten HTTP-Aufrufe genügen; keine Proxmox-Client-Bibliothek wird eingeführt, wie vom Auftrag verlangt.
## Don't Hand-Roll
| Problem | Nicht selbst bauen | Stattdessen | Warum |
|---|---|---|---|
| Verschlüsselung von Token-Secret/Passwort | eigenes Crypto-Schema | `CryptoService` (bestehend) | Einzige Verschlüsselungsschicht im Projekt, bereits geprüft (T-05-10), Schlüsselverwaltung über `TESSERA_ENCRYPTION_KEY` schon gelöst |
| Selbstsigniertes Zertifikat tolerieren | eigener HTTPS-Agent/eigene TLS-Logik | `undici.Agent({ connect: { rejectUnauthorized } })`, bedingt pro Server | Bereits einmal im Projekt gemessen (icon-discovery), inkl. der Node-24-Falle |
| Cron-Auftrag je Mandant | eigener Intervall-Mechanismus (`setInterval` global) | `SchedulerRegistry.addCronJob()` (DKV/Tender-Muster) | Bereits zweimal im Projekt gelöst, inkl. der Verdrängungs-Falle (WINDOWS #21) |
## Code Examples
### API-Token-Aufruf mit bedingtem TLS-Bypass (PVE)
```ts
// Muster: apps/api/src/favorites/icon-discovery.service.ts (Dispatcher-Mechanik)
// + apps/api/src/ldap/ldap.service.ts:168 (bedingtes tlsRejectUnauthorized)
import { Agent, fetch as undiciFetch } from 'undici';
async function fetchPveResources(server: {
baseUrl: string; // z.B. https://pve.example.internal:8006
tokenId: string; // user@realm!tokenname
tokenSecret: string; // entschluesselt, nur im Speicher
tlsRejectUnauthorized: boolean;
}) {
const dispatcher = server.tlsRejectUnauthorized
? undefined // Standardpfad: echte Zertifikatspruefung, kein Sonderfall
: new Agent({ connect: { rejectUnauthorized: false } }); // NUR fuer diesen einen Server
const response = await undiciFetch(
`${server.baseUrl}/api2/json/cluster/resources`,
{
dispatcher,
headers: {
Authorization: `PVEAPIToken=${server.tokenId}=${server.tokenSecret}`,
},
},
);
if (!response.ok) {
throw new Error(`PVE-Antwort ${response.status}`); // 401 = Zugang/Ticket ungueltig
}
return response.json(); // { data: [...] } — type vm|node|storage gemischt
}
```
### Modul-Registrierung (Vorlage Domaincheck)
```ts
// apps/api/src/domaincheck/domaincheck.seed.ts — VERIFIED, so gelesen
export async function seedDomaincheckModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'domaincheck',
name: 'Domaincheck',
version: '1.0.0',
category: 'domain-tools',
description: { de: '...', en: '...' },
isSystem: true,
});
}
```
Für Proxmox: `slug: 'proxmox'`, eigene `category` (z.B. `'infrastructure'`), Controller mit `@Controller('modules/proxmox')` + `@UseModule('proxmox')` auf Klassenebene — exaktes Muster in `apps/api/src/domaincheck/domaincheck.controller.ts:1-8` `[VERIFIED]`.
### Scheduler-Kombination (DKV-Mandanten-Fan-out + Tender-Bootstrap-Timing)
```ts
// Kombiniert: apps/api/src/dkv/dkv-scheduler.service.ts (Mandanten-Fan-out)
// + apps/api/src/tenders/tender-scheduler.service.ts (OnApplicationBootstrap)
@Injectable()
export class ProxmoxSchedulerService implements OnApplicationBootstrap {
// NICHT OnModuleInit — siehe tender-scheduler.service.ts Kopfkommentar:
// onModuleInit-Reihenfolge zwischen Modulen ist nicht garantiert.
async onApplicationBootstrap(): Promise<void> {
const systemPrisma = forSystem(this.prisma); // alle Mandanten sehen, vor Mandantenkontext
const servers = await systemPrisma.proxmoxServer.findMany({ where: { isActive: true } });
const byTenant = groupBy(servers, (s) => s.tenantId);
for (const [tenantId, tenantServers] of byTenant) {
this.setInterval(tenantId, tenantServers); // ein Cron-Auftrag je Mandant, wie DKV
}
}
}
```
## Assumptions Log
| # | Claim | Abschnitt | Risiko falls falsch |
|---|---|---|---|
| A1 | PMG unterstützt keine API-Token, nur Ticket-Login (Forenbeleg, keine Primärquelle mit explizitem Gegenteil-Zitat) | Block 1, Anmeldung — API-Token | Falls doch unterstützt: UI verbietet unnötig eine gültige Option. Falls nicht: ohne diese Prüfung entsteht ein PMG-Zugang, der nie funktioniert |
| A2 | PBS/PMG-Ticket-Cookie heißt `PBSAuthCookie`/`PMGAuthCookie` (analog PVE) | Block 1, Anmeldung — Ticket | Falsche Cookie-Bezeichnung -> jede Ticket-Anfrage schlägt mit 401 fehl, obwohl Zugang korrekt ist |
| A3 | Exakte Feldnamen der PBS-Snapshot-Liste (`verify-state`, `backup-time`, `size`) | Block 1, PBS | Falsche Feldnamen -> `undefined`-Werte in der UI statt eines klaren Fehlers, bis manuell gegen den API-Viewer geprüft |
| A4 | Proxmox verwendet 401 breiter als übliche REST-Konvention (auch für Berechtigungsfehler, nicht nur Authentifizierung) | Block 1, Fehlerverhalten | Falls doch 403 vorkommt: UI zeigt „Zugang abgelehnt" statt einer treffenderen „Rolle reicht nicht"-Meldung — kosmetisch, kein Blocker |
| A5 | PMG-Statistik-Endpunkt liefert keine eigene Quarantäne-Zahl unter `/statistics/mail` (separater Pfad vermutet, nicht recherchiert) | Block 1, PMG | Falls Quarantäne-Zahl doch im selben Aufruf steckt: unnötiger zweiter API-Aufruf in der ersten Fassung — kein Blocker, nur Ineffizienz |
**Empfehlung:** A1–A3 vor dem ersten Implementierungs-Task als `checkpoint:human-verify` gegen einen echten PVE-/PBS-/PMG-Testserver bestätigen (der Auftrag nennt keinen erreichbaren Testserver für diese Recherche-Session — siehe Environment Availability).
## Environment Availability
Kein für diese Recherche erreichbarer PVE-/PBS-/PMG-Server bekannt oder im Auftrag genannt — anders als beim Windows-Test-VM- oder ViCoTest-Zugang aus dem Projektgedächtnis gibt es dafür keinen dokumentierten Zugriffsweg. Die API-Formen in diesem Dokument sind ausschließlich aus Doku/Forenbelegen zusammengetragen (siehe Assumptions Log), nicht live verifiziert. Der Planer sollte den ersten Implementierungs-Task so schneiden, dass ein `checkpoint:human-verify` (Anlegen eines echten Testzugangs durch den Nutzer) vor der Feldnamen-kritischen PBS/PMG-Arbeit steht — für PVE ist die Beleglage deutlich fester (offizielle `pveum-plain.html`/Wiki-Seite bestätigen Header-Form und CSRF-Verhalten wörtlich).
| Abhängigkeit | Gebraucht für | Verfügbar (diese Recherche-Session) | Fallback |
|---|---|---|---|
| Erreichbarer PVE-Server | Verifikation `cluster/resources`-Feldnamen, Token-Header | ✗ | Foren-/Community-Beleg, `checkpoint:human-verify` vor Bau |
| Erreichbarer PBS-Server | Verifikation Snapshot-/Verify-Feldnamen | ✗ | dito |
| Erreichbarer PMG-Server | Verifikation Statistik-Feldnamen, Token-Unterstützung | ✗ | dito, höchste Priorität wegen A1 |
## Validation Architecture
### Test Framework
| Property | Value |
|---|---|
| Framework | Vitest 3.2.6 (`apps/api`, `environment: 'node'`) `[VERIFIED: docs/anleitung-entwicklung.md, Abschnitt "Tests"]` |
| Config file | `apps/api/vitest.config.ts` |
| Quick run command | `pnpm --filter @tessera/api test` |
| Full suite command | `pnpm test` (Root, über Turborepo beide Apps) |
### Phase Requirements -> Test Map
| Behavior | Test Type | Automated Command |
|---|---|---|
| Verschlüsselung/Entschlüsselung Token-Secret/Passwort | unit | `CryptoService` bereits getestet; neuer Roundtrip-Test analog `crypto.service.spec.ts` |
| RLS-Abdeckung `ProxmoxServer`/`ProxmoxServerStatus` | guard | `pnpm --filter @tessera/api exec vitest run src/prisma/rls-coverage.spec.ts` |
| Zugriffsklassifikation vollständig dokumentiert | guard | `pnpm --filter @tessera/api exec vitest run src/prisma/rls-access-inventory.spec.ts` |
| Scheduler: ein Auftrag je Mandant, kein Verdrängen | unit | analog `dkv-scheduler.service.spec.ts` |
| TLS-Bypass nur bei `tlsRejectUnauthorized === false` dieser einen Zeile | unit | neuer Test, Vorbild fehlt (icon-discovery hat keinen bedingten Pfad) — selbst schreiben |
| `@UseModule('proxmox')` blockiert ohne Freigabe | unit | analog `module.guard.spec.ts` |
| Widget verschwindet ohne Modulzugriff | unit | analog `widget-wrapper.test.tsx`/`widget-module-map.spec.ts` |
### Sampling Rate
- **Per Task Commit:** `pnpm --filter @tessera/api test`
- **Per Wave Merge:** `pnpm test` (Root)
- **Phase Gate:** volle Suite grün vor `/gsd-verify-work`
### Wave 0 Gaps
- Kein PVE/PBS/PMG-Testserver erreichbar (siehe Environment Availability) — Feldnamen-kritische Tests bleiben bis zur manuellen Verifikation mit gemockten Antworten gebaut, nicht gegen einen echten Server.
## Security Domain
### Applicable ASVS Categories (Level 1)
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | ja (gegenüber Proxmox, nicht gegenüber Tessera-Nutzern) | Token/Passwort serverseitig gespeichert, nie an den Browser zurückgegeben (Maskierung wie `LdapConfigService`) |
| V4 Access Control | ja | `@UseModule('proxmox')` + `ModuleAccessGate` (zweistufig, wie alle Module) |
| V5 Input Validation | ja | `class-validator`-DTOs für Server-Adresse/Zugang (URL-Form, Enum für Typ/Auth-Methode) |
| V6 Cryptography | ja | `CryptoService` (AES-256-GCM), niemals selbst hand-rollen |
| V9 Communications | ja | TLS-Bypass ist die zentrale Bedrohung dieses Moduls — siehe unten |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| TLS-Bypass leakt Zugangsdaten an MITM | Information Disclosure | Bypass nur pro Server-Zeile, Default „prüfen", niemals global/Umgebungsvariable (siehe Block 2) |
| Gespeichertes Token/Passwort im Klartext lesbar bei DB-Dump | Information Disclosure | `CryptoService`-Verschlüsselung, Schlüssel getrennt vom DB-Backup aufbewahrt (bestehende Vorgabe, `docs/anleitung-entwicklung.md`) |
| Fremdmandant liest Proxmox-Zugang eines anderen Mandanten | Elevation of Privilege | RLS auf `ProxmoxServer`/`ProxmoxServerStatus`, `forTenant()`-Bindung, Pflicht-Testabdeckung (siehe Anschlussstellen) |
| Server-Antwort mit riesigem Payload (viele hundert VMs) legt den API-Prozess lahm | Denial of Service | Nur der Scheduler ruft Proxmox live auf (begrenzte Frequenz), die Modulseite liest immer aus dem Cache — kein ungebremster Nutzer-Trigger auf die Fremd-API |
## Sources
### Primary (HIGH confidence — aus tatsächlich gelesenem Projekt-Code)
- `apps/api/src/favorites/icon-discovery.service.ts` — undici-Dispatcher-Mechanik, TLS-Bypass-Kommentar
- `apps/api/src/ldap/ldap-config.service.ts`, `apps/api/src/ldap/crypto.service.ts` — Verschlüsselung, Systemkontext-Backfill
- `apps/api/src/dkv/dkv-scheduler.service.ts`, `apps/api/src/tenders/tender-scheduler.service.ts` — Scheduler-Muster
- `apps/api/prisma/schema.prisma` — `CalendarSource`, `LdapConfig`, `Module`/`TenantModuleActivation`, `Tender`, `DkvInvoiceHistory`
- `apps/api/src/prisma/rls-coverage.spec.ts`, `apps/api/src/prisma/rls-access-inventory.spec.ts` — RLS-Gates
- `docs/mandantentrennung-zugriffsklassifikation.md` — Klassifikationspflicht
- `docs/anleitung-entwicklung.md` — Modul-/Kachel-Registrierungsmuster
- `.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` — Drei-Stellen-Kachel-Muster
### Secondary (MEDIUM confidence — offizielle Proxmox-Doku, per WebFetch/WebSearch gelesen)
- pve.proxmox.com/pve-docs/pveum-plain.html — API-Token-Header, PVEAuditor-Rolle
- pve.proxmox.com/wiki/Proxmox_VE_API — Ticket-Endpunkt, CSRF-Verhalten
- pbs.proxmox.com/docs/user-management.html — PBSAPIToken-Header, Audit/DatastoreAudit-Rollen
### Tertiary (LOW confidence — Forenbelege, nicht in Primärdoku bestätigt)
- forum.proxmox.com (mehrere Threads) — PMG-Token-Lücke, `cluster/resources`-Feldnamen, PBS-Snapshot-Felder, PMG-Statistik-Felder, RRD-Counter-Typ
- pmg.proxmox.com/pmg-docs/pmg-admin-guide.html — Auditor-Rollenbeschreibung (aus Sekundärzitaten, nicht direkt aus dem Volltext extrahierbar — Dokument zu groß für den Abruf)
## Metadata
**Confidence breakdown:**
- PVE-Auth/CSRF/Rollen: HIGH — offizielle Doku wörtlich zitiert
- PBS-Auth/Rollen: HIGH (Auth-Header, Rollen), MEDIUM (Snapshot-Feldnamen, nur Forenbeleg)
- PMG-Auth: LOW (Token-Unterstützung nicht in Primärquelle bestätigt) — als `checkpoint:human-verify` markiert
- Bestandsmuster (Crypto/Scheduler/RLS/Modul-Registrierung): HIGH — aus gelesenem Code zitiert
**Research date:** 2026-09-23
**Valid until:** ~30 Tage für Bestandsmuster (stabil); Proxmox-API-Details sollten vor dem ersten Implementierungs-Task gegen einen echten Server nachgeprüft werden, unabhängig vom Datum (siehe Assumptions Log)
@@ -0,0 +1,295 @@
---
phase: quick-260923-dhh
plan: 01
subsystem: infrastructure
tags: [proxmox, pve, pbs, pmg, undici, scheduler, rls, module-registry, nestjs, next-intl]
dependency-graph:
requires: []
provides: [proxmox-module, proxmox-server-model, proxmox-background-poller]
affects: [apps/api/src/proxmox, apps/web/src/app/(portal)/modules/proxmox, apps/web/src/lib/proxmox-api.ts]
tech-stack:
added: []
patterns:
- "undiciFetch statt globalem fetch fuer einen bedingten TLS-Dispatcher (zweites, unabhaengiges Auftreten nach icon-discovery.service.ts)"
- "Nur-Lese-Riegel per Quelltext-Analyse (proxmox-nur-lesen.spec.ts), Vorbild rls-access-inventory.spec.ts"
- "Scheduler kombiniert DkvSchedulerService-Mandanten-Fan-out mit TenderSchedulerService-onApplicationBootstrap-Timing"
- "select ohne Geheimnisfelder statt nachtraeglicher Maskierung"
key-files:
created:
- apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
- apps/api/src/proxmox/proxmox.types.ts
- apps/api/src/proxmox/proxmox-auth.ts
- apps/api/src/proxmox/proxmox-client.service.ts
- apps/api/src/proxmox/proxmox-normalize.ts
- apps/api/src/proxmox/proxmox.service.ts
- apps/api/src/proxmox/proxmox-scheduler.service.ts
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/proxmox/proxmox.module.ts
- apps/api/src/proxmox/proxmox.seed.ts
- apps/api/src/proxmox/dto/proxmox-server.dto.ts
- apps/api/src/proxmox/proxmox-nur-lesen.spec.ts
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/layout.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
- apps/web/src/lib/proxmox-api.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/app.module.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/web/src/lib/module-loader.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-entwicklung.md
- docs/anleitung-anwender.md
decisions:
- "D-01 bis D-11 aus dem Plan woertlich umgesetzt, keine Abweichung."
- "proxmoxGet uebergibt bewusst KEIN method-Feld an undiciFetch (GET ist der Grundwert) — dadurch ist loginTicket() in proxmox-auth.ts die einzige Stelle, die ein Anfrageverfahren explizit uebergibt, und proxmox-nur-lesen.spec.ts kann das maschinell auf genau EINS pruefen."
- "Ticket-Erneuerung sitzt je POLL-DURCHLAUF, nicht je Aufruf: ein PBS-Durchlauf mit mehreren Folgeabfragen (Belegung + je Datenspeicher Sicherungen) loggt sich bei 401 hoechstens einmal neu ein, nicht einmal je Anfrage."
- "proxmox.service.ts ist der EINZIGE forSystem()-Aufrufer des Moduls (loadActiveServersForScheduler) — in FORSYSTEM_ALLOWED_CALL_SITES eingetragen, Stand von ProxmoxServer auf system-gebunden gehoben."
- "docs/anwenderhandbuch.md aus dem Plan existiert nicht im Repo — der echte Dateiname ist docs/anleitung-anwender.md; dort den Proxmox-Abschnitt eingefuegt (Rule 3)."
metrics:
duration: "~5h (Session unterbrochen und fortgesetzt)"
completed: 2026-09-23
actuals:
tokens: 50829
tasks: 7
commits: 7
plan_head_before: ec9c779
status: complete
---
# Quick 260923-dhh: Proxmox-Modul (PVE/PBS/PMG) — nur beobachten Summary
Vollstaendiges Proxmox-Modul (Datenbank, Dienst, API, Hintergrundabfrage je Mandant, Einstellungsseite, Modulseite, Dokumentation) — PVE/PBS/PMG werden per API-Token (PVE/PBS) oder Ticket-Anmeldung (alle drei) nur gelesen, kein Weg im Modul veraendert je etwas bei Proxmox.
## Gemessene Torzahlen
| Tor | Ausgangswert (23.09., vor Beginn) | Endstand (nach Aufgabe 7) |
|---|---|---|
| `pnpm --filter @tessera/api test` | 1240 Tests, 77 Dateien | **1311 Tests, 82 Dateien** |
| `pnpm --filter @tessera/web test` | 693 Tests, 82 Dateien | **708 Tests, 84 Dateien** |
| `rls-coverage.spec.ts` / `rls-access-inventory.spec.ts` | 5 / 30 | **5 / 30** (unveraendert gruen) |
| `proxmox-nur-lesen.spec.ts` | (existierte nicht) | **2 Tests, gruen** |
| `pnpm type-check` | 4/4 | **4/4** |
| `pnpm lint` | 5/5 | **5/5** |
| Biome-Warnungen in `apps/web` | 53 | **53** (exakt unveraendert) |
## Performance
- **Duration:** ~5h (inklusive einer Unterbrechung durch Nutzungslimit, an derselben Stelle fortgesetzt)
- **Tasks:** 7/7
- **Files modified:** 34 (18 neu, 16 geaendert)
## Accomplishments
- `ProxmoxServer`/`ProxmoxServerStatus` mit RLS (`tenant_isolation_policy` auf beiden,
`system_read_policy` zusaetzlich auf `ProxmoxServer` fuer den Planer-Startpfad)
- `proxmox-auth.ts` als einzige Stelle, die Kopfzeilen/Cookies baut: Token-Schema je Produkt
(PVE `=`, PBS `:`, PMG lehnt ab) und Ticket-Anmeldung (die einzige nicht-lesende Anfrage
des Moduls)
- `proxmox-client.service.ts`/`proxmox-normalize.ts`: nachsichtige Fehler-/Feldbehandlung,
sieben stabile Fehlerschluessel, nie ein Wurf bei unerwarteter Form
- `proxmox-scheduler.service.ts`: ein Cron-Auftrag je Mandant (`proxmox-poll:<tenantId>`),
`onApplicationBootstrap`, Abfrageintervall = kleinstes `pollIntervalMin` der aktiven Server
- Einstellungsseite (anlegen/bearbeiten/loeschen/testen) und Modulseite (Serverliste mit
produktabhaengiger Auslastung, `null` immer als „unbekannt")
- Anwenderhandbuch- und Entwicklungsanleitung-Abschnitte, Zugriffsklassifikation vollstaendig
nachgezogen
## Task Commits
Jede Aufgabe wurde einzeln committet:
1. **Aufgabe 1: PVE per Token, Ende-zu-Ende** — `3a1bfd9` (feat)
2. **Aufgabe 2: Benutzer/Passwort, Fehlerklassen, Nur-Lesen-Riegel** — `4f8a368` (test)
3. **Aufgabe 3: PBS und PMG auswerten** — `998aba9` (feat)
4. **Aufgabe 4: Hintergrundabfrage je Mandant, Verbindungstest** — `fccaf8d` (feat)
5. **Aufgabe 5: Einstellungsseite (anlegen, bearbeiten, loeschen, testen)** — `723cf68` (feat)
6. **Aufgabe 6: Modulseite mit Auslastung** — `06fcdc0` (feat)
7. **Aufgabe 7: Dokumentation und Nachmessung aller Tore** — `3091b04` (docs)
_Kein separater Metadaten-Commit — STATE.md/SUMMARY.md werden laut Auftrag nicht committet._
## Files Created/Modified
Siehe `key-files` im Frontmatter — vollstaendige Liste, hier die wichtigsten:
- `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql` — RLS-Migration,
von Hand geschrieben (Vorbild `20260923120000_dashboard_tabs`)
- `apps/api/src/proxmox/proxmox-client.service.ts` — `proxmoxGet`, `classifyFailure`,
`parseJsonLenient`
- `apps/api/src/proxmox/proxmox-auth.ts` — `buildTokenAuthHeader`, `loginTicket`,
`buildTicketCookieHeader`
- `apps/api/src/proxmox/proxmox-normalize.ts` — `normalizePve`/`normalizePbs`/`normalizePmg`
plus `readNumber`/`readText`/`readBool`/`readList`
- `apps/api/src/proxmox/proxmox.service.ts` — CRUD, Poll-Logik, Zehn-Sekunden-Sperre,
`loadActiveServersForScheduler` (einziger `forSystem()`-Aufruf)
- `apps/api/src/proxmox/proxmox-scheduler.service.ts` — Planer je Mandant
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx` — einzige Stelle,
die einen Messwert in Text verwandelt
## Decisions Made
Siehe `decisions` im Frontmatter. Zusaetzlich zwei technische Entwurfsentscheidungen, die der
Plan nicht bis auf diese Ebene vorschrieb:
- **Signatur `proxmoxGet(target, path)`:** `target` traegt fertige Kopfzeilen
(`{ baseUrl, tlsRejectUnauthorized, headers }`), gebaut ausschliesslich von `proxmox-auth.ts`
— der Klient selbst kennt keine Anmeldeform, nur HTTP-Transport und Fehlerklassifikation.
- **`proxmox-nur-lesen.spec.ts` erkennt Aufrufformen ueber Klammertiefen-Bilanzierung**
(nicht per einfachem Zeilen-Regex), weil Proxmox-Pfade und `proxmoxGet(`/`getWithRetry(`-
Aufrufe im Quelltext ueber mehrere Zeilen verteilt sind.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] `docs/anwenderhandbuch.md` existiert nicht im Repo**
- **Found during:** Aufgabe 7
- **Issue:** Das Plan-Frontmatter nennt `docs/anwenderhandbuch.md` als zu aendernde Datei; diese
Datei gibt es im Repository nicht. Der tatsaechliche Anwenderhandbuch-Dateiname ist
`docs/anleitung-anwender.md` (bestaetigt per `git log --diff-filter=A`).
- **Fix:** Den Proxmox-Abschnitt in `docs/anleitung-anwender.md` eingefuegt statt eine neue,
falsch benannte Datei anzulegen.
- **Files modified:** `docs/anleitung-anwender.md`
- **Verification:** Datei existiert, Abschnitt „Proxmox" lesbar, Modulzahl „vier" auf „fuenf"
korrigiert.
- **Committed in:** `3091b04` (Aufgabe-7-Commit)
**2. [Rule 3 - Blocking] Umlaut-Regressionswaechter (`umlaut-guard.spec.ts`) schlug fehl**
- **Found during:** Aufgabe 5 und erneut Aufgabe 6
- **Issue:** Neue, bereits korrekte deutsche Woerter mit „ss" (`bewusst`, `gemessene`,
`Messung`, `Prozessorlast`) in den neuen `de.json`-Texten wurden vom Waechter als
moegliche ae/oe/ue/ss-Ersatzschreibung markiert, weil sie noch nicht auf der Positivliste
standen.
- **Fix:** Alle vier Woerter zu `UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts`
hinzugefuegt (kein Ersatzschreibung — bereits korrektes Deutsch).
- **Files modified:** `apps/web/src/messages/umlaut-dictionary.ts`
- **Verification:** `umlaut-guard.spec.ts` gruen, `pnpm --filter @tessera/web test` vollstaendig
gruen.
- **Committed in:** `723cf68` (Aufgabe 5), `06fcdc0` (Aufgabe 6)
**3. [Rule 3 - Blocking] `proxmox-nur-lesen.spec.ts` erkannte den `getWithRetry`-Umschlag nicht**
- **Found during:** Aufgabe 3 (beim Einbau der PBS-Mehrfachabfrage)
- **Issue:** Der urspruengliche Riegel erkannte Proxmox-Pfade nur innerhalb direkter
`proxmoxGet(...)`-Aufrufe; nach der Extraktion der Ticket-Erneuerung in einen privaten
Umschlag `getWithRetry()` (Aufgabe 2/3) lagen alle Pfade jetzt in dessen Argumenten, nicht
mehr direkt in `proxmoxGet(...)`.
- **Fix:** Die erlaubte Aufrufform-Liste um `getWithRetry` erweitert (dokumentierte Ausnahme,
selbst durch dieselbe erste Aussage des Riegels abgesichert: `getWithRetry` ruft
ausschliesslich `proxmoxGet`).
- **Files modified:** `apps/api/src/proxmox/proxmox-nur-lesen.spec.ts`
- **Verification:** Beide Aussagen des Riegels gruen, bewusster Test bestaetigt weiterhin genau
eine `undiciFetch`-Methodenstelle.
- **Committed in:** `998aba9` (Aufgabe 3)
---
**Total deviations:** 3 auto-fixed (alle Rule 3 — blockierende Fehler beim Ausfuehren, keine
davon eine architektonische Entscheidung)
**Impact on plan:** Keine Abweichung vom fachlichen Umfang des Plans; alle drei Korrekturen
waren notwendig, damit die vom Plan selbst verlangten Tore (Aufgabe 7: alle Testsuiten gruen)
ueberhaupt erreichbar waren.
## Issues Encountered
Die Ausfuehrung wurde durch ein Nutzungslimit mitten in Aufgabe 4 unterbrochen (nach dem
Schreiben von `proxmox-scheduler.service.ts` und dem Wiring in `proxmox.controller.ts`/
`proxmox.module.ts`, vor dem Schreiben der zugehoerigen Testdatei). Nach Fortsetzung wurde der
Stand anhand von `git status`/`git log` verifiziert und exakt an der protokollierten Stelle
weitergearbeitet — keine Wiederholung bereits committeter Aufgaben.
## User Setup Required
**Es gibt in dieser Umgebung keinen echten PVE-/PBS-/PMG-Server.** Alle Tests laufen gegen
erfundene Antworten in der von der Recherche dokumentierten Form (`vi.mock('undici', …)`).
Folgende Annahmen der Recherche sind vor dem ersten echten Test explizit zu bestaetigen bzw.
bei Abweichung an genau einer Stelle nachzuziehen:
- **Annahme A2 — Ticket-Cookie-Namen fuer PBS/PMG:** `PBSAuthCookie`/`PMGAuthCookie` sind aus
dem PVE-Muster ABGELEITET, nicht aus Primaerdoku bestaetigt. Nachzuziehende Stelle:
`TICKET_COOKIE_NAME` in `apps/api/src/proxmox/proxmox-auth.ts`.
- **Annahme A3 — PBS-Belegungs-/Snapshot-Feldnamen:** `store`/`total`/`used`/`avail` und
`backup-time`/`verification` sind aus Forenbelegen abgeleitet. Nachzuziehende Stelle:
`PBS_USAGE_FIELDS`/`PBS_SNAPSHOT_FIELDS` in `apps/api/src/proxmox/proxmox-normalize.ts`
(mehrere plausible Namen je Feld moeglich, der Leser nimmt den ersten vorhandenen).
- **Annahme A5 — PMG-Statistikfelder:** `count_in`/`count_out`/`spamcount_in`/`spamcount_out`/
`viruscount_in`/`viruscount_out` sind aus `pmgsh`-Community-Belegen abgeleitet.
Nachzuziehende Stelle: `PMG_STATS_FIELDS` in `apps/api/src/proxmox/proxmox-normalize.ts`.
- **NUR-LESE-Rollen am Proxmox-Server selbst anlegen** (aus dem Plan-Frontmatter
`user_setup`, unveraendert offen): PVE `PVEAuditor`, PBS `Audit`/`DatastoreAudit`,
PMG `Auditor` — je Produkt fuer den Zugang, den Tessera nutzt.
Weicht die Wirklichkeit an einer dieser Stellen ab, zeigt die Modulseite dank der
nachsichtigen Leser „unbekannt" statt eines Absturzes, und die gekuerzte Rohantwort bleibt im
Zwischenlager erhalten (`rawSample`, bis 20 000 Zeichen) — der Nutzer sieht darin, wie das
Feld tatsaechlich heisst.
## Known Stubs
Keine — jede in `<must_haves>` genannte Wahrheit ist durch mindestens einen automatisierten
Test belegt (siehe Aufgaben 1–6). Die drei oben genannten Annahmen sind keine Stubs, sondern
dokumentierte, noch nicht am echten Server bestaetigte Feldnamen — die Auswertung fuer sie ist
vollstaendig gebaut, nur ihre exakten externen Namen sind ungeprueft.
## Next Phase Readiness
- Das Modul ist vollstaendig gebaut und alle automatisierten Tore sind gruen; die
Dashboard-Kachel (D-11) ist bewusst nicht Teil dieses Auftrags und folgt separat
(`WIDGET_TYPES`/`WIDGET_MODULE_SLUGS`/`registerWidget`, siehe
`docs/anleitung-entwicklung.md`, Abschnitt „Eine Kachel zum Modul").
- **Blocker fuer den naechsten Schritt:** keiner auf Code-Ebene. Der Nutzer muss das Modul
gegen mindestens einen echten PVE-/PBS-/PMG-Server pruefen (siehe „User Setup Required"),
bevor die drei Annahmen als bestaetigt gelten koennen.
- Container wurden in dieser Ausfuehrung bewusst NICHT neu gebaut/neu gestartet und es wurde
keine Browser-Pruefung durchgefuehrt (Auftragsvorgabe) — das uebernimmt der Nutzer bzw. eine
spaetere Sitzung.
---
*Phase: quick-260923-dhh*
*Completed: 2026-09-23*
## Self-Check: PASSED
All 24 files listed under `key-files` (created + modified) verified present on disk. All 7
task commits (`3a1bfd9`, `4f8a368`, `998aba9`, `fccaf8d`, `723cf68`, `06fcdc0`, `3091b04`)
verified present in `git log`.
## Nachbesserungen aus dem Rundgang
Drei Befunde aus dem menschlichen Browser-Rundgang zu diesem Modul wurden behoben — Details,
Tasks und Tests in einem eigenen Quick-Task:
[260923-ku6-drei-nachbesserungen-aus-dem-browser-run](../260923-ku6-drei-nachbesserungen-aus-dem-browser-run/260923-ku6-SUMMARY.md)
(Commits `710034c`, `f1bb7f7`).
**Befund 1 (wichtig): „Verbindung testen" pruefte den gespeicherten Stand, nicht das
Formular.** Eine im Formular abgeschaltete Zertifikatspruefung oder ein neu eingetipptes
Token-/Passwort-Geheimnis wurden vom Test ignoriert und griffen erst nach „Speichern" — eine
Falle fuer den naheliegenden Ablauf (eintippen, testen, dann erst speichern). Behoben durch ein
neues `TestProxmoxServerDto` samt Merge-Baustein `resolveEffectiveTestServer` in
`ProxmoxService`: normale Formularfelder gewinnen immer (auch wenn absichtlich geleert),
Geheimnisfelder behalten die bestehende „leer gelassen -> gespeicherten Wert weiterverwenden"-
Regel aus `updateServer`, weil `ServerForm` sie beim Laden nie aus der Datenbank vorbefuellt.
Neue Route `POST servers/test` (ohne `:id`) deckt denselben Test waehrend der Neuanlage ab, wo
es noch keinen gespeicherten Server gibt; der Testen-Knopf steht jetzt immer zur Verfuegung,
nicht mehr erst nach dem ersten Speichern.
**Befund 2 (wichtig): falsche Meldung fuer „noch nie abgefragt".** Ein frisch angelegter
Server zeigte „Letzte Abfrage: unbekannt" UND faelschlich „Ein unerwarteter Fehler ist
aufgetreten" — die leere Zwischenlagerzeile aus `createServer` hat `reachable: false` und
`errorKind: null`, was bisher blind in die Fehleruebersetzung `unbekannt` lief. Behoben durch
einen eigenen, ruhigen Zustand fuer `status.lastPolledAt === null`, der auf „Jetzt
aktualisieren" verweist; die bestehenden Fehlermeldungen (inkl. `unbekannt` fuer echte
unbekannte Fehler) bleiben fuer bereits abgefragte, aber nicht erreichbare Server unveraendert.
**Befund 3 (kosmetisch): die Adresse wurde in Grossbuchstaben angezeigt.** Die Klasse
`uppercase` sass auf der ganzen Statuszeile statt nur auf dem Produktkuerzel und faerbte
dadurch auch die Adresse gross. Jetzt nur noch auf dem Produktkuerzel (`<span>`).
**Zahlen nach der Nachbesserung:** api 1311 → 1316 Tests, web 708 → 712 Tests, type-check
4/4, lint 5/5, Biome `apps/web` weiterhin exakt 53 Warnungen. Container wurden nicht neu
gebaut, keine Browser-Pruefung in diesem Lauf (macht der Orchestrator danach).
@@ -0,0 +1,126 @@
---
phase: quick-260923-dhh
verified: 2026-09-23T14:58:00Z
status: gaps_found
score: 8/9 must-haves verified
covered_files: [".planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-PLAN.md", ".planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-RESEARCH.md", ".planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-SUMMARY.md", "apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql", "apps/api/prisma/schema.prisma", "apps/api/src/prisma/rls-access-inventory.spec.ts", "apps/api/src/proxmox/dto/proxmox-server.dto.ts", "apps/api/src/proxmox/proxmox-auth.ts", "apps/api/src/proxmox/proxmox-client.service.spec.ts", "apps/api/src/proxmox/proxmox-client.service.ts", "apps/api/src/proxmox/proxmox-normalize.spec.ts", "apps/api/src/proxmox/proxmox-normalize.ts", "apps/api/src/proxmox/proxmox-nur-lesen.spec.ts", "apps/api/src/proxmox/proxmox-scheduler.service.spec.ts", "apps/api/src/proxmox/proxmox-scheduler.service.ts", "apps/api/src/proxmox/proxmox.controller.ts", "apps/api/src/proxmox/proxmox.module.ts", "apps/api/src/proxmox/proxmox.seed.ts", "apps/api/src/proxmox/proxmox.service.spec.ts", "apps/api/src/proxmox/proxmox.service.ts", "apps/api/src/proxmox/proxmox.types.ts", "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx", "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx", "apps/web/src/app/(portal)/modules/proxmox/layout.tsx", "apps/web/src/app/(portal)/modules/proxmox/page.tsx", "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx", "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx", "apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx", "apps/web/src/lib/module-loader.ts", "apps/web/src/lib/proxmox-api.ts", "apps/web/src/messages/de.json", "apps/web/src/messages/en.json", "apps/web/src/messages/umlaut-dictionary.ts", "docs/anleitung-anwender.md", "docs/anleitung-entwicklung.md", "docs/mandantentrennung-zugriffsklassifikation.md"]
covered_digest: "v1:sha256:2ee19956636c68304958254f2e1d979a6763c3fae7dc11cd781fe24d56e498e8"
behavior_unverified: 0
overrides_applied: 0
gaps:
- truth: "Ein fehlendes, anders benanntes oder falsch typisiertes Feld einer Proxmox-Antwort fuehrt zu unbekannt in der Anzeige, nie zu einem Absturz, einer leeren Seite oder einem stillen Falschwert."
status: partial
reason: "normalizePmg() kombiniert spamcount_in/spamcount_out (und viruscount_in/viruscount_out) ueber sumOrNull(a, b), das einen fehlenden Teilwert stillschweigend als 0 behandelt statt die Summe als unbekannt zu markieren. sumOrNull(10, null) liefert 10 — dieser Wert erscheint in der Modulseite als vollstaendige Tageszahl 'Spam: 10', obwohl eine der beiden Quellfelder (spamcount_out) fehlte oder anders heisst. Genau dieses Szenario ist der zentrale Risikofall des Moduls: PMG-Feldnamen sind Annahme A5 (Forenbeleg, unbestaetigt), und ein teilweise falscher, aber plausibel aussehender Wert ist laut eigenem Kommentar in proxmox-normalize.ts ('ein still falscher Wert waere schlimmer als ein ehrliches unbekannt') genau das, was das Modul verhindern soll. Alle uebrigen Einzelwerte (readNumber/readText/readBool je Feld, PBS readFirstPresent-Alternativnamen) sind korrekt nachsichtig und liefern bei fehlendem Feld null — nur diese eine Aggregation (zwei Teilwerte zu einer Summe) durchbricht das Muster."
artifacts:
- path: "apps/api/src/proxmox/proxmox-normalize.ts"
issue: "sumOrNull(a, b) (Zeile 240-243) gibt (a??0)+(b??0) zurueck, sobald mindestens einer von a/b nicht null ist — ein fehlender Halbwert wird als 0 addiert statt die Summe auf null zu setzen. Betrifft spamCount und virusCount in normalizePmg()."
missing:
- "sumOrNull so aendern, dass die Summe null ist, sobald a ODER b null ist (nicht erst wenn beide null sind) — oder spamCount/virusCount nur berechnen, wenn beide Teilwerte vorhanden sind."
- "Test in proxmox-normalize.spec.ts ergaenzen: 'nur spamcount_in vorhanden, spamcount_out fehlt' -> spamCount muss null sein, nicht der Teilwert."
---
# Quick 260923-dhh: Proxmox-Modul (PVE/PBS/PMG) — nur beobachten Verification Report
**Goal:** PVE/PBS/PMG per Modul beobachten (nicht veraendern): Server in den Einstellungen anlegen mit verschluesseltem Zugang, Zertifikatsfehler nur je Server dulden, Hintergrundabfrage mit Zwischenlager, Modulseite mit Serverliste und Auslastung, Verbindungstest mit Klartext-Ursache.
**Verified:** 2026-09-23T14:58Z
**Status:** gaps_found
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Kein Weg im Modul veraendert etwas bei Proxmox; die einzige Nicht-GET-Anfrage ist die Ticket-Anmeldung, maschinell nachgezaehlt | ✓ VERIFIED | `proxmox-nur-lesen.spec.ts` liest den Quelltext (Klammertiefen-Bilanzierung), zaehlt genau 1 `method:`-Uebergabe an `undiciFetch` in `proxmox-auth.ts`, und verlangt, dass jeder API-Pfad ausserhalb der Ticket-Anmeldung durch `proxmoxGet`/`getWithRetry` laeuft. `getWithRetry` (proxmox.service.ts:275) ruft ausschliesslich `proxmoxGet` — keine verdeckte zweite Schreibstelle. Grep ueber `apps/api/src/proxmox` bestaetigt: kein bare `fetch(` ausserhalb `undiciFetch`. Test lief gruen (2/2). |
| 2 | Administrator legt Server (Name, Typ, Adresse, Zugang) an; Geheimnis nie im Klartext sichtbar | ✓ VERIFIED | `createServer`/`updateServer` verschluesseln via `CryptoService`; `SAFE_SERVER_SELECT` (proxmox.service.ts:26-42) waehlt `encryptedTokenSecret`/`encryptedPassword` nicht aus — die Felder verlassen die DB nie. `proxmox.service.spec.ts` bestaetigt `'encryptedTokenSecret' in list[0]` ist `false`. Frontend `ServerForm.tsx`: Geheimnisfelder immer leer geladen (`tokenSecret: ''`, `password: ''`), leer gelassen = unveraendert (Backend-Logik in `updateServer`). |
| 3 | PVE/PBS: Token ODER Passwort; PMG nur Passwort, Token-Feld verschwindet und wird serverseitig abgelehnt | ✓ VERIFIED | `ServerForm.tsx`: `{form.productType !== 'pmg' && <option value="token">...}` — Token-Option fehlt bei PMG. `PmgOhneTokenConstraint` im DTO UND zusaetzliche Pruefung in `updateServer` gegen den EFFEKTIVEN Stand (verhindert Umgehung ueber Teil-Updates). `buildTokenAuthHeader('pmg', ...)` wirft. Getestet in `proxmox-client.service.spec.ts` (DTO-Validierung PMG+Token). |
| 4 | Zertifikatsfehler nur je Server geduldet, Default "pruefen" | ✓ VERIFIED | `proxmoxGet`/`loginTicket` bauen den `Agent`-Dispatcher JE AUFRUF aus `target.tlsRejectUnauthorized` der jeweiligen Zeile — kein Modul-Singleton, keine Env-Variable. DTO-Default `tlsRejectUnauthorized ?? true`. Test bestaetigt: `true` → kein Dispatcher, `false` → genau ein `Agent` mit `rejectUnauthorized: false`. |
| 5 | Modulseite und jede Anzeige lesen ausschliesslich aus dem Zwischenlager | ✓ VERIFIED | `GET servers` → `listWithStatus()` liest nur aus der DB (kein `proxmoxGet`-Aufruf). `page.tsx`/`ServerCard.tsx` rendern nur `server.status`, das aus derselben Response stammt. Live-Abfrage findet nur ueber `pollServer`/`testConnection` statt, explizit durch Nutzerklick oder Scheduler ausgeloest. |
| 6 | Knopf "Verbindung testen" nennt Ursache in Alltagssprache | ✓ VERIFIED | Alle 7 `ProxmoxErrorKind`-Werte haben deutsche Klartexttexte in `de.json`/`en.json` (Sie-Form, mit Ursache und naechstem Schritt). `testConnection()` schreibt NICHT ins Zwischenlager (Vorbild LDAP-Test). |
| 7 | Fehlendes/anders benanntes/falsch typisiertes Feld → "unbekannt", nie Absturz/leere Seite/stiller Falschwert | ✗ PARTIAL | Siehe Gap unten: `sumOrNull()` in `normalizePmg()` liefert bei einem fehlenden Teilwert (z. B. `spamcount_out` fehlt) einen scheinbar vollstaendigen, tatsaechlich unvollstaendigen Zahlenwert statt `null`/"unbekannt". Alle uebrigen Einzelwerte (PVE/PBS, PMG countIn/countOut) sind korrekt nachsichtig — verifiziert in `proxmox-normalize.spec.ts` (20 Tests gruen) und live nachgerechnet (`node -e`). |
| 8 | Ohne Server: Modulseite ruhig, erklaert dass noch keiner eingetragen ist | ✓ VERIFIED | `page.tsx`: `servers.length === 0` → `t('emptyState')` plus Link zu den Einstellungen fuer Admins, kein Fehlertext. |
| 9 | Beide Tabellen tragen tenantId mit RLS-Policy; rls-coverage/rls-access-inventory bleiben gruen | ✓ VERIFIED | Migration erstellt `tenant_isolation_policy` auf beiden Tabellen plus `system_read_policy` nur auf `ProxmoxServer`. **Live in der Dev-DB bestaetigt** (`psql`): `relrowsecurity=t`, `relforcerowsecurity=t` auf beiden Tabellen; `pg_policies` zeigt exakt die erwarteten drei Policies. `rls-coverage.spec.ts` (5/5) und `rls-access-inventory.spec.ts` (30/30) gruen, inkl. `FORSYSTEM_ALLOWED_CALL_SITES`-Eintrag fuer den einzigen `forSystem()`-Aufruf. Klassifikationsdoku nachgemessen (`grep -c` bestaetigt 11 gebundene + 1 System-Rohtreffer, Doku sagt dasselbe). |
**Score:** 8/9 truths verified (0 present-but-behavior-unverified)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql` | RLS-Migration | ✓ VERIFIED | Existiert, angewendet (Tabellen + Policies live in der Dev-DB bestaetigt) |
| `apps/api/src/proxmox/proxmox-auth.ts` | einzige Kopfzeilen-Stelle | ✓ VERIFIED | `buildTokenAuthHeader`, `loginTicket`, `buildTicketCookieHeader`; keine andere Datei im Repo baut PVEAPIToken/PBSAPIToken/Cookie-Header |
| `apps/api/src/proxmox/proxmox-client.service.ts` | nur-lesender HTTP-Zugang | ✓ VERIFIED | `proxmoxGet`, `classifyFailure`, `parseJsonLenient` — kein `method`-Parameter |
| `apps/api/src/proxmox/proxmox-normalize.ts` | nachsichtige Leser | ⚠️ SUBSTANTIVE MIT LUECKE | Grundfunktionen (`readNumber`/`readText`/`readBool`/`readList`) korrekt; `normalizePmg`s Aggregation (`sumOrNull`) durchbricht das Muster (siehe Gap) |
| `apps/api/src/proxmox/proxmox-scheduler.service.ts` | Planer je Mandant | ✓ VERIFIED | `onApplicationBootstrap`, ein Cron-Auftrag je Mandant, Fan-out getestet (9/9 Tests) |
| `apps/api/src/proxmox/proxmox-nur-lesen.spec.ts` | maschineller Riegel D-01 | ✓ VERIFIED | 2/2 Tests gruen, Klammertiefen-Analyse statt naiver Regex |
| `apps/web/src/app/(portal)/modules/proxmox/page.tsx` | Modulseite | ✓ VERIFIED | Leerzustand, Serverliste, "Jetzt aktualisieren" |
| `apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx` | Einstellungsseite | ✓ VERIFIED | Rollen-Gate (Anzeige), CRUD, Loeschbestaetigung |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `proxmox-auth.ts` | Klient/Planer/Verbindungstest | einzige Kopfzeilen-Bau-Stelle (D-03) | ✓ WIRED | `proxmox.service.ts` importiert ausschliesslich `buildTicketCookieHeader`/`buildTokenAuthHeader`/`loginTicket` aus dieser Datei; kein Nachbau anderswo |
| `proxmox-client.service.ts` | `tlsRejectUnauthorized`-Feld der Serverzeile | Dispatcher je Aufruf (D-04) | ✓ WIRED | `target.tlsRejectUnauthorized ? undefined : new Agent(...)` in `proxmoxGet` und `loginTicket`, je aus der uebergebenen Serverzeile |
| `proxmox-scheduler.service.ts` | `proxmox.controller.ts` | `onApplicationBootstrap` + `refreshTenant` nach jedem Speichern | ✓ WIRED | Controller ruft `scheduler.refreshTenant(tenantId)` nach `create`/`update`/`remove` |
| `proxmox.controller.ts` | `@UseModule`/`@Roles` | Modulfreigabe + Rollenschutz (D-09) | ✓ WIRED | `@UseModule('proxmox')` auf Klassenebene, `@Roles(ADMIN, SUPER_ADMIN)` auf allen Schreibwegen |
| Jeder DB-Zugriff | `forTenant()`/`forSystem()` | Mandantenbindung (D-08) | ✓ WIRED | `grep -c` bestaetigt 11 `tenantPrisma.(proxmoxServer\|proxmoxServerStatus).`-Treffer, 1 `systemPrisma.proxmoxServer.`-Treffer — deckungsgleich mit `FORSYSTEM_ALLOWED_CALL_SITES` und der Klassifikationsdoku |
### Data-Flow Trace
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|---------------------|--------|
| `ServerCard.tsx` | `server.status.metrics` | `GET modules/proxmox/servers` → `listWithStatus()` → DB (`ProxmoxServerStatus`) | Ja (mit Testdaten belegt, kein echter Proxmox verfuegbar — s. unten) | ✓ FLOWING |
| `ServerForm.tsx` Testergebnis | `testResult` | `POST servers/:id/test` → `testConnection()` → `pollOne()` (kein DB-Schreiben) | Ja | ✓ FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Nur-Lesen-Riegel haelt (Klammertiefen-Analyse, nicht nur Praesenz) | `vitest run src/proxmox/proxmox-nur-lesen.spec.ts` | 2/2 gruen | ✓ PASS |
| Ticket-Erneuerung: genau EIN zweiter Versuch, zweites 401 bleibt Fehler | `vitest run src/proxmox` (enthaelt beide Faelle) | gruen | ✓ PASS |
| Scheduler: zwei Mandanten verdraengen sich nicht, leere Serverliste → kein Auftrag | `vitest run src/proxmox/proxmox-scheduler.service.spec.ts` | 9/9 gruen | ✓ PASS |
| RLS tatsaechlich in der Dev-DB aktiv (nicht nur im SQL-Text) | `docker exec ... psql -c "SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname IN (...)"` | `t / t` auf beiden Tabellen, 3 erwartete Policies vorhanden | ✓ PASS |
| `sumOrNull`-Aggregationsluecke (eigener Nachbau, nicht Teil der Testsuite) | `node -e "sumOrNull(10, null)"` | `10` (haette bei ehrlichem Verhalten `null` sein muessen) | ✗ FAIL — bestaetigt den Gap oben |
| Volle Testsuiten | `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test` | 1311/1311 bzw. 708/708 gruen, identisch zu SUMMARY-Zahlen | ✓ PASS |
| type-check / lint / Biome | `pnpm type-check`, `pnpm lint`, `pnpm --filter @tessera/web exec biome lint .` | 4/4, 5/5, "Found 53 warnings" | ✓ PASS |
### Requirements Coverage
Kein separates REQUIREMENTS.md fuer Quick-Tasks; Abdeckung erfolgt ueber die elf D-Nummern im Plan-Frontmatter (`<source_audit>`), alle als COVERED gefuehrt und hier gegengeprueft — kein Widerspruch gefunden ausser dem oben genannten Gap zu D-08/T-DHH-08 (stiller Falschwert).
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `apps/api/src/proxmox/proxmox-normalize.ts` | 240-243 | Aggregation verschluckt fehlenden Teilwert (`sumOrNull`) | 🛑 Blocker (verletzt explizites must-have) | PMG "Spam"/"Viren"-Zahl kann eine unvollstaendige, aber vertrauenswuerdig aussehende Zahl zeigen statt "unbekannt" |
| — | — | Keine TBD/FIXME/XXX in den neuen Dateien gefunden | ℹ️ Info | — |
| `apps/web/.../page.tsx` | 54 | "Jetzt aktualisieren"-Knopf wird JEDEM Nutzer mit Modulzugriff gezeigt, `POST servers/:id/poll` ist aber `@Roles(ADMIN, SUPER_ADMIN)`; Fehler wird mit `.catch(() => undefined)` still verschluckt | ⚠️ Warning (UX, keine Sicherheitsluecke — Backend blockt korrekt) | Normale Nutzer sehen einen Knopf, der bei ihnen wirkungslos bleibt, ohne Rueckmeldung |
## Human Verification Required
Diese Punkte kann kein automatisierter Check abschliessend pruefen — teils weil kein echter Proxmox-Server in dieser Umgebung erreichbar ist (vom Auftrag selbst so benannt), teils weil es sich um visuelles/Browser-Verhalten handelt.
### 1. Modulseite im Browser (vom Plan als `<human-check>` in Aufgabe 6 vorgesehen)
**Test:** `/modules/proxmox` oeffnen: ohne Server pruefen, dass der ruhige Hinweis erscheint; danach in den Einstellungen einen Server anlegen und pruefen, dass er in der Liste auftaucht; einen absichtlich falschen Zugang eintragen und pruefen, dass Klartext statt einer leeren Flaeche erscheint.
**Expected:** Ruhiger Leerzustand, danach korrekte Anzeige, dann Klartext-Fehlermeldung.
**Why human:** Erfordert echten Browser-Durchlauf; die laufenden Container wurden fuer diese Verifikation bewusst nicht neu gebaut (Auftragsvorgabe), ein visueller Check ist damit nicht ohne Weiteres moeglich.
### 2. Annahmen A2/A3/A5 gegen echte PVE-/PBS-/PMG-Server
**Test:** Cookie-Namen (`PBSAuthCookie`/`PMGAuthCookie`), PBS-Belegungs-/Snapshot-Feldnamen und PMG-Statistikfelder gegen einen echten Server pruefen.
**Expected:** Die in `TICKET_COOKIE_NAME`/`PBS_USAGE_FIELDS`/`PBS_SNAPSHOT_FIELDS`/`PMG_STATS_FIELDS` hinterlegten Namen stimmen, oder werden an der jeweils benannten EINEN Stelle nachgezogen.
**Why human:** Kein PVE/PBS/PMG-Server in dieser Umgebung erreichbar — vom Plan selbst so benannt und in `user_setup` dokumentiert, keine Verifikationsluecke dieser Pruefung.
## Gaps Summary
Ein konkreter, durch Code und einen eigenen Nachrechenlauf bestaetigter Gap: `normalizePmg()`s `sumOrNull()`-Hilfsfunktion behandelt einen fehlenden Teilwert (`spamcount_out`/`viruscount_out` bzw. deren `_in`-Gegenstuecke) als `0` statt die kombinierte Summe als `null`/"unbekannt" zu markieren. Das widerspricht direkt dem im Plan-Frontmatter (`must_haves.truths`) UND im eigenen Code-Kommentar ("ein still falscher Wert waere schlimmer als ein ehrliches unbekannt") formulierten Anspruch. Da PMG-Feldnamen die am wenigsten abgesicherte Annahme des gesamten Auftrags sind (Annahme A5, reiner Forenbeleg), ist genau dieses Szenario — ein Teilfeld feuert, das andere heisst anders — nicht hypothetisch, sondern der wahrscheinlichste erste Fehlerfall beim echten Test durch den Nutzer. Kein Test in `proxmox-normalize.spec.ts` deckt den Fall "nur eine Haelfte des Paares vorhanden" ab; alle vorhandenen Tests pruefen entweder "beide vorhanden" oder "beide fehlen".
Alle uebrigen acht Wahrheiten aus dem Plan sind vollstaendig verifiziert, mehrfach durch automatisierte Tests UND durch eigene Stichproben (Live-RLS-Abfrage gegen die tatsaechliche Dev-Datenbank, Grep-Nachzaehlung der Mandantenbindung, direkte Pruefung des Nur-Lesen-Riegels, manuelles Nachrechnen der Klammertiefen-Logik). Alle sieben Commits, alle 24 im Frontmatter genannten Dateien und alle gemessenen Torzahlen (1311/1311 API-Tests, 708/708 Web-Tests, 4/4 type-check, 5/5 lint, exakt 53 Biome-Warnungen) wurden unabhaengig nachvollzogen und stimmen exakt mit der SUMMARY ueberein.
---
_Verified: 2026-09-23T14:58Z_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,108 @@
---
phase: quick
plan: 260923-ku6
type: quick
autonomous: true
requirements: []
---
# Quick Task 260923-ku6: Drei Nachbesserungen aus dem Browser-Rundgang (Proxmox-Modul)
## Objective
Drei im Browser-Rundgang zu Quick-Task 260923-dhh gefundene Fehler beheben, ohne den
Funktionsumfang sonst zu veraendern:
1. „Verbindung testen" prueft den gespeicherten Stand statt der Formularwerte.
2. Ein frisch angelegter, noch nie abgefragter Server zeigt faelschlich die Sammelmeldung
„Ein unerwarteter Fehler ist aufgetreten" statt eines ruhigen „noch keine Abfrage"-Zustands.
3. Die CSS-Klasse `uppercase` faerbt in der Modulseiten-Zeile die ganze Zeile (inkl. Adresse)
gross statt nur das Produktkuerzel.
## Context
- Quelle: menschlicher Browser-Rundgang zu `.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-PLAN.md`.
- Betroffene Dateien: `apps/api/src/proxmox/proxmox.controller.ts`, `apps/api/src/proxmox/proxmox.service.ts`,
`apps/api/src/proxmox/dto/proxmox-server.dto.ts`, `apps/web/src/lib/proxmox-api.ts`,
`apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx`,
`apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx`,
`apps/web/src/messages/de.json`, `apps/web/src/messages/en.json`.
## Tasks
### Task 1: Verbindungstest prueft Formularwerte statt gespeicherten Stand (Befund 1)
<task type="auto">
<files>
apps/api/src/proxmox/dto/proxmox-server.dto.ts
apps/api/src/proxmox/proxmox.service.ts
apps/api/src/proxmox/proxmox.controller.ts
apps/api/src/proxmox/proxmox.service.spec.ts
apps/web/src/lib/proxmox-api.ts
apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx
</files>
<action>
Backend: neues `TestProxmoxServerDto` (alle Felder optional, wie `UpdateProxmoxServerDto`).
`POST servers/:id/test` nimmt diesen Body entgegen und mischt ihn mit dem gespeicherten
Server: pro Feld gilt „im Formular gesendet und nicht leer -> Formularwert, sonst
gespeicherter Wert" (Geheimnisfelder: nicht gesendet/leer -> gespeicherter, verschluesselter
Wert bleibt bestehen und wird wie ueblich entschluesselt). Neue Route `POST servers/test`
(ohne `:id`) fuer die Neuanlage — testet ausschliesslich mit den Formularwerten, ohne
gespeicherten Fallback. Beide Routen rufen denselben privaten Merge-Baustein auf; dieser
wird per Unit-Test abgedeckt (leeres Geheimnisfeld -> gespeicherter Wert bleibt; gefuelltes
Geheimnisfeld -> neuer Wert greift; abgeschaltete Zertifikatspruefung im Formular wird
uebernommen). Zugangsdaten weiterhin nicht in Log/Antwort (bestehende Riegel unveraendert).
Frontend: `testServer`/neue `testDraftServer`-Funktion senden immer den vollstaendigen
aktuellen Formularstand. `ServerForm` zeigt den Testen-Knopf immer (nicht nur nach dem
Speichern) und waehlt je nach `savedServer` die passende Funktion.
</action>
<verify>cd apps/api && pnpm vitest run src/proxmox/proxmox.service.spec.ts && cd ../web && pnpm vitest run src/app/\(portal\)/modules/proxmox/settings/components/ServerForm.test.tsx</verify>
<done>Ein Test zeigt: gespeicherter Server mit im Formular abgeschalteter Zertifikatspruefung
-> Testergebnis beruecksichtigt die abgeschaltete Pruefung (nicht mehr `zertifikat`-Fehler).
Ein zweiter Test zeigt: leer gelassenes Geheimnisfeld nutzt weiterhin den gespeicherten Wert.
Der Testen-Knopf funktioniert auch ohne gespeicherten Server.</done>
</task>
### Task 2: Ruhiger Zustand fuer "noch nie abgefragt" (Befund 2)
<task type="auto">
<files>
apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
</files>
<action>
Neuer Uebersetzungsschluessel `proxmox.card.notPolledYet` (DE/EN), der auf den Knopf
„Jetzt aktualisieren" verweist. In `ServerCard`: wenn `status.lastPolledAt === null` (noch
keine Abfrage gelaufen), erscheint dieser ruhige Hinweis statt des Fehlerblocks — auch wenn
`status.reachable` false ist (Zustand direkt nach dem Anlegen). Die bestehenden
Fehlermeldungen (inkl. `unbekannt`) bleiben fuer den Fall `lastPolledAt !== null &&
!reachable` unveraendert.
</action>
<verify>cd apps/web && pnpm vitest run src/app/\(portal\)/modules/proxmox/components/ServerCard.test.tsx</verify>
<done>Ein Test zeigt: Status mit `lastPolledAt: null, reachable: false, errorKind: null`
zeigt den ruhigen Hinweistext und NICHT die Meldung "Ein unerwarteter Fehler ist
aufgetreten". Bestehende Fehlermeldungs-Tests bleiben gruen.</done>
</task>
### Task 3: uppercase nur auf Produktkuerzel (Befund 3)
<task type="auto">
<files>
apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
</files>
<action>
`uppercase` von der Zeile auf ein `<span>` um `server.productType` verschieben; die Adresse
bleibt unveraendert dargestellt.
</action>
<verify>cd apps/web && pnpm vitest run src/app/\(portal\)/modules/proxmox/components/ServerCard.test.tsx</verify>
<done>Adresse erscheint in der Modulseiten-Zeile nicht mehr grossgeschrieben, Produktkuerzel weiterhin schon.</done>
</task>
## Gesamtverifikation
Nach allen drei Aufgaben: `pnpm --filter api test`, `pnpm --filter web test`,
`pnpm type-check`, `pnpm lint`, `pnpm --filter web exec biome check .` (Warnungszahl exakt
53) muessen unveraendert/gruen sein, keine Container-Neubauten, keine Browser-Pruefung.
@@ -0,0 +1,154 @@
---
phase: quick
plan: 260923-ku6
subsystem: ui
tags: [nestjs, next.js, proxmox, class-validator, vitest, next-intl, biome]
requires:
- phase: 260923-dhh
provides: Proxmox-Modul (PVE/PBS/PMG anbinden, Verbindungstest, Modulseite)
provides:
- Verbindungstest prueft Formularwerte statt gespeicherten Stand (neue Route POST servers/test, TestProxmoxServerDto, resolveEffectiveTestServer-Merge)
- Ruhiger "noch nicht abgefragt"-Zustand auf der Modulseite statt Sammelfehlermeldung
- uppercase-Klasse nur noch auf dem Produktkuerzel, nicht mehr auf der Adresse
affects: [proxmox]
actuals:
tokens: 9700
tasks: 3
commits: 2
tech-stack:
added: []
patterns:
- "Formular-vs-gespeichert-Merge fuer Verbindungstests: normale Felder folgen dem Formular (auch geleert), Geheimnisfelder folgen der 'leer -> gespeicherten Wert behalten'-Regel, weil das Formular Geheimnisse beim Laden nie vorbefuellt"
key-files:
created: []
modified:
- apps/api/src/proxmox/dto/proxmox-server.dto.ts
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/proxmox/proxmox.service.ts
- apps/api/src/proxmox/proxmox.service.spec.ts
- apps/web/src/lib/proxmox-api.ts
- "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx"
- "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.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/messages/de.json
- apps/web/src/messages/en.json
key-decisions:
- "Verbindungstest-Route POST servers/:id/test nimmt jetzt einen optionalen Body (TestProxmoxServerDto) entgegen; neue Route POST servers/test (ohne :id) deckt die Neuanlage ab, ueberschneidet sich nicht mit servers/:id/test (unterschiedliche Segmentzahl)"
- "Geheimnisfelder behalten beim Test die 'leer -> gespeicherten Wert' Sonderregel, alle anderen Felder folgen strikt dem gesendeten Formularstand (auch wenn absichtlich geleert)"
- "Befund 2+3 in einem Commit, weil beide Aenderungen in derselben Datei (ServerCard.tsx) liegen"
requirements-completed: []
coverage:
- id: D1
description: "Verbindungstest prueft Formularwerte (Zertifikatspruefung, neues Geheimnis) statt des gespeicherten Stands; leer gelassenes Geheimnisfeld nutzt weiterhin den gespeicherten Wert; Test funktioniert auch bei der Neuanlage ohne gespeicherten Server"
verification:
- kind: unit
ref: "apps/api/src/proxmox/proxmox.service.spec.ts#Nachbesserung Befund 1: testConnection prueft die im Formular abgeschaltete Zertifikatspruefung..."
status: pass
- kind: unit
ref: "apps/api/src/proxmox/proxmox.service.spec.ts#Nachbesserung Befund 1: ein im Formular NEU eingetipptes Token-Geheimnis wird getestet..."
status: pass
- kind: unit
ref: "apps/api/src/proxmox/proxmox.service.spec.ts#Nachbesserung Befund 1: leer gelassenes Geheimnisfeld im Formular nutzt weiterhin das gespeicherte Token-Geheimnis"
status: pass
- kind: unit
ref: "apps/api/src/proxmox/proxmox.service.spec.ts#Nachbesserung Befund 1: testDraftConnection testet einen noch nicht gespeicherten Server..."
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx#Nachbesserung Befund 1: bei der Neuanlage ... steht der Testen-Knopf zur Verfuegung..."
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx#Nachbesserung Befund 1: der Test prueft die im Formular abgeschaltete Zertifikatspruefung..."
status: pass
human_judgment: true
rationale: "Browser-Pruefung des tatsaechlichen Verhaltens macht der Orchestrator danach (per Auftrag ausgeschlossen aus diesem Lauf)"
- id: D2
description: "Frisch angelegter, noch nie abgefragter Server zeigt einen ruhigen Hinweis statt der Sammelfehlermeldung 'Ein unerwarteter Fehler ist aufgetreten'"
verification:
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx#Nachbesserung Befund 2: ein frisch angelegter, noch nie abgefragter Server..."
status: pass
human_judgment: true
rationale: "Browser-Pruefung des tatsaechlichen Verhaltens macht der Orchestrator danach (per Auftrag ausgeschlossen aus diesem Lauf)"
- id: D3
description: "Adresse in der Modulseiten-Zeile nicht mehr grossgeschrieben, nur noch das Produktkuerzel"
verification:
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx#Nachbesserung Befund 3: die Adresse bleibt unveraendert dargestellt..."
status: pass
human_judgment: false
duration: 45min
completed: 2026-09-23
status: complete
---
# Quick Task 260923-ku6: Drei Nachbesserungen aus dem Browser-Rundgang (Proxmox-Modul) Summary
**Verbindungstest folgt jetzt dem Formular statt dem gespeicherten Server, ein frisch angelegter Server zeigt einen ruhigen "noch nicht abgefragt"-Hinweis statt einer falschen Fehlermeldung, und die Adresse in der Modulseiten-Zeile ist nicht mehr grossgeschrieben.**
## Performance
- **Duration:** ~45 min
- **Tasks:** 3
- **Files modified:** 11
## Accomplishments
- **Befund 1:** `POST servers/:id/test` prueft jetzt den aktuellen Formularstand (Zertifikatspruefung, Token-/Passwort-Geheimnis, Adresse, Zugangsart) statt blind des gespeicherten Servers; neue Route `POST servers/test` deckt denselben Test waehrend der Neuanlage ab, wo es noch keinen gespeicherten Server gibt. Geheimnisfelder behalten die Sonderregel "leer gelassen -> gespeicherten Wert weiterverwenden", weil `ServerForm` sie beim Laden nie aus der Datenbank vorbefuellt.
- **Befund 2:** Ein frisch angelegter, noch nie abgefragter Server (`status.lastPolledAt === null`) zeigt einen ruhigen Hinweistext, der auf "Jetzt aktualisieren" verweist, statt der Sammelmeldung "Ein unerwarteter Fehler ist aufgetreten". Echte Fehlermeldungen bleiben fuer bereits abgefragte, aber nicht erreichbare Server unveraendert.
- **Befund 3:** Die `uppercase`-Klasse sitzt jetzt nur noch auf dem Produktkuerzel (`<span>`), nicht mehr auf der ganzen Statuszeile — die Adresse erscheint wieder wie eingegeben.
## Task Commits
1. **Task 1: Verbindungstest prueft Formularwerte statt gespeicherten Stand (Befund 1)** - `710034c` (fix)
2. **Task 2+3: Ruhiger "noch nicht abgefragt"-Zustand und Adresse ohne Grossschreibung (Befund 2+3)** - `f1bb7f7` (fix)
_Beide Aufgaben von Befund 2 und 3 liegen in derselben Datei (`ServerCard.tsx`) und wurden deshalb in einem Commit zusammengefasst — Begruendung steht in der Commit-Nachricht._
## Files Created/Modified
- `apps/api/src/proxmox/dto/proxmox-server.dto.ts` - neues `TestProxmoxServerDto`
- `apps/api/src/proxmox/proxmox.controller.ts` - `test` nimmt jetzt einen Body entgegen, neue Route `testDraft` (`POST servers/test`)
- `apps/api/src/proxmox/proxmox.service.ts` - `resolveEffectiveTestServer`-Merge, `testConnection` mit `dto`-Parameter, neue `testDraftConnection`
- `apps/api/src/proxmox/proxmox.service.spec.ts` - 5 neue Tests fuer Befund 1
- `apps/web/src/lib/proxmox-api.ts` - `testServer` nimmt jetzt ein Payload, neue `testDraftServer`
- `apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx` - Testen-Knopf immer sichtbar, sendet immer den Formularstand
- `apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx` - alte "kein Knopf vor dem Speichern"-Erwartung durch das neue, gewuenschte Verhalten ersetzt, 2 neue Tests
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx` - ruhiger "noch nicht abgefragt"-Zustand, `uppercase` nur auf dem Produktkuerzel
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx` - 2 neue Tests
- `apps/web/src/messages/de.json`, `apps/web/src/messages/en.json` - neuer Schluessel `proxmox.card.notPolledYet`
## Decisions Made
- Geheimnisfelder (`tokenSecret`/`password`) folgen beim Testen weiterhin der bestehenden "leer -> gespeicherten Wert behalten"-Regel aus `updateServer`, weil `ServerForm` sie beim Laden absichtlich nie vorbefuellt (kein Klartext-Leak). Alle anderen Felder (`tokenId`, `username`, `baseUrl`, `authMethod`, `productType`, `tlsRejectUnauthorized`) folgen strikt dem gesendeten Formularwert, auch wenn er absichtlich geleert wurde — diese Felder sind beim Laden immer vorbefuellt, ein leeres Feld ist dort also eine bewusste Nutzeraktion.
- Neue Route `POST servers/test` statt eines Sonderwerts fuer `:id` (z. B. `new`), weil sie sich mit `servers/:id/test` nicht ueberschneidet (zwei vs. drei Segmente) und dadurch keine Routen-Reihenfolge-Abhaengigkeit entsteht.
- `buildTestPayload()` in `ServerForm.tsx` sendet bewusst kein `name`-Feld, weil `TestProxmoxServerDto` `@IsNotEmpty()` auf `name` erbt und ein waehrend der Neuanlage noch leeres Namensfeld sonst jeden Testklick mit 400 blockiert hätte.
## Deviations from Plan
None - plan executed exactly as written (PLAN.md `.planning/quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/260923-ku6-PLAN.md`).
## Issues Encountered
- Die Aenderung an `ServerCard.tsx` (`status && status.lastPolledAt && !status.reachable`) loeste eine neue Biome-Warnung (`lint/complexity/useOptionalChain`) aus, die die geforderte exakte Warnungszahl (53) auf 54 angehoben haette. Behoben durch Umformulierung zu `status?.lastPolledAt && !status.reachable` (TypeScript narrowt `status` fuer den Rest des Ausdrucks korrekt nach) — Warnungszahl danach wieder exakt 53.
- Der bestehende Test "ohne gespeicherten Server (Neuanlage) gibt es keinen Verbindung-testen-Knopf" widersprach direkt der geforderten Korrektur aus Befund 1 (Testen soll bei der Neuanlage funktionieren) und wurde durch einen Test mit dem neuen, gewuenschten Verhalten ersetzt.
## User Setup Required
None - keine externe Konfiguration noetig.
## Next Phase Readiness
Alle drei Befunde behoben, alle Tore gruen (api 1316/1316, web 712/712, type-check 4/4, lint 5/5, Biome `apps/web` exakt 53 Warnungen). Browser-Pruefung der tatsaechlichen UI macht der Orchestrator im Anschluss, wie im Auftrag verlangt.
---
*Phase: quick-260923-ku6*
*Completed: 2026-09-23*
@@ -0,0 +1,221 @@
---
phase: quick
plan: 260923-le6
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/proxmox/proxmox-normalize.ts
- apps/api/src/proxmox/proxmox-normalize.spec.ts
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
autonomous: true
requirements: []
estimate:
tokens: 45000
raw_tokens: 45000
tasks: 3
confidence: low
must_haves:
truths:
- "PMG: fehlt von einem Paar (spamcount_in/spamcount_out bzw. viruscount_in/viruscount_out) genau eine Haelfte, ist spamCount bzw. virusCount null (Anzeige 'unbekannt') — in beide Richtungen, nie eine Teilsumme"
- "PMG: sind beide Haelften vorhanden, bleibt die Summe wie bisher (10+2 -> 12); fehlen beide, bleibt null"
- "Auf der Proxmox-Modulseite sehen nur ADMIN und SUPER_ADMIN den Knopf 'Jetzt aktualisieren'; USER (und ein noch nicht geladener Benutzer) sehen ihn nicht"
- "Ein noch nie abgefragter Server zeigt Admins weiterhin 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'; Nicht-Admins sehen stattdessen einen Text ohne Verweis auf den Knopf"
- "Sonst aendert sich an der Modulseite nichts (Festlegung: kein Umbau, keine zusaetzlichen Details/Statusfarben)"
artifacts:
- path: apps/api/src/proxmox/proxmox-normalize.ts
provides: "sumOrNull liefert null, sobald ein Teilwert null ist"
- path: apps/api/src/proxmox/proxmox-normalize.spec.ts
provides: "Testfaelle 'nur eine Haelfte vorhanden -> null' fuer Spam und Viren, beide Richtungen"
- path: apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
provides: "optionale Eigenschaft isAdmin (Vorgabe false), waehlt den Hinweistext fuer noch nie abgefragte Server"
- path: apps/web/src/app/(portal)/modules/proxmox/page.tsx
provides: "Aktualisieren-Knopf nur fuer Admins, reicht isAdmin an ServerCard weiter"
- path: apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
provides: "Seitentest: Knopf sichtbar fuer ADMIN/SUPER_ADMIN, unsichtbar fuer USER/null"
key_links:
- from: "apps/web/src/app/(portal)/modules/proxmox/page.tsx"
to: "ServerCard"
via: "isAdmin={isAdmin}"
pattern: "isAdmin=\\{isAdmin\\}"
- from: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx"
to: "apps/web/src/messages/de.json proxmox.card.notPolledYetAutomatic"
via: "t('card.notPolledYetAutomatic')"
pattern: "card\\.notPolledYetAutomatic"
---
<objective>
Zwei Befunde aus der Abnahme des Proxmox-Moduls beheben, sonst nichts:
1. **PMG-Teilsumme (API):** `sumOrNull(a, b)` in `apps/api/src/proxmox/proxmox-normalize.ts` addiert heute einen fehlenden Teilwert als 0, sobald nur EINE Haelfte null ist. Dadurch zeigt die Seite z. B. „Spam: 10“ als vollstaendige Tageszahl, obwohl `spamcount_out` fehlte. Das ist der in `.planning/quick/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-VERIFICATION.md` (Wahrheit 7, Blocker) belegte Fehler. Kuenftig ist die Summe null, sobald ein Teilwert null ist.
2. **Aktualisieren-Knopf nur fuer Admins (Web):** Der Knopf „Jetzt aktualisieren“ erscheint heute bei allen, die Zugriff auf das Modul haben. Der Endpunkt `POST servers/:id/poll` verlangt aber `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` (`apps/api/src/proxmox/proxmox.controller.ts:89-90`), deshalb passiert beim Klick fuer alle anderen nichts. Kuenftig sehen nur ADMIN/SUPER_ADMIN den Knopf. Der Hinweis „Noch keine Abfrage gelaufen. Klicken Sie oben auf …“ darf Nicht-Admins nicht mehr auf einen Knopf verweisen, den sie nicht sehen.
**Festlegung (locked, vom Nutzer):** KEIN Umbau der Proxmox-Modulseite. Der Nutzer hat seinen Wunsch nach mehr Details bzw. Statusfarben ausdruecklich zurueckgezogen. Nur diese zwei Korrekturen, keine weiteren Anzeige-, Layout- oder Textaenderungen.
Hinweis zum Zuschnitt: Tracer-first entfaellt (wie `--no-tracer`). Es handelt sich um zwei voneinander unabhaengige Fehlerkorrekturen, jede in genau einer Schicht, ohne neue Architektur, die ein Durchstich absichern muesste. Aufgabe 1 (API) und Aufgabe 2/3 (Web) beruehren keine gemeinsamen Dateien. Aufgabe 3 braucht die Eigenschaft `isAdmin` aus Aufgabe 2.
Purpose: Das Modul soll keinen still falschen, plausibel aussehenden Wert zeigen (eigener Anspruch in `proxmox-normalize.ts` und `ServerCard.tsx`: „ein still falscher Wert waere schlimmer als ein ehrliches unbekannt“). Ausserdem soll kein Knopf erscheinen, der fuer den Betrachter wirkungslos ist.
Output: korrigierte `sumOrNull` samt Tests; `ServerCard` mit `isAdmin`-Eigenschaft und einem zweiten Hinweistext in de/en; Modulseite, die den Knopf nur Admins zeigt, samt neuem Seitentest.
</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/260923-dhh-proxmox-modul-pve-pbs-und-pmg-anbinden-n/260923-dhh-VERIFICATION.md
@apps/api/src/proxmox/proxmox-normalize.ts
@apps/api/src/proxmox/proxmox-normalize.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)/modules/proxmox/components/ServerCard.test.tsx
@apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx
<interfaces>
Bereits im Code vorhanden (vom Planer gelesen, nicht erneut suchen):
- `apps/web/src/lib/stores/auth-store.ts`: `useAuthStore((s) => s.user)`, `user.role` ist `'SUPER_ADMIN' | 'ADMIN' | 'USER'`.
- `page.tsx` berechnet BEREITS `const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';` (Zeile 18-19) und nutzt es fuer den Einstellungs-Link. Das ist das etablierte Muster; es wird kein neuer Mechanismus eingefuehrt.
- `apps/web/src/lib/proxmox-api.ts`: `listServers(): Promise<ProxmoxServer[]>`, `pollServer(id: string): Promise<ProxmoxTestResult>`, Typ `ProxmoxServer` (inkl. `isActive`, `pollIntervalMin`, `status: ProxmoxServerStatus | null`).
- `ServerCard` wird ausschliesslich in `page.tsx:99` verwendet (per grep geprueft).
- Test-Muster fuer Rollen: `settings-roles.test.tsx` mockt `@/lib/stores/auth-store` mit `useAuthStore: (selector) => mockAuthStore(selector)` und setzt je Fall `mockAuthStore.mockImplementation((sel) => sel({ user }))`, ausserdem `next/link` als `<a>` und `next-intl` mit handgeschriebener Uebersetzungstabelle.
- Nachrichtendateien: nur `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json`. Namensraum `proxmox.card` (de.json ab Zeile 712). `umlaut-guard.spec.ts` prueft de.json auf Ersatzschreibungen (ae/oe/ue/ss) — neue deutsche Texte brauchen echte Umlaute.
- Abfragetakt: `ProxmoxServer.pollIntervalMin` Vorgabe 5, erlaubt 1–1440 (`dto/proxmox-server.dto.ts` `@Min(1) @Max(1440)`). Der Planer laeuft je Mandant im kleinsten Intervall der aktiven Server (`proxmox-scheduler.service.ts`). Inaktive Server (`isActive: false`) werden nicht automatisch abgefragt.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Aufgabe 1: PMG-Summe wird null, sobald eine Haelfte fehlt (sumOrNull)</name>
<files>apps/api/src/proxmox/proxmox-normalize.ts, apps/api/src/proxmox/proxmox-normalize.spec.ts</files>
<read_first>apps/api/src/proxmox/proxmox-normalize.ts (Zeilen 222-270), apps/api/src/proxmox/proxmox-normalize.spec.ts (Zeilen 185-240)</read_first>
<behavior>
- Nur `spamcount_in: 10` vorhanden, `spamcount_out` fehlt -> `spamCount` ist `null` (heute faelschlich 10)
- Nur `spamcount_out: 2` vorhanden, `spamcount_in` fehlt -> `spamCount` ist `null`
- Nur `viruscount_in: 1` vorhanden, `viruscount_out` fehlt -> `virusCount` ist `null`
- Nur `viruscount_out: 3` vorhanden, `viruscount_in` fehlt -> `virusCount` ist `null`
- Eine Haelfte vorhanden, die andere ist nicht lesbar (z. B. `spamcount_out: 'abc'`, `readNumber` liefert null) -> `spamCount` ist `null`
- Unabhaengigkeit der Paare: Spam unvollstaendig, Viren vollstaendig (`viruscount_in: 1, viruscount_out: 0`) -> `spamCount` null, `virusCount` 1; `countIn`/`countOut` bleiben unberuehrt
- Unveraendert gruen: die bestehenden Tests „beide vorhanden -> 12/1“, „beide fehlen -> null“, „HTML -> antwortform“
</behavior>
<action>
RED: Im bestehenden `describe('normalizePmg (Aufgabe 3, <behavior>)', ...)`-Block von `proxmox-normalize.spec.ts` neue Faelle fuer jede Zeile aus `<behavior>` ergaenzen. Das geht als einzelne `it` oder als `it.each` ueber eine Tabelle {Beschreibung, data, erwartetes spamCount, erwartetes virusCount}. Jeder Testname nennt „nur eine Haelfte vorhanden -> null“ und die Richtung (in bzw. out) sowie Spam bzw. Viren. Vorhandene Tests unveraendert lassen. Der Planer hat geprueft, dass keiner das alte Verhalten festschreibt: Die vorhandenen PMG-Tests decken nur „beide vorhanden“ und „beide fehlen“ ab, und `proxmox.service.spec.ts:350-360` liefert beide Haelften (`spamcount_in: 1, spamcount_out: 0`). Test ausfuehren, die neuen Faelle muessen ROT sein. Commit `test(260923-le6): PMG-Teilsumme ohne Haelfte muss null sein`.
GREEN: `sumOrNull(a, b)` so aendern, dass es `null` zurueckgibt, sobald `a` ODER `b` `null` ist. Nur wenn beide Zahlen sind, wird ihre Summe zurueckgegeben. Die bisherige Ersatz-durch-Null-Addition entfaellt vollstaendig, ein fehlender Teilwert wird nie mehr als 0 behandelt. Ueber der Funktion einen kurzen deutschen Kommentar ergaenzen (Stil der Datei, ASCII-Umschreibungen wie im Rest der Datei): Eine Tageszahl aus zwei Teilwerten ist nur dann bekannt, wenn beide Teilwerte bekannt sind; eine Teilsumme saehe vollstaendig aus, waere aber still falsch (Abnahmebefund 260923-dhh, Wahrheit 7; PMG-Feldnamen sind nur Annahme A5). `normalizePmg` selbst und die Feldtabelle `PMG_STATS_FIELDS` bleiben unveraendert. Tests muessen GRUEN sein. Commit `fix(260923-le6): PMG-Summe null bei fehlendem Teilwert`.
</action>
<verify>
<automated>pnpm --filter api exec vitest run src/proxmox</automated>
<automated>test "$(grep -v '^\s*//' apps/api/src/proxmox/proxmox-normalize.ts | grep -c '?? 0) + (')" -eq 0</automated>
<automated>pnpm --filter api type-check</automated>
</verify>
<done>Alle Tests unter `apps/api/src/proxmox` gruen, darunter mindestens 5 neue Faelle „nur eine Haelfte vorhanden -> null“ (Spam in/out, Viren in/out, nicht lesbare Haelfte). Die Ersatz-durch-Null-Addition steht nicht mehr in `proxmox-normalize.ts`. API-Typpruefung ohne Fehler.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: ServerCard waehlt den Hinweistext nach Rolle (isAdmin) und zweiter Text in de/en</name>
<files>apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx (Zeilen 150-215), apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx, apps/web/src/messages/de.json (Zeilen 706-720), apps/web/src/messages/en.json (Zeilen 706-720)</read_first>
<behavior>
- `isAdmin` gesetzt, Server nie abgefragt (`status.lastPolledAt === null`) -> Text „Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.“ (wie heute)
- `isAdmin={false}`, Server nie abgefragt -> Text „Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.“, und nirgends in der Karte steht „Jetzt aktualisieren“
- `isAdmin` weggelassen -> verhaelt sich wie `isAdmin={false}` (sichere Vorgabe)
- Weiterhin in keinem der Faelle die Sammelmeldung „Unerwarteter Fehler.“
</behavior>
<action>
Umsetzung der zweiten Korrektur, Teil Karte.
(a) Nachrichten: In `apps/web/src/messages/de.json` unter `proxmox.card`, direkt nach `notPolledYet`, den neuen Schluessel `notPolledYetAutomatic` mit dem Wert „Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.“ anlegen. Echtes „ä“ verwenden (umlaut-guard), Sie-Form bzw. unpersoenlich wie die uebrigen App-Texte. In `apps/web/src/messages/en.json` an derselben Stelle `notPolledYetAutomatic`: „No poll has run yet. The values will appear after the next automatic poll.“ Andere Schluessel nicht anfassen; `notPolledYet`, `refresh` und `refreshing` bleiben unveraendert. Begruendung der Wortwahl (Planer-Ermessen, Vorschlag aus dem Auftrag angepasst): Ein „in Kürze“ waere nicht immer wahr, denn das Intervall ist je Server von 1 bis 1440 Minuten einstellbar (`@Max(1440)`). „Nach der nächsten automatischen Abfrage“ stimmt bei jedem Intervall und verweist auf keinen Knopf.
(b) `ServerCard.tsx`: `ServerCardProps` um die optionale Eigenschaft `isAdmin?: boolean` erweitern und in der Funktionssignatur mit Vorgabe `false` entgegennehmen. Die sichere Vorgabe bedeutet: Wer die Eigenschaft vergisst, zeigt keinen Verweis auf einen Knopf. Im vorhandenen Zweig fuer nie abgefragte Server (`status && !status.lastPolledAt`) den Text nach `isAdmin` waehlen. Ist `isAdmin` wahr, bleibt der heutige Aufruf `t('card.notPolledYet', { refreshLabel: t('card.refresh') })` unveraendert, sonst `t('card.notPolledYetAutomatic')`. Den Kommentar „Nachbesserung Befund 2“ um einen Satz ergaenzen: Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis (260923-le6). Sonst NICHTS an der Karte aendern, auch keine Formatierung unbeteiligter Zeilen (Festlegung: kein Umbau). Insbesondere kein `biome format --write` auf die ganze Datei, das wuerde unbeteiligte Zeilen umbrechen.
(c) `ServerCard.test.tsx`: In die `next-intl`-Mock-Tabelle `'card.notPolledYetAutomatic'` mit dem deutschen Text aus (a) aufnehmen. Den bestehenden Test „Nachbesserung Befund 2: …“ auf `render(<ServerCard server={server} isAdmin />)` umstellen; seine Erwartungen bleiben. Neue Tests fuer die Faelle aus `<behavior>` ergaenzen: `isAdmin={false}` sowie weggelassenes `isAdmin` jeweils mit Erwartung des automatischen Textes, `queryByText(/Jetzt aktualisieren/)` ist `null` und `queryByText('Unerwarteter Fehler.')` ist `null`. Zuerst die Tests schreiben und ROT sehen, dann (a)+(b) umsetzen und GRUEN sehen. Ein Commit genuegt: `fix(260923-le6): Proxmox-Karte verweist Nicht-Admins nicht auf den Aktualisieren-Knopf`.
</action>
<verify>
<automated>pnpm --filter web exec vitest run "src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx" src/messages</automated>
<automated>node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');const a=d.proxmox.card.notPolledYetAutomatic,b=e.proxmox.card.notPolledYetAutomatic;if(!a||!b||/aktualisieren/i.test(a)||/refresh/i.test(b)||!a.includes('nächsten'))process.exit(1);if(d.proxmox.card.notPolledYet!=='Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.')process.exit(2)"</automated>
</verify>
<done>ServerCard-Tests gruen (bestehende und neue Admin-/Nicht-Admin-Faelle); `src/messages`-Tests (umlaut-guard, Paritaet) gruen. `notPolledYetAutomatic` existiert in de und en und erwaehnt keinen Aktualisieren-Knopf. `notPolledYet` ist unveraendert.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: Modulseite zeigt „Jetzt aktualisieren“ nur ADMIN/SUPER_ADMIN, mit Seitentest</name>
<files>apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx</files>
<read_first>apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx (Zeilen 1-100, nur das Mock-Muster)</read_first>
<behavior>
- Rolle USER, eine Serverliste mit einem nie abgefragten Server -> kein Knopf mit Namen „Jetzt aktualisieren“; der Karten-Hinweis ist der automatische Text
- Kein Benutzer geladen (`user: null`) -> kein Knopf
- Rolle ADMIN -> Knopf „Jetzt aktualisieren“ sichtbar; der Karten-Hinweis ist der Admin-Text mit Knopfverweis
- Rolle SUPER_ADMIN -> Knopf sichtbar
- Leere Serverliste bei ADMIN -> weiterhin kein Knopf (bestehende Bedingung `servers.length > 0` bleibt)
</behavior>
<action>
Umsetzung der zweiten Korrektur, Teil Seite.
(a) Neue Testdatei `apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx` nach dem Muster von `settings-roles.test.tsx` anlegen. Gemockt werden `@/lib/proxmox-api` (`listServers` als `vi.fn()`, der je Fall eine Liste aufloest, und `pollServer` als `vi.fn()`), `@/lib/stores/auth-store` (Selektor-Durchreichung ueber `mockAuthStore`), `next/link` (als `<a>`) und `next-intl`. Die handgeschriebene Uebersetzungstabelle enthaelt mindestens `title`, `description`, `loading`, `loadError`, `emptyState`, `card.refresh`, `card.refreshing`, `card.settingsLink`, `card.unknownValue`, `card.lastPolledLabel`, `card.notPolledYet` (mit `{refreshLabel}`-Ersetzung wie in `ServerCard.test.tsx`) und `card.notPolledYetAutomatic`. Die Seite ueber `import ProxmoxPage from './page'` rendern. Mit `waitFor`/`findByText` auf den Servernamen warten, weil `listServers` asynchron ist. Danach Knopf per `queryByRole('button', { name: 'Jetzt aktualisieren' })` bzw. `getByRole` pruefen. Ein Testfall je Zeile aus `<behavior>`; der Server im Test ist ein nie abgefragter Server (Status wie im Befund-2-Test von `ServerCard.test.tsx`: `lastPolledAt: null`, `reachable: false`, `errorKind: null`). `afterEach` mit `cleanup()` und `vi.clearAllMocks()`. Test ausfuehren, die USER- und null-Faelle muessen ROT sein.
(b) `page.tsx`: Die bestehende Bedingung des Aktualisieren-Knopfs (`servers !== null && servers.length > 0`) zusaetzlich an `isAdmin` knuepfen, sodass der Knopf nur fuer ADMIN/SUPER_ADMIN gerendert wird. Die vorhandene Variable `isAdmin` wiederverwenden, keinen neuen Rollen-Mechanismus einfuehren. `handleRefresh` bleibt unveraendert. An der Render-Stelle `<ServerCard server={server} />` die Eigenschaft `isAdmin={isAdmin}` weiterreichen. Den Kopfkommentar der Komponente um einen Satz ergaenzen: Der Knopf erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6). Sonst nichts an der Seite aendern: keine neuen Texte, kein Layout, keine Import-Umsortierung. Das vorbestehende organizeImports-Signal von biome in dieser Datei bleibt unangetastet.
Tests GRUEN sehen. Commit `fix(260923-le6): Aktualisieren-Knopf der Proxmox-Seite nur fuer Admins`.
</action>
<verify>
<automated>pnpm --filter web exec vitest run "src/app/(portal)/modules/proxmox"</automated>
<automated>grep -c 'isAdmin={isAdmin}' "apps/web/src/app/(portal)/modules/proxmox/page.tsx"</automated>
<automated>pnpm --filter web type-check</automated>
</verify>
<done>Alle Web-Tests im Proxmox-Verzeichnis gruen (ServerCard, ServerForm, neuer Seitentest mit mindestens 5 Faellen: USER, null, ADMIN, SUPER_ADMIN, leere Liste). `page.tsx` reicht `isAdmin={isAdmin}` an `ServerCard` weiter. Web-Typpruefung ohne Fehler.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> API `POST /proxmox/servers/:id/poll` | Nicht-Admin koennte die Abfrage manuell ausloesen; die Berechtigung prueft ausschliesslich der Server (`@Roles(ADMIN, SUPER_ADMIN)`) |
| PMG-Server -> `normalizePmg` | Fremde, nur angenommene Antwortform (Annahme A5); unvollstaendige Felder duerfen keinen falschen Wert erzeugen |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-le6-01 | Elevation of Privilege | `POST servers/:id/poll` | low | accept | Das Ausblenden des Knopfes ist reine Oberflaeche, keine Sicherheitsgrenze. Die Durchsetzung bleibt unveraendert serverseitig per `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` in `proxmox.controller.ts:89-90`. Dieser Plan aendert den Controller nicht. |
| T-le6-02 | Tampering (Integritaet der Anzeige) | `sumOrNull` in `normalizePmg` | medium | mitigate | Aufgabe 1: Summe null, sobald ein Teilwert fehlt oder unlesbar ist. Die neuen Tests decken beide Richtungen fuer Spam und Viren ab. |
| T-le6-03 | Information Disclosure | `ServerCard` Hinweistext | low | accept | Der neue Text enthaelt keine Server- oder Zugangsdaten, nur einen statischen Hinweis. |
</threat_model>
<verification>
Nach allen drei Aufgaben (vom Planer an der Ausgangslage 6530ae5 geprueft: alles gruen, `biome lint` sauber):
- `pnpm --filter api exec vitest run src/proxmox` gruen
- `pnpm --filter web exec vitest run "src/app/(portal)/modules/proxmox" src/messages` gruen
- `pnpm --filter api type-check` und `pnpm --filter web type-check` ohne Fehler
- `pnpm exec biome lint apps/api/src/proxmox/proxmox-normalize.ts apps/api/src/proxmox/proxmox-normalize.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)/modules/proxmox/components/ServerCard.test.tsx" "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx"` ohne Befund
- `pnpm exec biome check "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx"` ohne Befund (neue Datei, voll konform)
- `biome check` auf den fuenf VORHANDENEN Dateien: vorher 6 Befunde, alle vorbestehend (Formatierung je Datei, dazu organizeImports in `page.tsx`). Deren Anzahl darf nicht steigen. Die vorbestehenden Befunde werden nicht mit behoben, das waere fremder Diff (Festlegung: kein Umbau).
- Keine Container-Neubauten, kein Deploy, keine Browserpruefung in diesem Plan
</verification>
<success_criteria>
- Eine PMG-Antwort mit nur einer Haelfte eines Spam- oder Viren-Paares ergibt `null`, die Seite zeigt dort also „unbekannt“ statt einer Teilsumme.
- Auf der Proxmox-Modulseite sehen nur ADMIN und SUPER_ADMIN „Jetzt aktualisieren“. Der Hinweis fuer nie abgefragte Server verweist Nicht-Admins auf die automatische Abfrage statt auf den Knopf.
- Sonst keine sichtbare Aenderung an der Modulseite.
- In der SUMMARY als Beobachtung vermerken, nicht beheben: Inaktive Server (`isActive: false`) werden nicht automatisch abgefragt. Fuer einen inaktiven, nie abgefragten Server stimmt der neue Nicht-Admin-Text deshalb nicht ganz. Das ist ein vorbestehender Randfall, denn auch die Karte fuer Admins beachtet `isActive` heute nicht. Er liegt ausserhalb dieses Auftrags (Festlegung: kein Umbau) und wird dem Nutzer zur Entscheidung vorgelegt.
- In der SUMMARY vermerken, dass damit die offene Luecke (Wahrheit 7) aus `260923-dhh-VERIFICATION.md` geschlossen ist.
</success_criteria>
<output>
Create `.planning/quick/260923-le6-proxmox-abnahmebefunde-sumornull-null-be/260923-le6-SUMMARY.md` when done
</output>
@@ -0,0 +1,174 @@
---
phase: quick
plan: 260923-le6
subsystem: proxmox-modul
tags: [nestjs, next-intl, vitest, tdd, proxmox]
requires:
- phase: quick-260923-dhh
provides: "Proxmox-Modul (PVE/PBS/PMG) inklusive normalizePmg und ServerCard; Abnahmebefund Wahrheit 7 (PMG-Teilsumme) blieb offen"
provides:
- "sumOrNull liefert null, sobald ein Teilwert einer PMG-Summe (Spam/Viren) fehlt oder unlesbar ist — nie mehr eine Teilsumme"
- "ServerCard zeigt Nicht-Admins fuer nie abgefragte Server einen Hinweis ohne Knopfverweis (isAdmin-Eigenschaft, Vorgabe false)"
- "Proxmox-Modulseite zeigt den Knopf 'Jetzt aktualisieren' nur ADMIN/SUPER_ADMIN"
affects: [proxmox-modul, dashboard-kachel-proxmox]
actuals:
tokens: 4581
tasks: 3
commits: 4
plan_head_before: 6530ae5
tech-stack:
added: []
patterns:
- "isAdmin?: boolean (Vorgabe false) als sichere Eigenschaft fuer UI-Elemente, deren serverseitige Aktion rollenbeschraenkt ist (uebernimmt das bestehende Muster aus page.tsx, kein neuer Mechanismus)"
key-files:
created:
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
modified:
- apps/api/src/proxmox/proxmox-normalize.ts
- apps/api/src/proxmox/proxmox-normalize.spec.ts
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
key-decisions:
- "Wortwahl fuer notPolledYetAutomatic: 'nach der naechsten automatischen Abfrage' statt 'in Kuerze', weil das Poll-Intervall je Server 1-1440 Minuten einstellbar ist und 'in Kuerze' nicht immer zutraefe"
patterns-established:
- "sumOrNull(a, b): null wenn a ODER b null ist (statt Ersatz-durch-Null) — Muster fuer jede zukuenftige Tageszahl aus zwei Teilwerten"
requirements-completed: []
coverage:
- id: D1
description: "PMG-Summe (Spam/Viren) ist null, sobald genau eine Haelfte fehlt oder unlesbar ist — in beide Richtungen (in/out), Paare unabhaengig voneinander"
verification:
- kind: unit
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#nur eine Haelfte vorhanden -> null (Spam, nur spamcount_in)"
status: pass
- kind: unit
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#nur eine Haelfte vorhanden -> null (Spam, nur spamcount_out)"
status: pass
- kind: unit
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#nur eine Haelfte vorhanden -> null (Viren, nur viruscount_in)"
status: pass
- kind: unit
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#nur eine Haelfte vorhanden -> null (Viren, nur viruscount_out)"
status: pass
- kind: unit
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#eine Haelfte ist nicht lesbar -> null (Spam, spamcount_out ist Text)"
status: pass
- kind: unit
ref: "apps/api/src/proxmox/proxmox-normalize.spec.ts#Unabhaengigkeit der Paare: Spam unvollstaendig, Viren vollstaendig"
status: pass
human_judgment: false
- id: D2
description: "Auf der Proxmox-Modulseite sehen nur ADMIN/SUPER_ADMIN den Knopf 'Jetzt aktualisieren'; USER und ein noch nicht geladener Benutzer sehen ihn nicht; ServerCard verweist Nicht-Admins nicht auf den Knopf"
verification:
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#Rolle USER: kein Knopf"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#kein Benutzer geladen (user: null): kein Knopf"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#Rolle ADMIN: Knopf sichtbar"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#Rolle SUPER_ADMIN: Knopf sichtbar"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx#leere Serverliste bei ADMIN: weiterhin kein Knopf"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx#260923-le6: isAdmin={false}, noch nie abgefragt -> automatischer Hinweis ohne Knopfverweis"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx#260923-le6: isAdmin weggelassen -> verhaelt sich wie isAdmin={false}"
status: pass
human_judgment: false
duration: 21min
completed: 2026-09-23
status: complete
---
# Quick Task 260923-le6: Zwei Abnahmebefunde des Proxmox-Moduls behoben Summary
**PMG-Teilsumme wird null statt still falsch (sumOrNull), Aktualisieren-Knopf der Proxmox-Modulseite nur noch fuer ADMIN/SUPER_ADMIN sichtbar**
## Performance
- **Duration:** 21 min
- **Started:** 2026-09-23T13:12:00Z
- **Completed:** 2026-09-23T13:33:50Z
- **Tasks:** 3
- **Files modified:** 8 (7 geaendert, 1 neu)
## Accomplishments
- `sumOrNull(a, b)` in `proxmox-normalize.ts` liefert `null`, sobald ein Teilwert (Spam oder Viren, je Richtung in/out) fehlt oder nicht lesbar ist — die bisherige stille Ersatz-durch-0-Addition ist vollstaendig entfernt. Damit ist Wahrheit 7 (Blocker) aus `260923-dhh-VERIFICATION.md` geschlossen.
- `ServerCard` bekommt die optionale Eigenschaft `isAdmin` (Vorgabe `false`) und zeigt Nicht-Admins fuer einen nie abgefragten Server einen neuen Hinweistext (`proxmox.card.notPolledYetAutomatic`, de/en), der auf keinen Knopf verweist.
- Die Proxmox-Modulseite zeigt den Knopf "Jetzt aktualisieren" nur noch, wenn `isAdmin` wahr ist (bestehende Variable wiederverwendet, kein neuer Rollen-Mechanismus), und reicht `isAdmin` an `ServerCard` weiter.
## Task Commits
Alle Aufgaben wurden per TDD (RED -> GREEN) umgesetzt und einzeln committet:
1. **Aufgabe 1 (RED): PMG-Teilsumme-Tests** - `c13d657` (test)
2. **Aufgabe 1 (GREEN): sumOrNull korrigiert** - `2eb86e1` (fix)
3. **Aufgabe 2: ServerCard mit isAdmin und zweitem Hinweistext** - `2f8dd14` (fix)
4. **Aufgabe 3: Aktualisieren-Knopf nur fuer Admins** - `e1b191b` (fix)
_Hinweis: Aufgabe 1 hatte planmaessig zwei Commits (RED/GREEN); Aufgaben 2 und 3 wurden je in einem Commit umgesetzt, wie im Plan vorgesehen (Tests zuerst rot gesehen, dann implementiert, ein Commit je Aufgabe)._
## Files Created/Modified
- `apps/api/src/proxmox/proxmox-normalize.ts` - `sumOrNull` liefert `null` bei fehlendem Teilwert statt Ersatz-durch-0
- `apps/api/src/proxmox/proxmox-normalize.spec.ts` - 6 neue Testfaelle fuer beide Richtungen (Spam/Viren), unlesbare Haelfte, Unabhaengigkeit der Paare
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx` - neue `isAdmin`-Eigenschaft (Vorgabe `false`), waehlt den Hinweistext fuer nie abgefragte Server
- `apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx` - bestehenden Test auf `isAdmin` umgestellt, zwei neue Faelle (`isAdmin={false}`, weggelassen)
- `apps/web/src/messages/de.json` / `en.json` - neuer Schluessel `proxmox.card.notPolledYetAutomatic`
- `apps/web/src/app/(portal)/modules/proxmox/page.tsx` - Knopf nur bei `isAdmin`, reicht `isAdmin={isAdmin}` an `ServerCard` weiter
- `apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx` (neu) - Seitentest mit 5 Faellen (USER, `user: null`, ADMIN, SUPER_ADMIN, leere Liste bei ADMIN)
## Decisions Made
- Formulierung "Noch keine Abfrage gelaufen. Die Werte erscheinen nach der naechsten automatischen Abfrage." statt eines "in Kuerze"-Hinweises, weil das Poll-Intervall je Server zwischen 1 und 1440 Minuten liegen kann (`@Max(1440)`) — die gewaehlte Formulierung stimmt bei jedem Intervall und verweist auf keinen Knopf.
- Keine weiteren Aenderungen an der Modulseite (Festlegung des Nutzers: kein Umbau, keine zusaetzlichen Details oder Statusfarben) — bestaetigt eingehalten.
## Deviations from Plan
None - plan genau wie geschrieben ausgefuehrt.
## Issues Encountered
None.
## Beobachtungen (nicht behoben, dem Nutzer zur Entscheidung vorgelegt)
- **Inaktive Server:** Ein inaktiver, nie abgefragter Server (`isActive: false`) wird nicht automatisch abgefragt (`proxmox-scheduler.service.ts` fragt nur aktive Server ab). Der neue Nicht-Admin-Hinweistext "...erscheinen nach der naechsten automatischen Abfrage" trifft fuer diesen Randfall nicht ganz zu. Das ist ein vorbestehender Randfall — auch die Admin-Karte beachtet `isActive` heute nicht — und liegt ausserhalb dieses Auftrags (Festlegung: kein Umbau). Wird hier nur vermerkt, nicht behoben.
## Verifikation (alle gruen, wie im Plan verlangt)
- `pnpm --filter api exec vitest run src/proxmox` - 82 Tests gruen (26 in `proxmox-normalize.spec.ts`, davon 6 neu)
- `pnpm --filter web exec vitest run "src/app/(portal)/modules/proxmox" src/messages` - 32 Tests gruen
- `pnpm --filter api type-check` und `pnpm --filter web type-check` - ohne Fehler
- `biome lint` auf den 6 Plan-Dateien - ohne Befund
- `biome check` auf der neuen Datei `proxmox-page-roles.test.tsx` - ohne Befund (nach `biome check --write` fuer Formatierung)
- `biome check` auf den 5 vorbestehenden Dateien - weiterhin genau 6 Befunde (vorbestehende Formatierung + `organizeImports` in `page.tsx`), keine neuen Befunde — wie im Plan festgelegt nicht behoben (fremder Diff)
- Keine Container-Neubauten, kein Deploy, keine Browserpruefung — wie im Plan vorgesehen
## User Setup Required
None - keine externe Konfiguration noetig.
## Next Phase Readiness
- Die offene Luecke (Wahrheit 7) aus `260923-dhh-VERIFICATION.md` ist geschlossen; das Proxmox-Modul hat keine bekannten offenen Abnahmebefunde mehr.
- Offen beim Nutzer (keine Entscheidung noetig, nur zur Kenntnis): der oben vermerkte Randfall bei inaktiven, nie abgefragten Servern.
## Self-Check: PASSED
Alle im Plan genannten Dateien wurden gefunden, alle vier Commits sind im Log nachweisbar.
---
*Plan: 260923-le6*
*Completed: 2026-09-23*
@@ -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>
@@ -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.
@@ -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)
@@ -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.
@@ -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.
@@ -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)
@@ -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.
@@ -0,0 +1,65 @@
---
created: 2026-09-23
title: Flackernder Test "TenantContextSelector" — Zeitüberschreitung bei 5 s, hat die Freigabe 1.3.1 blockiert
area: apps/web
severity: flake
trigger: sobald wieder an apps/web gearbeitet wird — spätestens vor der nächsten Freigabe, weil der Fall dort teuer ist.
relates_to: Freigabe v1.3.1 (CI-Lauf 418, Job 1262)
---
## Was passiert ist
Beim Freigeben von 1.3.1 lösen drei Pipelines gleichzeitig aus (`main`, Zweig
`live`, Tag `v1.3.1`). Auf dem Tag-Lauf fiel der Test
```
src/app/(portal)/marketplace/tenant-selector.test.tsx
> TenantContextSelector > renders tenant options for SUPER_ADMIN; renders nothing for ADMIN
```
mit `Error: Test timed out in 5000ms` aus. **Derselbe Commit** (`ad004b28`) war
im selben Zeitraum auf `main` (Lauf 1025) und auf `live` (Lauf 1026) grün — es
ist also kein Fehler im Code, sondern der Läufer war mit drei parallelen
Pipelines ausgelastet und der Test lief in sein 5-Sekunden-Limit.
## Warum das teuer war
Der Abbild-Bau hängt am Test-Job. Rot heißt: **„Build & Publish Images" und
„Desktop-Pakete bauen" wurden übersprungen** — die Freigabe war damit getaggt,
aber nicht gebaut. Kein `live`-Abbild, kein Gitea-Release, keine
Desktop-Pakete. Erst ein Neustart des Laufs (Gitea-API,
`POST /actions/runs/418/rerun`) hat alles nachgeholt.
Das trifft **jede** Freigabe, weil jede Freigabe drei gleichzeitige Läufe
auslöst. Der Fall wiederholt sich also, nicht zufällig.
## Was zu tun ist
1. Den Test ansehen: warum braucht er überhaupt nahe 5 s? Verdacht ist ein
`waitFor` auf etwas, das erst nach einem Datenabruf erscheint, oder zwei
Fälle (SUPER_ADMIN und ADMIN) in EINEM `it`, das dadurch doppelt so lange
läuft — der Testname nennt beide Fälle in einem Satz.
2. Entweder auftrennen (zwei `it`-Blöcke) oder die Wartezeit gezielt erhöhen.
Ein globales Hochsetzen von `testTimeout` versteckt nur, dass hier etwas
langsam ist.
3. Prüfen, ob weitere Tests nahe am Limit liegen — die Läuferlast bleibt ja.
## Nicht die Lösung
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.
+20
View File
@@ -4,6 +4,26 @@ 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
### Geändert
@@ -0,0 +1,113 @@
-- 260923-ad9 — Dashboard-Reiter: mehrere Dashboards je Benutzer.
--
-- Zweck: das Dashboard traegt heute genau eine Kachelflaeche je Benutzer.
-- Diese Migration gibt jedem Benutzer mehrere Dashboards ("Reiter"), die
-- oben nebeneinander stehen: jeder Reiter mit eigenen Kacheln und eigener
-- Anordnung, per Ziehen umsortierbar.
--
-- D-01: `position` (Integer) traegt die Reihenfolge, aufsteigend sortiert.
-- KEIN Unique auf (userId, position) — beim Umsortieren werden alle
-- Positionen eines Benutzers in EINER Transaktion neu geschrieben
-- (dashboard.service.ts, reorderDashboards, Muster FavoritesService.reorder);
-- ein Unique waere dabei nur im Weg.
--
-- D-03: niemand verliert etwas. Fuer jeden Benutzer, der heute Kacheln ODER
-- eine gespeicherte Anordnung hat, entsteht genau EIN Dashboard mit
-- position = 0 und dem Namen "Dashboard"; vorhandene Kacheln und die
-- vorhandene Anordnung werden darauf umgehaengt. Diese Bestandsuebernahme
-- MUSS vor den Fremdschluesseln laufen, sonst scheitert sie an genau diesen
-- — deshalb steht sie unten vor den ALTER-TABLE-Schritten fuer
-- WidgetInstance/DashboardLayout.
--
-- D-04: Zeilenschutz ist Pflicht. Die neue Tabelle traegt `tenantId` und
-- dieselbe Regel wie ihre Nachbarn — Mandant UND Benutzerdimension von
-- Anfang an (Form aus 20260911120000_rls_user_dimension_personal_tables,
-- uebernommen aus 20260921120000_dashboard_image).
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen ueber ALTER DEFAULT
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirkt die Regel erst, wenn
-- die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS,
-- siehe docs/mandantentrennung-datenbankrolle.md).
-- 1) Tabelle Dashboard anlegen, Indizes auf userId und tenantId.
CREATE TABLE "Dashboard" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Dashboard_userId_idx" ON "Dashboard"("userId");
CREATE INDEX "Dashboard_tenantId_idx" ON "Dashboard"("tenantId");
-- 2) Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000/20260921120000).
ALTER TABLE "Dashboard" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Dashboard" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "Dashboard"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
-- 3) Bestandsuebernahme (D-03): je Benutzer aus der Vereinigung der
-- Benutzer mit Kacheln und der Benutzer mit gespeicherter Anordnung genau
-- EINE Zeile einfuegen. DISTINCT ON sichert "je Benutzer genau eine Zeile"
-- auch fuer den theoretischen Fall "derselbe Benutzer mit zwei
-- Mandantenkennungen" ab (deterministische Wahl ueber die Sortierung nach
-- tenantId als zweitem Kriterium).
INSERT INTO "Dashboard" ("id", "userId", "tenantId", "name", "position", "createdAt", "updatedAt")
SELECT gen_random_uuid(), bestand."userId", bestand."tenantId", 'Dashboard', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (
SELECT DISTINCT ON ("userId") "userId", "tenantId"
FROM (
SELECT "userId", "tenantId" FROM "WidgetInstance"
UNION ALL
SELECT "userId", "tenantId" FROM "DashboardLayout"
) AS vereinigung
ORDER BY "userId", "tenantId"
) AS bestand;
-- 4) WidgetInstance.dashboardId: zunaechst NULLbar ergaenzen, aus der neuen
-- Tabelle ueber die Benutzerkennung befuellen (fuer jeden Benutzer mit
-- Kacheln existiert nach Schritt 3 GENAU ein Dashboard), dann NOT NULL,
-- Index, Fremdschluessel mit Loeschweitergabe.
ALTER TABLE "WidgetInstance" ADD COLUMN "dashboardId" TEXT;
UPDATE "WidgetInstance" wi
SET "dashboardId" = d."id"
FROM "Dashboard" d
WHERE d."userId" = wi."userId";
ALTER TABLE "WidgetInstance" ALTER COLUMN "dashboardId" SET NOT NULL;
CREATE INDEX "WidgetInstance_dashboardId_idx" ON "WidgetInstance"("dashboardId");
ALTER TABLE "WidgetInstance" ADD CONSTRAINT "WidgetInstance_dashboardId_fkey"
FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 5) DashboardLayout.dashboardId: dieselbe Uebernahme; zusaetzlich die
-- Eindeutigkeit auf userId entfernen (mehrere Reiter je Benutzer sind jetzt
-- erlaubt), dort einen gewoehnlichen Index anlegen, und die Eindeutigkeit
-- auf dashboardId anlegen (ein Reiter hat hoechstens eine gespeicherte
-- Anordnung).
ALTER TABLE "DashboardLayout" ADD COLUMN "dashboardId" TEXT;
UPDATE "DashboardLayout" dl
SET "dashboardId" = d."id"
FROM "Dashboard" d
WHERE d."userId" = dl."userId";
ALTER TABLE "DashboardLayout" ALTER COLUMN "dashboardId" SET NOT NULL;
DROP INDEX "DashboardLayout_userId_key";
CREATE INDEX "DashboardLayout_userId_idx" ON "DashboardLayout"("userId");
CREATE UNIQUE INDEX "DashboardLayout_dashboardId_key" ON "DashboardLayout"("dashboardId");
ALTER TABLE "DashboardLayout" ADD CONSTRAINT "DashboardLayout_dashboardId_fkey"
FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,94 @@
-- 260923-dhh — Proxmox-Modul (PVE/PBS/PMG), nur beobachten (D-01).
--
-- Zweck: zwei neue Tabellen fuer das Proxmox-Modul. `ProxmoxServer` traegt
-- die vom Administrator eingetragenen Server (Name, Typ, Adresse, Zugang,
-- verschluesselt) — mehrere Zeilen je Mandant, Vorbild `CalendarSource`,
-- NICHT `DkvModuleConfig` (Singleton je Mandant). `ProxmoxServerStatus` ist
-- das Zwischenlager (D-05): der Hintergrunddienst (Aufgabe 4) beschreibt
-- diese Zeile, die Modulseite liest ausschliesslich daraus.
--
-- Von Hand geschrieben (Vorbild 20260923120000_dashboard_tabs), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (D-08, Pflicht — sonst schlaegt rls-coverage.spec.ts fehl):
-- beide Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus
-- `DkvModuleConfig`, Migration 20260909140000) — Proxmox-Server sind
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines einzelnen
-- Benutzers.
--
-- Zusaetzlich NUR auf "ProxmoxServer" eine `system_read_policy` (Form aus
-- 20260914120000_rls_system_context_read): der Hintergrunddienst aus
-- Aufgabe 4 muss beim Start ueber `forSystem()` die aktiven Server ALLER
-- Mandanten sehen, um je Mandant einen eigenen Cron-Auftrag zu registrieren
-- (Muster DKV-/Tender-Planer). "ProxmoxServerStatus" bekommt diese Regel
-- BEWUSST NICHT — geschrieben wird dort ausschliesslich je Zeile
-- mandantengebunden (`forTenant(prisma, tenantId)`), ein Systemlesezugriff
-- auf das Zwischenlager hat keinen Aufrufer.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- 1) ProxmoxServer
CREATE TABLE "ProxmoxServer" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"productType" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"authMethod" TEXT NOT NULL,
"tokenId" TEXT,
"encryptedTokenSecret" TEXT,
"username" TEXT,
"encryptedPassword" TEXT,
"tlsRejectUnauthorized" BOOLEAN NOT NULL DEFAULT true,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"pollIntervalMin" INTEGER NOT NULL DEFAULT 5,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProxmoxServer_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProxmoxServer_tenantId_idx" ON "ProxmoxServer"("tenantId");
ALTER TABLE "ProxmoxServer" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ProxmoxServer" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ProxmoxServer"
USING ("tenantId" = current_tenant_id());
CREATE POLICY system_read_policy ON "ProxmoxServer"
FOR SELECT USING (is_system_context());
-- 2) ProxmoxServerStatus — Zwischenlager, 1:1 je Server, Loeschweitergabe.
CREATE TABLE "ProxmoxServerStatus" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"lastPolledAt" TIMESTAMP(3),
"lastOkAt" TIMESTAMP(3),
"reachable" BOOLEAN NOT NULL DEFAULT false,
"errorKind" TEXT,
"errorDetail" TEXT,
"metrics" JSONB,
"rawSample" JSONB,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProxmoxServerStatus_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProxmoxServerStatus_serverId_key" ON "ProxmoxServerStatus"("serverId");
CREATE INDEX "ProxmoxServerStatus_tenantId_idx" ON "ProxmoxServerStatus"("tenantId");
ALTER TABLE "ProxmoxServerStatus" ADD CONSTRAINT "ProxmoxServerStatus_serverId_fkey"
FOREIGN KEY ("serverId") REFERENCES "ProxmoxServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProxmoxServerStatus" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ProxmoxServerStatus" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ProxmoxServerStatus"
USING ("tenantId" = current_tenant_id());
@@ -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";
+103 -9
View File
@@ -187,14 +187,45 @@ model ModuleGrant {
@@index([moduleId])
}
// Dashboard-Reiter (quick-260923-ad9, D-01/D-02/D-09): mehrere Dashboards je
// Benutzer, ueber `position` (Integer) aufsteigend sortiert. KEIN Unique auf
// (userId, position) — `reorderDashboards` (Muster FavoritesService.reorder)
// schreibt beim Umsortieren ALLE Positionen eines Benutzers in EINER
// Transaktion neu; ein Unique waere dabei nur im Weg (kollidiert waehrend
// des Umschreibens mit sich selbst). KEIN eigenes Standard-Feld: "als
// Favorit festlegen" IST das Nach-vorn-Ziehen (D-09) — Position 0 ist der
// Standard, es gibt keine zweite Wahrheit daneben. Keine Relation zu
// User/Tenant — Form der Nachbarmodelle WidgetInstance/DashboardImage (eine
// Relation zu User wuerde an Bestandszeilen verwaister Benutzer scheitern).
model Dashboard {
id String @id @default(uuid())
userId String
tenantId String
name String
position Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
widgets WidgetInstance[]
layout DashboardLayout?
@@index([userId])
@@index([tenantId])
}
model DashboardLayout {
id String @id @default(uuid())
userId String @unique
userId String
tenantId String
// quick-260923-ad9 (D-02): haengt jetzt am Dashboard statt am Benutzer —
// die Eindeutigkeit wandert von userId auf dashboardId, userId/tenantId
// bleiben fuer Besitz- und Mandantenpruefung erhalten.
dashboardId String @unique
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
layouts Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([tenantId])
}
@@ -202,6 +233,10 @@ model WidgetInstance {
id String @id @default(uuid())
userId String
tenantId String
// quick-260923-ad9 (D-02): Kacheln haengen ab jetzt am Reiter, nicht mehr
// nur am Benutzer.
dashboardId String
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
widgetType String
config Json @default("{}")
createdAt DateTime @default(now())
@@ -210,6 +245,7 @@ model WidgetInstance {
@@index([userId])
@@index([tenantId])
@@index([dashboardId])
}
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
@@ -229,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])
@@ -383,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
@@ -615,3 +658,54 @@ model TenderRssFeedSource {
@@unique([userId, url])
@@index([userId])
}
// Quick-Auftrag 260923-dhh — Proxmox-Modul (PVE/PBS/PMG), nur beobachten (D-01).
//
// Vorbild ist `CalendarSource` (mehrere verschluesselte Fremdsystem-Zugaenge
// je Mandant), NICHT `DkvModuleConfig` (Singleton je Mandant): ein Mandant
// traegt hier beliebig viele Server ein. `authMethod` waehlt zwischen einem
// API-Token (`tokenId`/`encryptedTokenSecret`) und Benutzer/Passwort
// (`username`/`encryptedPassword`); PMG kennt laut Recherche nur Letzteres
// (DTO lehnt Token bei PMG serverseitig ab, D-03). `tlsRejectUnauthorized`
// ist woertlich der Feldname aus `LdapConfig` — Voreinstellung "pruefen",
// pro Zeile umschaltbar, nie global (D-04).
model ProxmoxServer {
id String @id @default(uuid())
tenantId String
name String
productType String // 'pve' | 'pbs' | 'pmg'
baseUrl String
authMethod String // 'token' | 'password'
tokenId String?
encryptedTokenSecret String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex), wie CalendarSource.encryptedPassword
username String?
encryptedPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex)
tlsRejectUnauthorized Boolean @default(true)
isActive Boolean @default(true)
pollIntervalMin Int @default(5)
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status ProxmoxServerStatus?
@@index([tenantId])
}
// Zwischenlager (D-05): der Hintergrunddienst (Aufgabe 4) beschreibt diese
// Zeile, die Modulseite liest ausschliesslich daraus — nie live bei Proxmox.
model ProxmoxServerStatus {
id String @id @default(uuid())
serverId String @unique
server ProxmoxServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
tenantId String
lastPolledAt DateTime?
lastOkAt DateTime?
reachable Boolean @default(false)
errorKind String?
errorDetail String?
metrics Json?
rawSample Json?
updatedAt DateTime @updatedAt
@@index([tenantId])
}
+2
View File
@@ -26,6 +26,7 @@ import { TenantGuard } from './tenant/tenant.guard';
import { TenantModule } from './tenant/tenant.module';
import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
@Module({
imports: [
@@ -51,6 +52,7 @@ import { UserModule } from './user/user.module';
FavoritesModule,
TendersModule,
BugReportsModule,
ProxmoxModule,
],
providers: [
// Global JWT guard: all routes require auth unless @Public()
@@ -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;
}
}
@@ -0,0 +1,143 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardController } from './dashboard.controller';
/**
* dashboard.controller.spec — NEU (quick-260923-ad9, Task 2). Form aus
* `dashboard-images.controller.spec.ts`: die Reiter-Kennung wird aus
* Abfrageparameter bzw. Rumpf an den Dienst durchgereicht, fehlender
* Benutzer-/Mandantenkontext führt zur vorhandenen Abweisung, und ein
* quelltextlesender Wächter prüft, dass die feste Route `tabs/order` VOR
* jeder Route mit Platzhalter unter demselben Präfix im Dateitext steht
* (NestJS-Routenreihenfolge — in dieser Anwendung hat eine Route mit
* Platzhalter schon einmal eine dahinter stehende feste Route verdeckt).
*/
function makeService() {
return {
listDashboards: vi.fn(async () => [{ id: 'dash-1' }]),
createDashboard: vi.fn(async () => ({ id: 'dash-2' })),
reorderDashboards: vi.fn(async () => [{ id: 'dash-1' }, { id: 'dash-2' }]),
renameDashboard: vi.fn(async (id: string) => ({ id })),
deleteDashboard: vi.fn(async (id: string) => ({ id })),
getLayout: vi.fn(async () => ({ lg: [] })),
saveLayout: vi.fn(async () => ({ id: 'layout-1' })),
getWidgets: vi.fn(async () => []),
addWidget: vi.fn(async () => ({ id: 'w1' })),
updateWidgetConfig: vi.fn(async () => ({ id: 'w1' })),
removeWidget: vi.fn(async () => ({ id: 'w1' })),
getSearchProviders: vi.fn(async () => []),
addSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
removeSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
};
}
function makeRequest(overrides: Partial<AuthenticatedRequest> = {}): AuthenticatedRequest {
return {
user: { id: 'user-1', tenantId: 'tenant-1', role: 'USER', username: 'anna', mustChangePassword: false },
tenantId: 'tenant-1',
...overrides,
} as AuthenticatedRequest;
}
describe('DashboardController — Reiter (quick-260923-ad9, Task 2)', () => {
it('listDashboards: reicht userId/tenantId aus dem Sitzungsnachweis durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.listDashboards(makeRequest());
expect(service.listDashboards).toHaveBeenCalledWith('user-1', 'tenant-1');
});
it('createDashboard: reicht userId/tenantId durch, kein Rumpf nötig', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.createDashboard(makeRequest());
expect(service.createDashboard).toHaveBeenCalledWith('user-1', 'tenant-1');
});
it('reorderDashboards: reicht die Kennungsliste aus dem Rumpf durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const dto = { ids: ['dash-2', 'dash-1'] } as never;
await controller.reorderDashboards(makeRequest(), dto);
expect(service.reorderDashboards).toHaveBeenCalledWith('user-1', 'tenant-1', dto);
});
it('renameDashboard: reicht Pfad-Kennung und Rumpf durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const dto = { name: 'Neuer Name' } as never;
await controller.renameDashboard('dash-1', makeRequest(), dto);
expect(service.renameDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1', dto);
});
it('deleteDashboard: reicht die Pfad-Kennung durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.deleteDashboard('dash-1', makeRequest());
expect(service.deleteDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1');
});
it('getLayout/getWidgets: reichen die Reiter-Kennung aus dem Abfrageparameter durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.getLayout(makeRequest(), 'dash-1');
await controller.getWidgets(makeRequest(), 'dash-1');
expect(service.getLayout).toHaveBeenCalledWith('user-1', 'tenant-1', 'dash-1');
expect(service.getWidgets).toHaveBeenCalledWith('user-1', 'tenant-1', 'USER', 'dash-1');
});
it('fehlender Mandantenkontext führt bei den neuen Reiter-Wegen zur vorhandenen Abweisung', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const req = makeRequest({ user: undefined, tenantId: undefined } as never);
await expect(controller.listDashboards(req)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.createDashboard(req)).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.reorderDashboards(req, { ids: [] } as never),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.renameDashboard('dash-1', req, { name: 'x' } as never),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.deleteDashboard('dash-1', req)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('Wächter: die feste Route "tabs/order" steht im Dateitext VOR jeder Route mit Platzhalter unter demselben Präfix (NestJS-Routenreihenfolge)', () => {
const source = readFileSync(join(__dirname, 'dashboard.controller.ts'), 'utf-8');
const orderIndex = source.indexOf("@Put('tabs/order')");
const patchIdIndex = source.indexOf("@Patch('tabs/:id')");
const deleteIdIndex = source.indexOf("@Delete('tabs/:id')");
expect(orderIndex, '@Put(\'tabs/order\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(patchIdIndex, '@Patch(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(deleteIdIndex, '@Delete(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(
orderIndex,
'tabs/order muss VOR PATCH tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
).toBeLessThan(patchIdIndex);
expect(
orderIndex,
'tabs/order muss VOR DELETE tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
).toBeLessThan(deleteIdIndex);
});
});
+73 -9
View File
@@ -8,12 +8,15 @@ import {
Patch,
Post,
Put,
Query,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardService } from './dashboard.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto';
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
import { SaveLayoutDto } from './dto/save-layout.dto';
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
@@ -25,10 +28,17 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
* and scopes all operations to the calling user (T-05-01, T-05-02).
*
* Routes:
* - GET /dashboard/layout — get user's saved layout
* - PUT /dashboard/layout — upsert user's layout
* - GET /dashboard/widgets — list user's widget instances
* - POST /dashboard/widgets — create a new widget instance
* - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9)
* - POST /dashboard/tabs — create a new, empty tab
* - PUT /dashboard/tabs/order — persist the tab order (MUST be declared
* before the `:id` routes below, see the
* source-order guard in dashboard.controller.spec.ts)
* - PATCH /dashboard/tabs/:id — rename a tab
* - DELETE /dashboard/tabs/:id — delete a tab, its widgets and its layout
* - GET /dashboard/layout — get the saved layout of one tab
* - PUT /dashboard/layout — upsert the layout of one tab
* - GET /dashboard/widgets — list the widget instances of one tab
* - POST /dashboard/widgets — create a new widget instance on one tab
* - PATCH /dashboard/widgets/:id/config — update widget config
* - DELETE /dashboard/widgets/:id — remove a widget instance
* - GET /dashboard/search-providers — list default + user's custom providers
@@ -66,10 +76,61 @@ export class DashboardController {
return { userId: user.id, tenantId, role: user.role };
}
@Get('layout')
async getLayout(@Req() req: AuthenticatedRequest) {
/**
* Reiter des Benutzers (quick-260923-ad9), nach Position aufsteigend;
* legt beim ersten Aufruf genau einen an.
*/
@Get('tabs')
async listDashboards(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getLayout(userId, tenantId);
return this.dashboardService.listDashboards(userId, tenantId);
}
@Post('tabs')
async createDashboard(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.createDashboard(userId, tenantId);
}
/**
* MUSS vor `PATCH tabs/:id` / `DELETE tabs/:id` stehen — in dieser
* Anwendung hat eine Route mit Platzhalter schon einmal eine dahinter
* stehende feste Route verdeckt (siehe Projektnotiz „NestJS Route-
* Order“); ein quelltextlesender Wächter in
* `dashboard.controller.spec.ts` prüft die Reihenfolge im Dateitext.
*/
@Put('tabs/order')
async reorderDashboards(
@Req() req: AuthenticatedRequest,
@Body() dto: ReorderDashboardsDto,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.reorderDashboards(userId, tenantId, dto);
}
@Patch('tabs/:id')
async renameDashboard(
@Param('id') id: string,
@Req() req: AuthenticatedRequest,
@Body() dto: RenameDashboardDto,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.renameDashboard(id, userId, tenantId, dto);
}
@Delete('tabs/:id')
async deleteDashboard(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.deleteDashboard(id, userId, tenantId);
}
@Get('layout')
async getLayout(
@Req() req: AuthenticatedRequest,
@Query('dashboardId') dashboardId: string,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getLayout(userId, tenantId, dashboardId);
}
@Put('layout')
@@ -79,9 +140,12 @@ export class DashboardController {
}
@Get('widgets')
async getWidgets(@Req() req: AuthenticatedRequest) {
async getWidgets(
@Req() req: AuthenticatedRequest,
@Query('dashboardId') dashboardId: string,
) {
const { userId, tenantId, role } = this.extractContext(req);
return this.dashboardService.getWidgets(userId, tenantId, role);
return this.dashboardService.getWidgets(userId, tenantId, role, dashboardId);
}
@Post('widgets')
File diff suppressed because it is too large Load Diff
+333 -27
View File
@@ -1,18 +1,29 @@
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 } from '../prisma/prisma-tenant.extension';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto';
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
import { SaveLayoutDto } from './dto/save-layout.dto';
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
import { getModuleSlugForWidgetType } from './widget-module-map';
/**
* T-AD9-06 — Riegel gegen Massenanfragen: hoechstens 20 Reiter je Benutzer
* (quick-260923-ad9, Task 2).
*/
const DASHBOARD_MAX_COUNT = 20;
/**
* Default search providers (D-15).
* Returned as part of getSearchProviders even when no DB rows exist.
@@ -82,19 +93,290 @@ const DEFAULT_SEARCH_PROVIDERS = [
*/
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
private readonly prisma: PrismaService,
private readonly moduleAccessService: ModuleAccessService,
) {}
/**
* Returns the user's saved layout, or a default empty layout
* with all breakpoint arrays initialized.
* 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.
*/
async getLayout(userId: string, tenantId: string) {
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
* und wird beim Öffnen geladen. Ist die Liste leer (erster Aufruf des
* Benutzers ueberhaupt), wird genau EIN Reiter „Dashboard“ angelegt.
*
* Das Anlegen laeuft in einer `withTenantTransaction`, deren ERSTE
* Anweisung eine Transaktionssperre auf die Benutzerkennung nimmt
* (`pg_advisory_xact_lock`, `hashtext` ueber die Benutzerkennung als
* ersten Schluessel, 0 als zweiten — beides eingebaute Postgres-
* Funktionen). Zwei gleichzeitige erste Aufrufe desselben Benutzers
* warten dadurch aufeinander statt beide "kein Reiter vorhanden" zu
* sehen; die erneute Zaehlung INNERHALB der Sperre verhindert die
* doppelte Anlage (T-AD9-07). `withTenantTransaction` setzt keine
* Benutzerdimension in der Sitzung — die Bedingung traegt `userId` UND
* `tenantId` deshalb selbst, als zweites Netz.
*/
async listDashboards(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const record = await tenantPrisma.dashboardLayout.findUnique({
let dashboards = await tenantPrisma.dashboard.findMany({
where: { userId },
orderBy: { position: 'asc' },
});
if (dashboards.length === 0) {
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId}), 0)`;
const existing = await tx.dashboard.count({
where: { userId, tenantId },
});
if (existing === 0) {
await tx.dashboard.create({
data: { userId, tenantId, name: 'Dashboard', position: 0 },
});
}
});
dashboards = await tenantPrisma.dashboard.findMany({
where: { userId },
orderBy: { position: 'asc' },
});
}
return dashboards;
}
/**
* Riegel gegen fremde Reiter (T-AD9-01/02/03, Muster `FavoritesService.
* create`/T-GWH-05): liest den Reiter ueber den BEREITS gebundenen
* Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft
* fuer drei ununterscheidbare Faelle dieselbe `NotFoundException` — "gibt
* es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden
* Mandanten" (die Mandantengrenze zieht bereits der gebundene Klient).
* Niemals eine abweichende Antwort, aus der sich die Existenz eines
* fremden Reiters ablesen liesse.
*/
private async assertOwnedDashboard(
tenantPrisma: ReturnType<typeof forTenant>,
dashboardId: string,
userId: string,
): Promise<void> {
const dashboard = await tenantPrisma.dashboard.findUnique({
where: { id: dashboardId },
});
if (!dashboard || dashboard.userId !== userId) {
throw new NotFoundException(`Dashboard with id '${dashboardId}' not found`);
}
}
/**
* Legt einen neuen, leeren Reiter an (quick-260923-ad9, Task 2, D-08).
* Name automatisch: "Dashboard 2", "Dashboard 3", … — die kleinste noch
* freie Zahl ab 2 (füllt eine Lücke, wenn z. B. "Dashboard 2" gelöscht
* wurde). Dieser Name ist ein gespeicherter Datenwert, keine
* Oberflächenbeschriftung — deshalb ein TypeScript-Text hier statt eines
* Übersetzungsschlüssels, genau wie der Name "Dashboard", den die
* Migration/`listDashboards` vergeben. Hängt ans Ende (höchste
* vorhandene Position plus eins) und liefert den neuen Reiter mit
* leerer Kachelliste (es existiert noch keine `WidgetInstance`-Zeile
* dafür).
*/
async createDashboard(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.dashboard.findMany({ where: { userId } });
if (existing.length >= DASHBOARD_MAX_COUNT) {
throw new BadRequestException(
`Es sind bereits ${DASHBOARD_MAX_COUNT} Dashboards vorhanden — mehr sind nicht möglich.`,
);
}
const existingNames = new Set(existing.map((d) => d.name));
let n = 2;
while (existingNames.has(`Dashboard ${n}`)) n++;
const nextPosition = existing.reduce((max, d) => Math.max(max, d.position), -1) + 1;
return tenantPrisma.dashboard.create({
data: { userId, tenantId, name: `Dashboard ${n}`, position: nextPosition },
});
}
/**
* Benennt einen Reiter um (quick-260923-ad9, Task 2). `assertOwnedDashboard`
* läuft zuerst, über denselben gebundenen Klienten — eine fremde Kennung
* liefert die Nicht-gefunden-Antwort (T-AD9-03). Beschneiden und
* Längenprüfung (1–40 Zeichen) liegen bereits im DTO.
*/
async renameDashboard(
id: string,
userId: string,
tenantId: string,
dto: RenameDashboardDto,
) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, id, userId);
return tenantPrisma.dashboard.update({
where: { id },
data: { name: dto.name },
});
}
/**
* Löscht einen Reiter mit seinen Kacheln und seiner Anordnung
* (quick-260923-ad9, Task 2). `assertOwnedDashboard` läuft zuerst; danach
* wird geprüft, ob es der letzte verbleibende Reiter ist (D-10) — der
* Server weist das ab, die Oberfläche bietet den Knopf dafür gar nicht
* erst an. Löschen, Anordnung-/Kachel-Entfernen und das lückenlose
* Neuschreiben der verbleibenden Positionen laufen als EINE
* `withTenantTransaction` (mehrschrittig, muss atomar sein — dieselbe
* Begründung wie `FavoritesService.reorder`). Die Löschweitergabe in der
* Datenbank (`onDelete: Cascade`) bleibt als zweites Netz bestehen; der
* geschriebene Weg unten ist der gebundene. `withTenantTransaction`
* setzt keine Benutzerdimension in der Sitzung — jede Bedingung trägt
* `userId` deshalb selbst.
*/
async deleteDashboard(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, id, userId);
const count = await tenantPrisma.dashboard.count({ where: { userId, tenantId } });
if (count <= 1) {
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
}
// 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 } });
const remaining = await tx.dashboard.findMany({
where: { userId },
orderBy: { position: 'asc' },
});
for (const [index, dashboard] of remaining.entries()) {
await tx.dashboard.updateMany({
where: { id: dashboard.id, userId },
data: { position: index },
});
}
return { id };
});
await this.cleanUpFavoriteIconFiles(userId, iconRows);
return result;
}
/**
* Persistiert die Reihenfolge der Reiter des Benutzers
* (quick-260923-ad9, Task 2). Wörtlich nach dem Muster
* `FavoritesService.reorder` (260917-jdd): EINE `withTenantTransaction`,
* darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung
* mit der gesendeten Liste prüfen (sonst Abweisung, KEIN Teilschreiben —
* die Prüfung läuft VOR jedem `updateMany`), dann je Eintrag ein
* `updateMany` mit `id` UND `userId` in der Bedingung und einer Prüfung
* auf genau eine getroffene Zeile (T-AD9-04). Existenzorakel-Vermeidung:
* EINE `BadRequestException` mit DERSELBEN Meldung für unvollständige,
* unbekannte und fremde Kennungen — kein Fall verrät, welcher Grund
* zutraf (Muster T-GWH-05/T-JDD-06).
*/
async reorderDashboards(userId: string, tenantId: string, dto: ReorderDashboardsDto) {
if (new Set(dto.ids).size !== dto.ids.length) {
throw new BadRequestException('ids must match the dashboards of this user exactly');
}
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
const existing = await tx.dashboard.findMany({
where: { userId },
select: { id: true },
});
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
if (existing.length !== dto.ids.length || dto.ids.some((id) => !existingIds.has(id))) {
throw new BadRequestException('ids must match the dashboards of this user exactly');
}
for (const [index, id] of dto.ids.entries()) {
const { count } = await tx.dashboard.updateMany({
where: { id, userId },
data: { position: index },
});
if (count !== 1) {
throw new BadRequestException('ids must match the dashboards of this user exactly');
}
}
return tx.dashboard.findMany({
where: { userId },
orderBy: { position: 'asc' },
});
});
}
/**
* Returns the saved layout of one dashboard tab, or a default empty
* layout with all breakpoint arrays initialized.
*
* quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
* `assertOwnedDashboard` runs first, over the SAME bound client.
*/
async getLayout(userId: string, tenantId: string, dashboardId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
const record = await tenantPrisma.dashboardLayout.findUnique({
where: { dashboardId },
});
if (!record) {
@@ -105,32 +387,33 @@ export class DashboardService {
}
/**
* Upserts the user's dashboard layout.
* Upserts the layout of one dashboard tab.
* Creates a new record if none exists, updates if it does.
*
* `userId` is platform-wide `@unique` (no tenant component) — a tenant
* whose user id was, by hand, moved off its actually-visible row could hit
* an `upsert` conflict on a row it cannot see under RLS. Measured
* (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row
* throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known
* error that the `tenders` area's translation pattern catches — this is a
* quick-260923-ad9 (D-02): scoped by `dto.dashboardId` instead of
* `userId` — `assertOwnedDashboard` runs first, over the SAME bound
* client. `dashboardId` is now the `@unique` column on `DashboardLayout`
* (was `userId` before this plan).
*
* A bound conflicting upsert against a row invisible under RLS throws
* `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known error
* that the `tenders` area's translation pattern catches — this is a
* different Prisma error class, `.code`/`.meta` are `undefined`, the only
* signal is the raw `.message` text). Translated below into an
* understandable German message instead of a raw 500, same intent as
* `tender-notification-pref.service.ts`, different detection. Not
* reachable via any application path today (a user's tenant id never
* changes after creation) — the honest fix is a schema change and is
* deferred as a product decision to Etappe 3, same as WINDOWS #22.
* signal is the raw `.message` text) — measured 260910-krx, Aufgabe 1,
* translation kept unchanged from before this plan.
*/
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
try {
return await tenantPrisma.dashboardLayout.upsert({
where: { userId },
where: { dashboardId: dto.dashboardId },
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
create: {
userId,
tenantId,
dashboardId: dto.dashboardId,
layouts: dto.layouts as unknown as Prisma.InputJsonValue,
},
});
@@ -145,12 +428,13 @@ export class DashboardService {
}
/**
* Returns all widget instances for a given user, gefiltert um Widgets
* Returns all widget instances of one dashboard tab, gefiltert um Widgets
* eines für den Benutzer gesperrten Moduls (D-22, PERM-07).
*
* Die bestehende Query bleibt unverändert die erste Aktion. Steht unter
* den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der
* Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die
* quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
* `assertOwnedDashboard` runs first, over the SAME bound client. Steht
* unter den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` —
* der Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die
* Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei
* mindestens einem modulgebundenen Widget wird die Zugriffsauflösung
* aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und
@@ -158,10 +442,12 @@ export class DashboardService {
* Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das
* betroffene Widget entfernt (Fail-Closed).
*/
async getWidgets(userId: string, tenantId: string, role: Role) {
async getWidgets(userId: string, tenantId: string, role: Role, dashboardId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
const widgets = await tenantPrisma.widgetInstance.findMany({
where: { userId },
where: { dashboardId },
orderBy: { createdAt: 'asc' },
});
@@ -207,14 +493,20 @@ export class DashboardService {
}
/**
* Creates a new widget instance for the user.
* Creates a new widget instance on one dashboard tab.
* quick-260923-ad9 (D-02): `assertOwnedDashboard` runs first, over the
* SAME bound client — a widget can only be created on a tab the caller
* owns.
*/
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
return tenantPrisma.widgetInstance.create({
data: {
userId,
tenantId,
dashboardId: dto.dashboardId,
widgetType: dto.widgetType,
config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue,
},
@@ -266,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);
@@ -279,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) ---
@@ -2,7 +2,7 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
import { WIDGET_TYPES } from '@tessera/shared';
/**
* DTO for creating a new widget instance on a user's dashboard.
* DTO for creating a new widget instance on one dashboard tab.
*
* quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus
* `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine
@@ -10,9 +10,15 @@ import { WIDGET_TYPES } from '@tessera/shared';
* zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog
* angebotene Kachel mit 400 ab.
*
* quick-260923-ad9: `dashboardId` selects the tab — the service verifies
* ownership before writing (`assertOwnedDashboard`).
*
* config is optional and defaults to {} on the model.
*/
export class CreateWidgetDto {
@IsString()
dashboardId!: string;
@IsString()
@IsIn([...WIDGET_TYPES])
widgetType!: string;
@@ -0,0 +1,17 @@
import { Transform } from 'class-transformer';
import { IsString, Length } from 'class-validator';
/**
* DTO for `PATCH /dashboard/tabs/:id` (quick-260923-ad9, Task 2).
*
* `name` wird VOR der Längenprüfung beschnitten (führende/nachgestellte
* Leerräume zählen nicht mit) — ein reiner Leerraum-Name schlägt danach an
* `@Length(1, 40)` fehl. Die Obergrenze von 40 Zeichen ist ein Riegel gegen
* Massenanfragen, kein UI-Detail (T-AD9-06).
*/
export class RenameDashboardDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@Length(1, 40)
name!: string;
}
@@ -0,0 +1,26 @@
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsString,
} from 'class-validator';
/**
* DTO for `PUT /dashboard/tabs/order` (quick-260923-ad9, Task 2).
*
* `ids` ist die VOLLSTÄNDIGE Kennungsliste der Reiter des Benutzers, in
* der gewünschten Reihenfolge — der Dienst verlangt einen exakten Abgleich
* gegen die vorhandenen Reiter (kein Teil-Umsortieren, keine fremden/
* unbekannten Kennungen), Muster `ReorderFavoritesDto`/`FavoritesService.
* reorder` (260917-jdd). `ArrayMaxSize(20)` ist ein Riegel gegen
* Massenanfragen (T-AD9-06) — ein Benutzer hat höchstens 20 Reiter.
*/
export class ReorderDashboardsDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(20)
@ArrayUnique()
@IsString({ each: true })
ids!: string[];
}
@@ -1,11 +1,16 @@
import { IsObject } from 'class-validator';
import { IsObject, IsString } from 'class-validator';
/**
* DTO for saving/updating a user's dashboard layout.
* DTO for saving/updating the layout of one dashboard tab (quick-260923-ad9).
* The layouts object contains responsive breakpoint layouts
* (lg, md, sm, xs, xxs) as managed by react-grid-layout.
* `dashboardId` selects the tab — the service verifies ownership before
* writing (`assertOwnedDashboard`).
*/
export class SaveLayoutDto {
@IsString()
dashboardId!: string;
@IsObject()
layouts!: Record<string, unknown>;
}
@@ -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();
});
@@ -39,8 +45,11 @@ describe('widget-module-map (quick-260922-m1h)', () => {
* ablehnen zu lassen.
*/
describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => {
// quick-260923-ad9: dashboardId ist seither ein Pflichtfeld (Reiter-
// Kennung) — hier fest mitgegeben, damit dieser Test weiterhin nur die
// Whitelist von widgetType prueft.
async function validateType(widgetType: string) {
const dto = plainToInstance(CreateWidgetDto, { widgetType });
const dto = plainToInstance(CreateWidgetDto, { widgetType, dashboardId: 'dash-1' });
return validate(dto);
}
+5 -6
View File
@@ -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
});
});
+51 -3
View File
@@ -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);
});
});
});
+237 -5
View File
@@ -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');
}
@@ -165,11 +165,27 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
* Summe neu: 5 Dateien, 6 Aufrufe.
*
* quick-260923-dhh (Aufgabe 4): eine sechste Datei kommt hinzu —
* `proxmox.service.ts`/`loadActiveServersForScheduler()`, derselbe
* Startpfad-Fall wie `dkv.service.ts`: der Planer liest beim Start ALLE
* aktiven `ProxmoxServer`-Zeilen aller Mandanten (`system_read_policy` auf
* `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],
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
['apps/api/src/tenders/tender-matching.service.ts', 1],
]);
@@ -0,0 +1,163 @@
import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUrl,
Max,
Min,
Validate,
ValidateIf,
type ValidationArguments,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
/**
* D-03: PMG kennt laut Recherche keinen API-Token (Annahme A1) — ein Server
* vom Typ `pmg` mit `authMethod: 'token'` wird bereits beim Speichern mit
* einer deutschen Klartextmeldung abgelehnt (400), nicht erst beim
* Abfragen. Angebracht am Feld `authMethod`, liest aber `productType`
* desselben Objekts (`args.object`) — class-validator erlaubt das.
*/
@ValidatorConstraint({ name: 'pmgOhneToken', async: false })
class PmgOhneTokenConstraint implements ValidatorConstraintInterface {
validate(_value: unknown, args: ValidationArguments): boolean {
const obj = args.object as { productType?: string; authMethod?: string };
return !(obj.productType === 'pmg' && obj.authMethod === 'token');
}
defaultMessage(): string {
return 'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.';
}
}
/**
* DTO fuer das Anlegen eines Proxmox-Servers (Aufgabe 1). Pflichtfelder je
* `authMethod` mit `@ValidateIf` (Aufgabe 2): ein Token-Zugang verlangt
* `tokenId`/`tokenSecret`, ein Passwort-Zugang `username`/`password`.
*/
export class CreateProxmoxServerDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsIn(['pve', 'pbs', 'pmg'])
productType!: 'pve' | 'pbs' | 'pmg';
// require_tld: false — interne Namen wie "pve.intern" sind sonst abgelehnt.
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
baseUrl!: string;
@IsIn(['token', 'password'])
@Validate(PmgOhneTokenConstraint)
authMethod!: 'token' | 'password';
@ValidateIf((o) => o.authMethod === 'token')
@IsString()
@IsNotEmpty()
tokenId?: string;
@ValidateIf((o) => o.authMethod === 'token')
@IsString()
@IsNotEmpty()
tokenSecret?: string;
@ValidateIf((o) => o.authMethod === 'password')
@IsString()
@IsNotEmpty()
username?: string;
@ValidateIf((o) => o.authMethod === 'password')
@IsString()
@IsNotEmpty()
password?: string;
@IsBoolean()
@IsOptional()
tlsRejectUnauthorized?: boolean;
@IsInt()
@Min(1)
@Max(1440)
@IsOptional()
pollIntervalMin?: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
/**
* DTO fuer das Bearbeiten (Aufgabe 5). Alle Felder optional; ein NICHT
* gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert, eine
* LEERE Zeichenkette bedeutet "loeschen" (Muster `LdapConfigService.updateConfig`)
* — diese Unterscheidung lebt im Service, nicht im DTO, deshalb bleiben
* `tokenSecret`/`password` hier einfache optionale Zeichenketten ohne
* `IsNotEmpty`.
*/
export class UpdateProxmoxServerDto {
@IsString()
@IsNotEmpty()
@IsOptional()
name?: string;
@IsIn(['pve', 'pbs', 'pmg'])
@IsOptional()
productType?: 'pve' | 'pbs' | 'pmg';
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
@IsOptional()
baseUrl?: string;
@IsIn(['token', 'password'])
@Validate(PmgOhneTokenConstraint)
@IsOptional()
authMethod?: 'token' | 'password';
@IsString()
@IsOptional()
tokenId?: string;
@IsString()
@IsOptional()
tokenSecret?: string;
@IsString()
@IsOptional()
username?: string;
@IsString()
@IsOptional()
password?: string;
@IsBoolean()
@IsOptional()
tlsRejectUnauthorized?: boolean;
@IsInt()
@Min(1)
@Max(1440)
@IsOptional()
pollIntervalMin?: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
/**
* DTO fuer den Verbindungstest (Nachbesserung Befund 1, Rundgang zu Aufgabe 4):
* derselbe Feldsatz wie `UpdateProxmoxServerDto` — der Test soll auf JEDEM
* dieser Felder den ungespeicherten Formularwert pruefen koennen, nicht den
* gespeicherten Stand. Ein NICHT gesendetes oder leeres Geheimnisfeld heisst
* "gespeicherten Wert weiterverwenden" (Merge-Logik in
* `ProxmoxService.resolveEffectiveTestServer`), genau wie beim Bearbeiten.
* Fuer die Neuanlage (noch kein gespeicherter Server) bleiben alle Felder
* optional, weil es dort keinen gespeicherten Fallback gibt — ein fehlendes
* Pflichtfeld fuehrt dort einfach zum selben Fehlerschluessel wie ein leer
* gelassenes Feld beim Anlegen selbst (z. B. `zugang` ohne Geheimnis).
*/
export class TestProxmoxServerDto extends UpdateProxmoxServerDto {}
+143
View File
@@ -0,0 +1,143 @@
import { Agent, fetch as undiciFetch } from 'undici';
import { classifyFailure, parseJsonLenient } from './proxmox-client.service';
import type { ProxmoxErrorKind, ProxmoxProductType } from './proxmox.types';
/**
* Die EINZIGE Stelle im gesamten Modul, die Anmeldeinformationen in
* Kopfzeilen (und ab Aufgabe 2 Cookies) uebersetzt (D-03, key_link):
* Klient, Verbindungstest und Planer rufen ausschliesslich diese Funktionen
* — keiner baut eine Kopfzeile nach. Jede Funktion nimmt Klartext entgegen
* und gibt nur die Kopfzeile zurueck; keine protokolliert das Geheimnis,
* keine wirft es in eine Fehlermeldung (T-DHH-01).
*/
/**
* API-Token-Kopfzeile. PVE und PBS teilen sich das Schema `<Produkt>APIToken`,
* unterscheiden sich aber im Trennzeichen vor dem Geheimnis (Recherche,
* Block 1): PVE nutzt ein Gleichheitszeichen, PBS einen Doppelpunkt. PMG
* kennt laut Recherche (Annahme A1, Forenbeleg, kein Primaerbeleg) kein
* Token-Schema — ein Aufruf mit `productType: 'pmg'` ist ein Programmierfehler
* (das DTO lehnt einen PMG-Token-Zugang bereits beim Speichern ab, siehe
* Aufgabe 2) und wirft deshalb statt still eine unbrauchbare Kopfzeile zu bauen.
*/
export function buildTokenAuthHeader(
productType: ProxmoxProductType,
tokenId: string,
tokenSecret: string,
): { Authorization: string } {
if (productType === 'pve') {
return { Authorization: `PVEAPIToken=${tokenId}=${tokenSecret}` };
}
if (productType === 'pbs') {
return { Authorization: `PBSAPIToken=${tokenId}:${tokenSecret}` };
}
throw new Error(
'PMG unterstuetzt keinen API-Token-Zugang (Annahme A1 der Recherche) — dieser Aufruf haette bereits beim Speichern des Servers abgelehnt werden muessen.',
);
}
/** 8 Sekunden — derselbe Wert wie `proxmox-client.service.ts` (Proxmox-Server stehen im lokalen Netz). */
const TICKET_LOGIN_TIMEOUT_MS = 8000;
/**
* Cookie-Name je Produkt, unter dem Folgeanfragen das Ticket mitfuehren.
* PVE ist woertlich aus der offiziellen Wiki-Seite zitiert; PBS und PMG
* sind aus dem Muster ABGELEITET, NICHT in der Doku bestaetigt (Recherche,
* Annahme A2) — der Nutzer bestaetigt sie an seinen echten Servern. Steht
* dort ein anderer Name, ist GENAU DIESE Konstante anzupassen, sonst nichts.
*/
const TICKET_COOKIE_NAME: Record<ProxmoxProductType, string> = {
pve: 'PVEAuthCookie',
pbs: 'PBSAuthCookie', // ANNAHME A2 — abgeleitet, nicht in pbs.proxmox.com/docs bestaetigt
pmg: 'PMGAuthCookie', // ANNAHME A2 — abgeleitet, nicht im pmg-admin-guide bestaetigt
};
/** Cookie-Kopfzeile fuer eine Ticket-Folgeanfrage. Kein `CSRFPreventionToken` — dieses Modul liest nur (D-01, Recherche Block 1). */
export function buildTicketCookieHeader(
productType: ProxmoxProductType,
ticket: string,
): { Cookie: string } {
return { Cookie: `${TICKET_COOKIE_NAME[productType]}=${ticket}` };
}
export type LoginTicketResult =
| { ok: true; ticket: string }
| { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string };
/**
* Ticket-Anmeldung — die EINZIGE Stelle im gesamten Modul, die eine
* NICHT-lesende Anfrage an Proxmox schickt (D-01, `proxmox-nur-
* lesen.spec.ts` zaehlt das maschinell nach). Sie aendert bei Proxmox
* nichts — sie holt nur einen Nachweis (ein Ticket) ab, mit dem
* Folgeanfragen sich als der eingetragene Benutzer ausweisen. Wie
* `proxmoxGet` wirft sie nach aussen nichts: jeder Fehlerfall landet als
* Ergebniswert.
*/
export async function loginTicket(
target: { baseUrl: string; tlsRejectUnauthorized: boolean },
productType: ProxmoxProductType,
username: string,
password: string,
): Promise<LoginTicketResult> {
const dispatcher = target.tlsRejectUnauthorized
? undefined
: new Agent({ connect: { rejectUnauthorized: false } });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TICKET_LOGIN_TIMEOUT_MS);
const url = `${target.baseUrl.replace(/\/+$/, '')}/api2/json/access/ticket`;
try {
const body = new URLSearchParams({ username, password });
// GENAU HIER, und nirgendwo sonst im Modul, wird ein Anfrageverfahren
// explizit an `undiciFetch` uebergeben (`method: 'POST'`) — der
// maschinelle Riegel `proxmox-nur-lesen.spec.ts` erwartet diese Zahl
// als exakt EINS.
const response = await undiciFetch(url, {
method: 'POST',
dispatcher,
signal: controller.signal,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const text = await response.text();
if (!response.ok) {
return {
ok: false,
errorKind: classifyFailure(response.status, null),
errorDetail: `Ticket-Anmeldung fehlgeschlagen (Status ${response.status})`,
};
}
const parsed = parseJsonLenient(text);
if (!parsed.ok) {
return {
ok: false,
errorKind: 'antwortform',
errorDetail: 'Die Antwort der Ticket-Anmeldung war kein JSON.',
};
}
const data = (parsed.data as { data?: { ticket?: unknown } } | null)?.data;
const ticket = data && typeof data.ticket === 'string' ? data.ticket : null;
if (!ticket) {
return {
ok: false,
errorKind: 'antwortform',
errorDetail: 'Die Antwort der Ticket-Anmeldung enthielt kein Ticket.',
};
}
return { ok: true, ticket };
} catch (err) {
return {
ok: false,
errorKind: classifyFailure(null, err),
errorDetail: 'Ticket-Anmeldung fehlgeschlagen: Verbindung nicht moeglich.',
};
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,375 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz geht (Vorbild
* `icon-discovery.service.spec.ts`).
*/
vi.mock('undici', () => ({
Agent: class Agent {
constructor(public readonly options: unknown) {}
},
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe, Signatur folgt dem Original
fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
}));
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
forSystem: vi.fn((p: unknown) => p),
}));
import { validate } from 'class-validator';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
import { buildTicketCookieHeader, loginTicket } from './proxmox-auth';
import { classifyFailure, parseJsonLenient, proxmoxGet } from './proxmox-client.service';
import { ProxmoxService } from './proxmox.service';
const crypto = {
encrypt: vi.fn((plaintext: string) =>
['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'),
),
decrypt: vi.fn((stored: string) => {
const [, , ciphertext] = stored.split(':');
return Buffer.from(ciphertext, 'hex').toString('utf8');
}),
};
function makeFakePrisma() {
const servers = new Map<string, any>();
const statuses = new Map<string, any>();
function applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) {
if (key === 'status') {
out.status = statuses.get(row.id) ?? null;
continue;
}
if (select[key]) out[key] = row[key];
}
return out;
}
const proxmoxServer = {
create: vi.fn(async ({ data, select }: { data: any; select?: any }) => {
const id = `srv-${servers.size + 1}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
delete row.status;
servers.set(id, row);
if (data.status?.create) {
statuses.set(id, { id: `status-${id}`, serverId: id, updatedAt: new Date(), ...data.status.create });
}
return applySelect(row, select);
}),
findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => {
let rows = [...servers.values()];
if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId);
return rows.map((r) => applySelect(r, select));
}),
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
const row = servers.get(where.id);
return row ? { ...row } : null;
}),
};
const proxmoxServerStatus = {
upsert: vi.fn(
async ({
where,
create,
update,
}: {
where: { serverId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
const existing = statuses.get(where.serverId);
const record = existing
? { ...existing, ...update }
: { id: `status-${where.serverId}`, updatedAt: new Date(), ...create };
statuses.set(where.serverId, record);
return { ...record };
},
),
};
return { proxmoxServer, proxmoxServerStatus, __servers: servers, __statuses: statuses };
}
const PASSWORD_DTO = {
name: 'pmg-1',
productType: 'pmg' as const,
baseUrl: 'https://pmg.intern:8006',
authMethod: 'password' as const,
username: 'admin@pmg',
password: 'geheimes-passwort',
};
function pveResourcesBody() {
return { data: [{ type: 'node', node: 'pve1', cpu: 0.1, maxcpu: 4, mem: 1, maxmem: 2 }] };
}
describe('classifyFailure (Aufgabe 2, <behavior>)', () => {
it('401 -> zugang, 403 -> rechte, 404 -> antwortform, 5xx -> server', () => {
expect(classifyFailure(401, null)).toBe('zugang');
expect(classifyFailure(403, null)).toBe('rechte');
expect(classifyFailure(404, null)).toBe('antwortform');
expect(classifyFailure(500, null)).toBe('server');
expect(classifyFailure(503, null)).toBe('server');
});
it('ein geworfener Netzfehler ohne Antwort wird zu netz', () => {
expect(classifyFailure(null, new Error('ECONNREFUSED'))).toBe('netz');
expect(classifyFailure(null, new Error('timeout'))).toBe('netz');
});
it('ein Zertifikatsfehler wird zu zertifikat, NICHT zu netz', () => {
const err = new Error('self signed certificate') as Error & { code?: string };
err.code = 'DEPTH_ZERO_SELF_SIGNED_CERT';
expect(classifyFailure(null, err)).toBe('zertifikat');
});
it('ein unbekannter Statuscode wird zu unbekannt', () => {
expect(classifyFailure(418, null)).toBe('unbekannt');
});
});
describe('parseJsonLenient (Aufgabe 2, <behavior>)', () => {
it('gueltiges JSON -> ok:true mit den Daten', () => {
expect(parseJsonLenient('{"a":1}')).toEqual({ ok: true, data: { a: 1 } });
});
it('kein JSON (HTML-Anmeldeseite) -> ok:false, kein Wurf', () => {
expect(() => parseJsonLenient('<html>login</html>')).not.toThrow();
expect(parseJsonLenient('<html>login</html>')).toEqual({ ok: false });
});
it('leerer Rumpf -> ok:false', () => {
expect(parseJsonLenient('')).toEqual({ ok: false });
});
});
describe('proxmoxGet — Integration gegen gemockten undici-Aufruf', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('401 wird zu errorKind zugang', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('Unauthorized', { status: 401 })));
const result = await proxmoxGet(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
'/api2/json/cluster/resources',
);
expect(result.ok).toBe(false);
expect(result.errorKind).toBe('zugang');
});
it('404 wird zu errorKind antwortform', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })));
const result = await proxmoxGet(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
'/api2/json/cluster/resources',
);
expect(result.errorKind).toBe('antwortform');
});
it('ein geworfener Netzfehler ohne Antwort wird zu errorKind netz', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('ECONNREFUSED');
}),
);
const result = await proxmoxGet(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
'/api2/json/cluster/resources',
);
expect(result.errorKind).toBe('netz');
});
it('eine Antwort, die kein JSON ist, fuehrt zu antwortform — kein Wurf', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('<html>Anmeldeseite</html>', { status: 200 })),
);
await expect(
proxmoxGet(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true, headers: {} },
'/api2/json/cluster/resources',
),
).resolves.toMatchObject({ ok: false, errorKind: 'antwortform' });
});
it('errorDetail enthaelt niemals ein Geheimnis', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ errors: { password: 'invalid' } }), { status: 401 })),
);
const result = await proxmoxGet(
{
baseUrl: 'https://pve.intern',
tlsRejectUnauthorized: true,
headers: { Authorization: 'PVEAPIToken=user@pam!tok=super-geheimes-secret-xyz' },
},
'/api2/json/cluster/resources',
);
expect(result.errorDetail).not.toContain('super-geheimes-secret-xyz');
});
});
describe('Ticket-Anmeldung (loginTicket) und Cookie-Kopfzeile (Aufgabe 2, <behavior>)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('POST /api2/json/access/ticket mit username/password liefert data.ticket', async () => {
const fetchSpy = vi.fn(async (url: string, options: RequestInit) => {
expect(url).toBe('https://pmg.intern:8006/api2/json/access/ticket');
expect(options.method).toBe('POST');
expect(options.body).toBe('username=admin%40pmg&password=geheimes-passwort');
return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:abc123' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
const result = await loginTicket(
{ baseUrl: 'https://pmg.intern:8006', tlsRejectUnauthorized: true },
'pmg',
'admin@pmg',
'geheimes-passwort',
);
expect(result).toEqual({ ok: true, ticket: 'PMG:admin@pmg:abc123' });
});
it('kein CSRFPreventionToken wird jemals mitgesendet', async () => {
const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => {
const headerKeys = Object.keys((options.headers as Record<string, string>) ?? {});
expect(headerKeys.some((k) => k.toLowerCase().includes('csrf'))).toBe(false);
expect(String(options.body)).not.toContain('CSRF');
return new Response(JSON.stringify({ data: { ticket: 't' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
await loginTicket({ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true }, 'pve', 'u', 'p');
});
it('Cookie-Kopfzeile traegt den produktabhaengigen Namen (PVE/PBS/PMG)', () => {
expect(buildTicketCookieHeader('pve', 'T1')).toEqual({ Cookie: 'PVEAuthCookie=T1' });
expect(buildTicketCookieHeader('pbs', 'T1')).toEqual({ Cookie: 'PBSAuthCookie=T1' });
expect(buildTicketCookieHeader('pmg', 'T1')).toEqual({ Cookie: 'PMGAuthCookie=T1' });
});
it('401 bei der Anmeldung selbst wird zu errorKind zugang', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 401 })));
const result = await loginTicket(
{ baseUrl: 'https://pve.intern', tlsRejectUnauthorized: true },
'pve',
'u',
'falsch',
);
expect(result).toMatchObject({ ok: false, errorKind: 'zugang' });
});
});
describe('PMG + Token wird beim Speichern abgelehnt (Aufgabe 2, <behavior>)', () => {
it('DTO-Validierung schlaegt fehl fuer productType pmg + authMethod token', async () => {
const dto = new CreateProxmoxServerDto();
Object.assign(dto, {
name: 'pmg-token',
productType: 'pmg',
baseUrl: 'https://pmg.intern',
authMethod: 'token',
tokenId: 'root@pam!x',
tokenSecret: 'geheim',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('PMG + password bleibt gueltig', async () => {
const dto = new CreateProxmoxServerDto();
Object.assign(dto, PASSWORD_DTO);
const errors = await validate(dto);
expect(errors).toEqual([]);
});
});
describe('Ticket-Erneuerung bei password-Auth (Aufgabe 2, <behavior> — genau EIN zweiter Versuch)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('erstes 401 loest genau eine erneute Anmeldung aus, danach gelingt die Abfrage', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
...PASSWORD_DTO,
productType: 'pve',
baseUrl: 'https://pve.intern',
});
let loginCalls = 0;
let getCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.endsWith('/access/ticket')) {
loginCalls++;
return new Response(JSON.stringify({ data: { ticket: `T${loginCalls}` } }), { status: 200 });
}
getCalls++;
if (getCalls === 1) return new Response('abgelaufen', { status: 401 });
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(loginCalls).toBe(2);
expect(getCalls).toBe(2);
expect(result?.reachable).toBe(true);
});
it('ein zweites 401 bleibt errorKind zugang — kein dritter Versuch', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
...PASSWORD_DTO,
productType: 'pve',
baseUrl: 'https://pve.intern',
});
let loginCalls = 0;
let getCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.endsWith('/access/ticket')) {
loginCalls++;
return new Response(JSON.stringify({ data: { ticket: `T${loginCalls}` } }), { status: 200 });
}
getCalls++;
return new Response('abgelaufen', { status: 401 });
}),
);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(loginCalls).toBe(2);
expect(getCalls).toBe(2);
expect(result?.reachable).toBe(false);
expect(result?.errorKind).toBe('zugang');
});
});
describe('forTenant bleibt Konvention auch mit Passwort-Zugang (D-08)', () => {
it('nutzt forTenant beim Anlegen', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', PASSWORD_DTO);
expect(forTenant).toHaveBeenCalled();
});
});
@@ -0,0 +1,230 @@
import { Agent, fetch as undiciFetch } from 'undici';
import type { ProxmoxErrorKind } from './proxmox.types';
/**
* Der HTTP-Zugang dieses Moduls, und ausschliesslich lesend (D-01). Genau
* EINE oeffentliche Datenabruf-Funktion `proxmoxGet` — das Anfrageverfahren
* ist fest auf GET verdrahtet, es gibt dafuer keinen Parameter und kein
* Durchreichen von aussen. `proxmox-nur-lesen.spec.ts` (Aufgabe 2) zaehlt
* maschinell nach, dass dies im gesamten Modul die einzige Stelle ist, die
* ein Anfrageverfahren an `undiciFetch` uebergibt.
*
* Zwingend `undiciFetch` aus dem `undici`-Paket, NICHT das globale `fetch`:
* Nodes globales `fetch` ignoriert einen `Agent`-Dispatcher aus dem
* npm-Paket (andere Klasse) — gemessen und dokumentiert in
* `apps/api/src/favorites/icon-discovery.service.ts:33-40`. Wer hier aus
* Gewohnheit zum globalen `fetch` wechselt, bekommt keinen Fehler beim
* Kompilieren, sondern eine zur Laufzeit STILLSCHWEIGEND ignorierte Option
* — ein selbstsigniertes Zertifikat wuerde trotz `tlsRejectUnauthorized:
* false` weiter abgelehnt.
*
* Der Dispatcher wird JE AUFRUF aus dem `tlsRejectUnauthorized`-Feld GENAU
* DIESER Serverzeile gebaut (D-04, T-DHH-03): ist es wahr (Vorgabe), wird
* KEIN Dispatcher uebergeben — echte Zertifikatspruefung, der Normalweg.
* Ist es falsch, ein FRISCHER `new Agent({ connect: { rejectUnauthorized:
* false } } )` NUR fuer diesen einen Aufruf. Ausdruecklich KEINE
* Modulkonstante wie `LENIENT_TLS_AGENT` in `icon-discovery.service.ts`
* (die Ausnahme eines Servers darf nie auf einen zweiten wirken) und
* ausdruecklich KEINE Node-Umgebungsvariable, die mit `NODE_TLS_` beginnt.
*
* Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen
* per Definition im privaten Netz, eine solche Pruefung wuerde jede reale
* Adresse blockieren (T-DHH-02). Die Absicherung ist stattdessen, dass nur
* ein Administrator (`@Roles(ADMIN, SUPER_ADMIN)`) Adressen eintragen darf
* — siehe Bedrohungsmodell T-DHH-02 im Plan.
*/
/** 8 Sekunden — Proxmox-Server stehen im lokalen Netz, eine laengere Wartezeit deutet auf "nicht erreichbar". */
const REQUEST_TIMEOUT_MS = 8000;
/** Deckel fuer `errorDetail` — niemals mehr als das, und nie ein Geheimnis (T-DHH-01). */
const ERROR_DETAIL_MAX_CHARS = 500;
/**
* Bekannte Zertifikatsfehlerkennungen von Node/undici. Ein Treffer wird zu
* `errorKind: 'zertifikat'`; im Zweifel (keine dieser Kennungen erkannt)
* bleibt es bei `'netz'` — eine Verwechslung in die falsche Richtung waere
* hier schlimmer als ein zu vorsichtiges "nicht erreichbar" (Aufgabe 2 `<behavior>`).
*/
const CERTIFICATE_ERROR_CODES = new Set([
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'CERT_HAS_EXPIRED',
'ERR_TLS_CERT_ALTNAME_INVALID',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_UNTRUSTED',
'ERR_TLS_CERT_ALTNAME_INVALID_ALTERNATE',
'CERT_SIGNATURE_FAILURE',
'CERT_NOT_YET_VALID',
]);
export interface ProxmoxGetTarget {
baseUrl: string;
tlsRejectUnauthorized: boolean;
/** Fertige Kopfzeilen — gebaut ausschliesslich von `proxmox-auth.ts` (D-03). */
headers: Record<string, string>;
}
export interface ProxmoxGetResult {
ok: boolean;
status: number | null;
body: unknown;
errorKind: ProxmoxErrorKind | null;
errorDetail: string | null;
}
/**
* Nachsichtiges JSON-Parsen: eine Antwort, die kein JSON ist (HTML-
* Anmeldeseite, leerer Rumpf), fuehrt zu `{ ok: false }` — kein geworfener
* Parserfehler, kein Absturz (Aufgabe 2 `<behavior>`).
*/
export function parseJsonLenient(text: string): { ok: true; data: unknown } | { ok: false } {
if (!text || text.trim().length === 0) {
return { ok: false };
}
try {
return { ok: true, data: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function isCertificateError(err: unknown): boolean {
const code = (err as { code?: unknown; cause?: { code?: unknown } })?.code;
const causeCode = (err as { cause?: { code?: unknown } })?.cause?.code;
if (typeof code === 'string' && CERTIFICATE_ERROR_CODES.has(code)) return true;
if (typeof causeCode === 'string' && CERTIFICATE_ERROR_CODES.has(causeCode)) return true;
const message = err instanceof Error ? err.message : String(err ?? '');
for (const known of CERTIFICATE_ERROR_CODES) {
if (message.includes(known)) return true;
}
return false;
}
/**
* Reine Fehler-Uebersetzung: liefert genau eine der sieben Werte aus
* `ProxmoxErrorKind`. `status` ist gesetzt, wenn Proxmox geantwortet hat;
* `thrownError` ist gesetzt, wenn der Aufruf selbst fehlgeschlagen ist
* (kein HTTP-Status, z. B. `ECONNREFUSED`/Timeout/DNS-Fehler).
*
* 401 -> 'zugang', 403 -> 'rechte', 404 -> 'antwortform' (falsche Adresse
* vermutet), 5xx -> 'server'. Ein geworfener Fehler ohne Antwort ist
* 'netz' — ausser die Fehlerkennung ist eindeutig eine Zertifikatskennung,
* dann 'zertifikat' (Aufgabe 2 `<behavior>`).
*/
export function classifyFailure(
status: number | null,
thrownError: unknown,
): ProxmoxErrorKind {
if (status === null) {
if (thrownError !== null && thrownError !== undefined && isCertificateError(thrownError)) {
return 'zertifikat';
}
return 'netz';
}
if (status === 401) return 'zugang';
if (status === 403) return 'rechte';
if (status === 404) return 'antwortform';
if (status >= 500 && status < 600) return 'server';
return 'unbekannt';
}
/**
* Kurze, deutsche Ergaenzung aus Statuszahl und — falls vorhanden und JSON
* — dem `errors`-Feld der Proxmox-Antwort. Auf `ERROR_DETAIL_MAX_CHARS`
* gekuerzt; niemals die gesendete Kopfzeile, niemals ein Geheimnis
* (T-DHH-01).
*/
function buildHttpErrorDetail(status: number, bodyText: string): string {
let detail = `Proxmox antwortete mit Status ${status}`;
const parsed = parseJsonLenient(bodyText);
if (parsed.ok && parsed.data && typeof parsed.data === 'object' && 'errors' in parsed.data) {
try {
const errorsText = JSON.stringify((parsed.data as { errors: unknown }).errors);
detail += `: ${errorsText}`;
} catch {
/* errors-Feld liess sich nicht serialisieren — Statuszahl allein reicht */
}
}
return detail.slice(0, ERROR_DETAIL_MAX_CHARS);
}
function buildThrownErrorDetail(err: unknown): string {
const message = err instanceof Error ? err.message : String(err ?? 'unbekannter Fehler');
return `Verbindung fehlgeschlagen: ${message}`.slice(0, ERROR_DETAIL_MAX_CHARS);
}
/**
* Die einzige Datenabruf-Funktion dieses Moduls (D-01). Wirft nach aussen
* NICHTS — jeder Fehlerfall (Netz, Zertifikat, HTTP-Status, kein JSON)
* landet als Ergebniswert in `errorKind`/`errorDetail`, damit ein
* Aufrufer nie mit einem unbehandelten Wurf abbricht.
*/
export async function proxmoxGet(
target: ProxmoxGetTarget,
path: string,
): Promise<ProxmoxGetResult> {
const dispatcher = target.tlsRejectUnauthorized
? undefined // Normalweg: echte Zertifikatspruefung, kein Sonderfall
: new Agent({ connect: { rejectUnauthorized: false } }); // NUR fuer diesen einen Aufruf (D-04)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const url = `${target.baseUrl.replace(/\/+$/, '')}${path}`;
try {
// KEIN `method`-Feld — GET ist der Grundwert von `fetch`/`undiciFetch`
// selbst, es gibt hierfuer keinen Parameter (D-01). `proxmox-nur-
// lesen.spec.ts` zaehlt Stellen, die ein Anfrageverfahren EXPLIZIT an
// `undiciFetch` uebergeben — die einzige solche Stelle im Modul ist
// `loginTicket` in `proxmox-auth.ts` (POST, Ticket-Anmeldung, D-01).
const response = await undiciFetch(url, {
dispatcher,
signal: controller.signal,
headers: target.headers,
});
const text = await response.text();
if (!response.ok) {
return {
ok: false,
status: response.status,
body: null,
errorKind: classifyFailure(response.status, null),
errorDetail: buildHttpErrorDetail(response.status, text),
};
}
const parsed = parseJsonLenient(text);
if (!parsed.ok) {
return {
ok: false,
status: response.status,
body: null,
errorKind: 'antwortform',
errorDetail: 'Die Antwort war kein JSON (z. B. eine Anmeldeseite oder ein leerer Rumpf).',
};
}
return {
ok: true,
status: response.status,
body: parsed.data,
errorKind: null,
errorDetail: null,
};
} catch (err) {
return {
ok: false,
status: null,
body: null,
errorKind: classifyFailure(null, err),
errorDetail: buildThrownErrorDetail(err),
};
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,287 @@
import { describe, expect, it } from 'vitest';
import {
listPbsDatastoreNames,
normalizePbs,
normalizePmg,
normalizePve,
readBool,
readList,
readNumber,
readText,
} from './proxmox-normalize';
describe('nachsichtige Leser (Aufgabe 3, <behavior>)', () => {
it('readNumber: Zahl, umwandelbare Zeichenkette, sonst null', () => {
expect(readNumber(42)).toBe(42);
expect(readNumber('42')).toBe(42);
expect(readNumber('0.37')).toBe(0.37);
expect(readNumber('nicht-umwandelbar')).toBeNull();
expect(readNumber(undefined)).toBeNull();
expect(readNumber(null)).toBeNull();
expect(readNumber(Number.NaN)).toBeNull();
});
it('readText: nichtleere Zeichenkette oder Zahl, sonst null', () => {
expect(readText('hallo')).toBe('hallo');
expect(readText(42)).toBe('42');
expect(readText('')).toBeNull();
expect(readText(null)).toBeNull();
expect(readText(undefined)).toBeNull();
});
it('readBool: boolesch oder gaengige Wahr/Falsch-Formen, sonst null', () => {
expect(readBool(true)).toBe(true);
expect(readBool('true')).toBe(true);
expect(readBool(1)).toBe(true);
expect(readBool(false)).toBe(false);
expect(readBool('false')).toBe(false);
expect(readBool('irgendwas')).toBeNull();
});
it('readList: alles, was kein Array ist, wird eine leere Liste', () => {
expect(readList([1, 2])).toEqual([1, 2]);
expect(readList('kein-array')).toEqual([]);
expect(readList(null)).toEqual([]);
expect(readList(undefined)).toEqual([]);
expect(readList({})).toEqual([]);
});
});
describe('normalizePve (Aufgabe 3, <behavior>)', () => {
it('Knotenzahl, laufende/gestoppte Gaeste, je Knoten Prozessorlast/Speicher, je Speicherort Belegung', () => {
const body = {
data: [
{ type: 'node', node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
{ type: 'node', node: 'pve2', cpu: 0.1, maxcpu: 4, mem: 1_000_000_000, maxmem: 8_000_000_000 },
{ type: 'qemu', node: 'pve1', status: 'running' },
{ type: 'qemu', node: 'pve1', status: 'stopped' },
{ type: 'lxc', node: 'pve2', status: 'running' },
{ type: 'storage', node: 'pve1', storage: 'local-lvm', disk: 100, maxdisk: 500 },
],
};
const { metrics, errorKind } = normalizePve(body);
expect(errorKind).toBeNull();
expect(metrics.nodeCount).toBe(2);
expect(metrics.guestsRunning).toBe(2);
expect(metrics.guestsStopped).toBe(1);
expect(metrics.nodes).toEqual([
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
{ node: 'pve2', cpu: 0.1, maxcpu: 4, mem: 1_000_000_000, maxmem: 8_000_000_000 },
]);
expect(metrics.storages).toEqual([
{ storage: 'local-lvm', node: 'pve1', disk: 100, maxdisk: 500 },
]);
});
it('Feld fehlt -> null, nie 0/Wurf', () => {
const body = { data: [{ type: 'node', node: 'pve1' }] };
expect(() => normalizePve(body)).not.toThrow();
const { metrics } = normalizePve(body);
expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null });
});
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
const body = { data: [{ type: 'node', node: 'pve1', cpu: '0.5', maxcpu: '4', mem: '100', maxmem: '200' }] };
const { metrics } = normalizePve(body);
expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: 0.5, maxcpu: 4, mem: 100, maxmem: 200 });
});
it('Antwort ist HTML statt JSON-Objekt (hier: eine Zeichenkette) -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
expect(() => normalizePve('<html>Anmeldeseite</html>')).not.toThrow();
const { metrics, errorKind } = normalizePve('<html>Anmeldeseite</html>');
expect(errorKind).toBe('antwortform');
expect(metrics).toEqual({
productType: 'pve',
nodeCount: 0,
guestsRunning: 0,
guestsStopped: 0,
nodes: [],
storages: [],
});
});
it('Antwort ist ein Array statt eines Objekts -> antwortform, kein Wurf', () => {
const { errorKind } = normalizePve([1, 2, 3]);
expect(errorKind).toBe('antwortform');
});
it('Antwort ist null -> antwortform, kein Wurf', () => {
const { errorKind } = normalizePve(null);
expect(errorKind).toBe('antwortform');
});
});
describe('normalizePbs (Aufgabe 3, <behavior>)', () => {
it('je Datenspeicher Gesamt/Belegt/Frei, letzter Sicherungszeitpunkt und letztes Pruefergebnis', () => {
const usage = {
data: [{ store: 'backup-store', total: 1000, used: 400, avail: 600 }],
};
const snapshotsByStore = {
'backup-store': {
data: [
{ 'backup-time': 1000, verification: { state: 'ok' } },
{ 'backup-time': 2000, verification: { state: 'failed' } },
],
},
};
const { metrics, errorKind } = normalizePbs(usage, snapshotsByStore);
expect(errorKind).toBeNull();
expect(metrics.datastores).toEqual([
{
name: 'backup-store',
total: 1000,
used: 400,
free: 600,
lastBackupAt: 2000,
lastVerifyState: 'failed',
},
]);
});
it('ein Datenspeicher ohne Sicherungen ergibt null (Frontend zeigt "noch keine Sicherung") und keinen Fehler', () => {
const usage = { data: [{ store: 'leer', total: 10, used: 0, avail: 10 }] };
const { metrics, errorKind } = normalizePbs(usage, { leer: { data: [] } });
expect(errorKind).toBeNull();
expect(metrics.datastores[0]).toMatchObject({ lastBackupAt: null, lastVerifyState: null });
});
it('Feld fehlt -> null, nie 0/Wurf', () => {
const usage = { data: [{ store: 'x' }] };
expect(() => normalizePbs(usage, {})).not.toThrow();
const { metrics } = normalizePbs(usage, {});
expect(metrics.datastores[0]).toEqual({
name: 'x',
total: null,
used: null,
free: null,
lastBackupAt: null,
lastVerifyState: null,
});
});
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
const usage = { data: [{ store: 'x', total: '1000', used: '400', avail: '600' }] };
const { metrics } = normalizePbs(usage, {});
expect(metrics.datastores[0]).toMatchObject({ total: 1000, used: 400, free: 600 });
});
it('Antwort ist HTML statt JSON -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
expect(() => normalizePbs('<html></html>', {})).not.toThrow();
const { metrics, errorKind } = normalizePbs('<html></html>', {});
expect(errorKind).toBe('antwortform');
expect(metrics.datastores).toEqual([]);
});
it('ein PBS-Server mit vielen Datenspeichern: listPbsDatastoreNames liefert alle Namen (Deckel lebt in proxmox.service.ts)', () => {
const usage = { data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}` })) };
expect(listPbsDatastoreNames(usage)).toHaveLength(15);
});
});
describe('normalizePmg (Aufgabe 3, <behavior>)', () => {
it('Tageszahlen eingehend, ausgehend, Spam, Viren', () => {
const body = {
data: {
count_in: 100,
count_out: 50,
spamcount_in: 10,
spamcount_out: 2,
viruscount_in: 1,
viruscount_out: 0,
},
};
const { metrics, errorKind } = normalizePmg(body);
expect(errorKind).toBeNull();
expect(metrics).toEqual({
productType: 'pmg',
countIn: 100,
countOut: 50,
spamCount: 12,
virusCount: 1,
});
});
it('Feld fehlt -> null, nie 0/Wurf', () => {
const body = { data: {} };
expect(() => normalizePmg(body)).not.toThrow();
const { metrics } = normalizePmg(body);
expect(metrics).toEqual({
productType: 'pmg',
countIn: null,
countOut: null,
spamCount: null,
virusCount: null,
});
});
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
const body = { data: { count_in: '100', count_out: '50' } };
const { metrics } = normalizePmg(body);
expect(metrics.countIn).toBe(100);
expect(metrics.countOut).toBe(50);
});
it('Antwort ist HTML statt JSON -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
expect(() => normalizePmg('<html></html>')).not.toThrow();
const { metrics, errorKind } = normalizePmg('<html></html>');
expect(errorKind).toBe('antwortform');
expect(metrics).toEqual({
productType: 'pmg',
countIn: null,
countOut: null,
spamCount: null,
virusCount: null,
});
});
it('nur eine Haelfte vorhanden -> null (Spam, nur spamcount_in)', () => {
const body = { data: { spamcount_in: 10 } };
const { metrics } = normalizePmg(body);
expect(metrics.spamCount).toBeNull();
});
it('nur eine Haelfte vorhanden -> null (Spam, nur spamcount_out)', () => {
const body = { data: { spamcount_out: 2 } };
const { metrics } = normalizePmg(body);
expect(metrics.spamCount).toBeNull();
});
it('nur eine Haelfte vorhanden -> null (Viren, nur viruscount_in)', () => {
const body = { data: { viruscount_in: 1 } };
const { metrics } = normalizePmg(body);
expect(metrics.virusCount).toBeNull();
});
it('nur eine Haelfte vorhanden -> null (Viren, nur viruscount_out)', () => {
const body = { data: { viruscount_out: 3 } };
const { metrics } = normalizePmg(body);
expect(metrics.virusCount).toBeNull();
});
it('eine Haelfte ist nicht lesbar -> null (Spam, spamcount_out ist Text)', () => {
const body = { data: { spamcount_in: 10, spamcount_out: 'abc' } };
const { metrics } = normalizePmg(body);
expect(metrics.spamCount).toBeNull();
});
it('Unabhaengigkeit der Paare: Spam unvollstaendig, Viren vollstaendig -> spamCount null, virusCount 1, countIn/countOut unberuehrt', () => {
const body = {
data: {
count_in: 100,
count_out: 50,
spamcount_in: 10,
viruscount_in: 1,
viruscount_out: 0,
},
};
const { metrics } = normalizePmg(body);
expect(metrics.spamCount).toBeNull();
expect(metrics.virusCount).toBe(1);
expect(metrics.countIn).toBe(100);
expect(metrics.countOut).toBe(50);
});
});
+273
View File
@@ -0,0 +1,273 @@
import type {
ProxmoxErrorKind,
ProxmoxPbsDatastoreMetric,
ProxmoxPbsMetrics,
ProxmoxPmgMetrics,
ProxmoxPveMetrics,
ProxmoxPveNodeMetric,
} from './proxmox.types';
/**
* Nachsichtige Leser als reine Funktionen ohne Datenbankbezug — der
* gesamte Umgang mit einer unerwarteten Form ist ein Rueckgabewert
* (`null`/leere Liste), NIE eine Ausnahme. Der Nutzer prueft dieses Modul
* ausschliesslich an seinen eigenen, echten Servern; ein Wurf wuerde ihm
* eine leere Seite zeigen statt eines ehrlichen "unbekannt".
*/
export function readNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
export function readText(value: unknown): string | null {
if (typeof value === 'string' && value.trim() !== '') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return null;
}
export function readBool(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (value === 'true' || value === 1 || value === '1') return true;
if (value === 'false' || value === 0 || value === '0') return false;
return null;
}
/** Liefert bei allem, was kein Array ist, eine LEERE Liste — nie einen Wurf. */
export function readList(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Nimmt den ersten VORHANDENEN Schluessel einer Namensliste (mehrere plausible Namen, in Reihenfolge). */
function readFirstPresent(record: Record<string, unknown>, keys: readonly string[]): unknown {
for (const key of keys) {
if (key in record && record[key] !== undefined) return record[key];
}
return undefined;
}
export interface NormalizeResult<TMetrics> {
metrics: TMetrics;
errorKind: ProxmoxErrorKind | null;
}
// ---------------------------------------------------------------------------
// PVE — /api2/json/cluster/resources
// ---------------------------------------------------------------------------
function emptyPveMetrics(): ProxmoxPveMetrics {
return {
productType: 'pve',
nodeCount: 0,
guestsRunning: 0,
guestsStopped: 0,
nodes: [],
storages: [],
};
}
export function normalizePve(body: unknown): NormalizeResult<ProxmoxPveMetrics> {
if (!isRecord(body)) {
// Ganze Antwort ist Zeichenkette/Array/null/leer — leeres Messwertobjekt, kein Wurf.
return { metrics: emptyPveMetrics(), errorKind: 'antwortform' };
}
const list = readList(body.data);
const isEntry = (e: unknown): e is Record<string, unknown> => isRecord(e);
const nodeEntries = list.filter((e) => isEntry(e) && e.type === 'node') as Record<string, unknown>[];
const guestEntries = list.filter(
(e) => isEntry(e) && (e.type === 'qemu' || e.type === 'lxc'),
) as Record<string, unknown>[];
const storageEntries = list.filter((e) => isEntry(e) && e.type === 'storage') as Record<
string,
unknown
>[];
const running = guestEntries.filter((g) => g.status === 'running').length;
const nodes: ProxmoxPveNodeMetric[] = nodeEntries.map((n) => ({
node: readText(n.node) ?? 'unbekannt',
cpu: readNumber(n.cpu),
maxcpu: readNumber(n.maxcpu),
mem: readNumber(n.mem),
maxmem: readNumber(n.maxmem),
}));
const storages = storageEntries.map((s) => ({
storage: readText(s.storage) ?? 'unbekannt',
node: readText(s.node) ?? 'unbekannt',
disk: readNumber(s.disk),
maxdisk: readNumber(s.maxdisk),
}));
return {
metrics: {
productType: 'pve',
nodeCount: nodeEntries.length,
guestsRunning: running,
guestsStopped: guestEntries.length - running,
nodes,
storages,
},
errorKind: null,
};
}
// ---------------------------------------------------------------------------
// PBS — /api2/json/status/datastore-usage + je Datenspeicher .../snapshots
// ---------------------------------------------------------------------------
/**
* Feldnamen der PBS-Belegungsabfrage sind aus der Recherche nur ABGELEITET
* (Annahme A3, Forenbeleg, kein Primaerbeleg) — GENAU DIESE Konstante ist
* anzupassen, wenn ein echter PBS-Server andere Namen liefert.
*/
const PBS_USAGE_FIELDS = {
store: ['store', 'name'],
total: ['total'],
used: ['used'],
free: ['avail', 'free'],
} as const;
/** Dieselbe Annahme A3 fuer die Sicherungsliste eines Datenspeichers. */
const PBS_SNAPSHOT_FIELDS = {
backupTime: ['backup-time', 'backupTime'],
verifyState: ['verification', 'verify-state', 'verifyState'],
} as const;
function emptyPbsMetrics(): ProxmoxPbsMetrics {
return { productType: 'pbs', datastores: [] };
}
/**
* Nur die Datenspeichernamen aus der Belegungsantwort — fuer den Deckel
* der Folgeabfragen in `proxmox.service.ts` (Aufgabe 3, hoechstens 10 je
* Durchlauf). Dieselbe Feldnamen-Konstante wie `normalizePbs`, damit es
* EINE Stelle zum Nachziehen gibt, nicht zwei.
*/
export function listPbsDatastoreNames(usage: unknown): string[] {
if (!isRecord(usage)) return [];
return readList(usage.data)
.filter(isRecord)
.map((entry) => readText(readFirstPresent(entry, PBS_USAGE_FIELDS.store)))
.filter((name): name is string => name !== null);
}
function readVerifyState(value: unknown): string | null {
// `verification` kann selbst ein Objekt sein ({ state: 'ok', ... }) oder
// direkt eine Zeichenkette — beide Formen kommen in Forenbeispielen vor.
if (isRecord(value)) {
const state = readFirstPresent(value, ['state', 'result']);
return readText(state);
}
return readText(value);
}
/**
* `usage` ist die Antwort von `/status/datastore-usage`; `snapshotsByStore`
* bildet je Datenspeichernamen die (bereits abgefragte) Rohantwort seiner
* `/admin/datastore/{store}/snapshots`-Abfrage ab — `undefined`, wenn der
* Deckel von hoechstens 10 Folgeabfragen je Durchlauf (`proxmox.service.ts`)
* diesen Speicher nicht mehr erreicht hat.
*/
export function normalizePbs(
usage: unknown,
snapshotsByStore: Record<string, unknown>,
): NormalizeResult<ProxmoxPbsMetrics> {
if (!isRecord(usage)) {
return { metrics: emptyPbsMetrics(), errorKind: 'antwortform' };
}
const entries = readList(usage.data).filter(isRecord);
const datastores: ProxmoxPbsDatastoreMetric[] = entries.map((entry) => {
const name = readText(readFirstPresent(entry, PBS_USAGE_FIELDS.store)) ?? 'unbekannt';
const snapshotsBody = snapshotsByStore[name];
const snapshotList = isRecord(snapshotsBody) ? readList(snapshotsBody.data).filter(isRecord) : [];
let lastBackupAt: number | null = null;
let lastVerifyState: string | null = null;
for (const snapshot of snapshotList) {
const backupTime = readNumber(readFirstPresent(snapshot, PBS_SNAPSHOT_FIELDS.backupTime));
if (backupTime !== null && (lastBackupAt === null || backupTime > lastBackupAt)) {
lastBackupAt = backupTime;
lastVerifyState = readVerifyState(readFirstPresent(snapshot, PBS_SNAPSHOT_FIELDS.verifyState));
}
}
// Kein Eintrag in der Liste (leer, aber kein Fehler): "noch keine
// Sicherung" — Frontend (Aufgabe 6) unterscheidet das ueber
// `snapshotList.length === 0`, hier bleibt der Wert ehrlich `null`.
return {
name,
total: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.total)),
used: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.used)),
free: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.free)),
lastBackupAt,
lastVerifyState,
};
});
return { metrics: { productType: 'pbs', datastores }, errorKind: null };
}
// ---------------------------------------------------------------------------
// PMG — /api2/json/statistics/mail
// ---------------------------------------------------------------------------
/** Annahme A5 der Recherche — abgeleitet aus `pmgsh`-Community-Belegen, nicht aus Primaerdoku. */
const PMG_STATS_FIELDS = {
countIn: ['count_in'],
countOut: ['count_out'],
spamIn: ['spamcount_in'],
spamOut: ['spamcount_out'],
virusIn: ['viruscount_in'],
virusOut: ['viruscount_out'],
} as const;
function emptyPmgMetrics(): ProxmoxPmgMetrics {
return { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null };
}
// Eine Tageszahl aus zwei Teilwerten ist nur dann bekannt, wenn beide
// Teilwerte bekannt sind; eine Teilsumme saehe vollstaendig aus, waere
// aber still falsch (Abnahmebefund 260923-dhh, Wahrheit 7; PMG-Feldnamen
// sind nur Annahme A5).
function sumOrNull(a: number | null, b: number | null): number | null {
if (a === null || b === null) return null;
return a + b;
}
export function normalizePmg(body: unknown): NormalizeResult<ProxmoxPmgMetrics> {
if (!isRecord(body)) {
return { metrics: emptyPmgMetrics(), errorKind: 'antwortform' };
}
const stats = isRecord(body.data) ? body.data : {};
const countIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.countIn));
const countOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.countOut));
const spamIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.spamIn));
const spamOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.spamOut));
const virusIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.virusIn));
const virusOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.virusOut));
return {
metrics: {
productType: 'pmg',
countIn,
countOut,
spamCount: sumOrNull(spamIn, spamOut),
virusCount: sumOrNull(virusIn, virusOut),
},
errorKind: null,
};
}
@@ -0,0 +1,187 @@
import { readFileSync, readdirSync } from 'node:fs';
import { basename, join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Der maschinelle Riegel zu D-01 ("nur beobachten") — gebaut nach dem
* Vorbild von `apps/api/src/prisma/rls-access-inventory.spec.ts`: der Test
* liest den Quelltext, nicht das Laufzeitverhalten. Zwei Aussagen:
*
* 1. Die Summe der Stellen, die ein Anfrageverfahren EXPLIZIT an
* `undiciFetch` uebergeben (`method: '...'`), ist genau
* `EXPECTED_METHOD_PASSING_CALLS` und liegt in `proxmox-auth.ts`
* (die Ticket-Anmeldung, `loginTicket` — die einzige nicht-lesende
* Anfrage im gesamten Modul, D-01). `proxmoxGet` in
* `proxmox-client.service.ts` uebergibt bewusst KEIN `method`-Feld:
* GET ist der Grundwert von `fetch` selbst.
* 2. Jeder gegen einen Proxmox-API-Pfad (`/api2/json/...`) gebauter Aufruf
* ausser der Ticket-Anmeldung laeuft ueber `proxmoxGet(...)`.
*
* Die erwartete Zahl steht als benannte Konstante mit ausgeschriebener
* Begruendung — eine spaetere Erhoehung erzwingt eine bewusste
* Entscheidung, statt unbemerkt durchzurutschen (T-DHH-07).
*/
/**
* GENAU EIN Aufruf darf im gesamten Modul ein Anfrageverfahren explizit an
* `undiciFetch` uebergeben: `loginTicket()` in `proxmox-auth.ts`
* (`method: 'POST'`, Ticket-Anmeldung). Jede weitere Stelle waere ein neuer,
* bislang unbedachter veraendernder Weg gegen Proxmox — T-DHH-07.
*/
const EXPECTED_METHOD_PASSING_CALLS = 1;
const EXPECTED_METHOD_PASSING_FILE = 'proxmox-auth.ts';
const PROXMOX_SRC_DIR = join(__dirname);
function listTsFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...listTsFiles(full));
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
out.push(full);
}
}
return out;
}
/** Entfernt Zeilen- und Blockkommentare — Vorbild `rls-access-inventory.spec.ts`. */
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((line) => !line.trim().startsWith('//'))
.join('\n');
}
/**
* Entfernt zusaetzlich Zeichenkettenliterale (nach dem Kommentar-Entfernen)
* — fuer Testdateien, damit eine erfundene Testkonstante wie
* `'https://x/api2/json/...'` in einer `expect(...)`-Zeile oder ein
* mockierter Antwortkoerper nicht als Fundstelle zaehlt (Plan-Vorgabe:
* "entfernt vor dem Zaehlen Kommentarzeilen und Zeichenkettenliterale aus
* Testdateien").
*/
function stripStringLiterals(source: string): string {
return source
.replace(/`(?:[^`\\]|\\.)*`/g, '``')
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
}
interface CallSpan {
start: number;
end: number;
}
/** Sammelt Argumentbereiche aller Aufrufe `calleeName(...)` per Klammertiefe. */
function collectCallArgSpans(text: string, calleeName: string): CallSpan[] {
const spans: CallSpan[] = [];
const re = new RegExp(`\\b${calleeName}\\(`, 'g');
let m: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: Standard-Iterationsform der Nachbardatei rls-access-inventory.spec.ts
while ((m = re.exec(text))) {
const openIdx = re.lastIndex - 1;
let depth = 0;
let i = openIdx;
for (; i < text.length; i++) {
if (text[i] === '(') depth++;
else if (text[i] === ')') {
depth--;
if (depth === 0) break;
}
}
spans.push({ start: openIdx, end: i });
}
return spans;
}
/** Zaehlt Stellen, die `method:` innerhalb eines `undiciFetch(...)`-Aufrufs uebergeben. */
function countMethodPassingCalls(text: string): number {
let count = 0;
const re = /undiciFetch\(/g;
let m: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: s.o.
while ((m = re.exec(text))) {
const openIdx = re.lastIndex - 1;
let depth = 0;
let i = openIdx;
for (; i < text.length; i++) {
if (text[i] === '(') depth++;
else if (text[i] === ')') {
depth--;
if (depth === 0) break;
}
}
const argsText = text.slice(openIdx, i + 1);
if (/\bmethod\s*:/.test(argsText)) count++;
}
return count;
}
/**
* Aufrufformen, deren Argumentbereich einen Proxmox-Pfad tragen darf:
* `proxmoxGet` selbst, UND `getWithRetry` — der private Umschlag in
* `proxmox.service.ts` (Aufgabe 2/3, Ticket-Erneuerung), der seinerseits
* ausschliesslich `proxmoxGet` ruft (durch dieselbe erste Aussage dieses
* Riegels abgesichert: keine zweite `undiciFetch`-Methodenstelle in dieser
* Datei).
*/
const ALLOWED_PATH_CALLEES = ['proxmoxGet', 'getWithRetry'] as const;
/** Fundstellen eines Proxmox-API-Pfads ausserhalb einer erlaubten Aufrufform. */
function findApiPathViolations(fileName: string, text: string): string[] {
if (fileName === 'proxmox-auth.ts') {
// Die Ticket-Anmeldung ist die eine dokumentierte Ausnahme (D-01).
return [];
}
const allowedSpans = ALLOWED_PATH_CALLEES.flatMap((callee) => collectCallArgSpans(text, callee));
const violations: string[] = [];
const pathRe = /\/api2\/json\/[A-Za-z0-9/{}_.-]*/g;
let m: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: s.o.
while ((m = pathRe.exec(text))) {
const idx = m.index;
const insideAllowedCall = allowedSpans.some((s) => idx >= s.start && idx <= s.end);
if (!insideAllowedCall) {
violations.push(`${fileName}@${idx}: ${m[0]}`);
}
}
return violations;
}
describe('proxmox-nur-lesen (D-01, T-DHH-07) — der maschinelle Riegel', () => {
const files = listTsFiles(PROXMOX_SRC_DIR);
it(`genau ${EXPECTED_METHOD_PASSING_CALLS} Stelle uebergibt ein Anfrageverfahren an undiciFetch, in ${EXPECTED_METHOD_PASSING_FILE}`, () => {
const perFile = files.map((file) => {
const raw = readFileSync(file, 'utf-8');
const isTest = file.endsWith('.spec.ts');
const cleaned = isTest ? stripStringLiterals(stripComments(raw)) : stripComments(raw);
return { file: basename(file), count: countMethodPassingCalls(cleaned) };
});
const total = perFile.reduce((sum, f) => sum + f.count, 0);
const filesWithCalls = perFile.filter((f) => f.count > 0).map((f) => f.file);
expect(total, `Gefundene Stellen: ${JSON.stringify(perFile.filter((f) => f.count > 0))}`).toBe(
EXPECTED_METHOD_PASSING_CALLS,
);
expect(filesWithCalls).toEqual([EXPECTED_METHOD_PASSING_FILE]);
});
it('jeder gegen einen Proxmox-Pfad gebaute Aufruf ausser der Ticket-Anmeldung laeuft ueber proxmoxGet', () => {
// Nur Produktionsdateien bauen tatsaechlich Aufrufe — Testdateien
// enthalten denselben Pfadtext nur als erwarteten Wert in `expect(...)`,
// das ist kein "gebauter Aufruf" im Sinn dieser Aussage.
const productionFiles = files.filter((file) => !file.endsWith('.spec.ts'));
const violations = productionFiles.flatMap((file) => {
const raw = readFileSync(file, 'utf-8');
const cleaned = stripComments(raw); // Pfad-Texte bleiben erhalten — nur Kommentare raus
return findApiPathViolations(basename(file), cleaned);
});
expect(violations).toEqual([]);
});
});
@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
/**
* ProxmoxSchedulerService.spec (Aufgabe 4) — Vorbild
* `dkv-scheduler.service.spec.ts`: echte Fake-Registry (Map-basiert,
* `getCronJob` wirft bei Unbekannt wie `@nestjs/schedule`), ECHTES `cron`
* (Peer von `@nestjs/schedule`) — `cronTime.source` und `fireOnTick()`
* sind die beobachtbaren Eigenschaften eines Auftrags.
*/
function makeFakeRegistry() {
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe
const jobs = new Map<string, any>();
return {
__jobs: jobs,
addCronJob: vi.fn((name: string, job: any) => {
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
jobs.set(name, job);
}),
getCronJob: vi.fn((name: string) => {
const job = jobs.get(name);
if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`);
return job;
}),
deleteCronJob: vi.fn((name: string) => {
const job = jobs.get(name);
if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`);
jobs.delete(name);
}),
getCronJobs: vi.fn(() => jobs),
};
}
interface FakeServerRow {
id: string;
tenantId: string;
pollIntervalMin: number;
isActive: boolean;
}
function makeFakeProxmoxService(
servers: FakeServerRow[] | Error,
options: { pollShouldThrowFor?: string[] } = {},
) {
const polledServerIds: string[] = [];
return {
loadActiveServersForScheduler: vi.fn(async () => {
if (servers instanceof Error) throw servers;
return servers.filter((s) => s.isActive).map((s) => ({
id: s.id,
tenantId: s.tenantId,
pollIntervalMin: s.pollIntervalMin,
}));
}),
loadActiveServersForTenantScheduling: vi.fn(async (tenantId: string) => {
if (servers instanceof Error) return [];
return servers
.filter((s) => s.isActive && s.tenantId === tenantId)
.map((s) => ({ pollIntervalMin: s.pollIntervalMin }));
}),
listActiveServerIdsForTenant: vi.fn(async (tenantId: string) => {
if (servers instanceof Error) return [];
return servers.filter((s) => s.isActive && s.tenantId === tenantId).map((s) => s.id);
}),
pollServer: vi.fn(async (_tenantId: string, serverId: string) => {
polledServerIds.push(serverId);
if (options.pollShouldThrowFor?.includes(serverId)) {
throw new Error(`poll boom for ${serverId}`);
}
return { reachable: true, errorKind: null, errorDetail: null, metrics: null, rawSample: null };
}),
__polledServerIds: polledServerIds,
};
}
function makeScheduler(
servers: FakeServerRow[] | Error,
options: { pollShouldThrowFor?: string[] } = {},
) {
const registry = makeFakeRegistry();
const proxmoxService = makeFakeProxmoxService(servers, options);
const scheduler = new ProxmoxSchedulerService(registry as any, proxmoxService as any);
const logSpy = vi.spyOn((scheduler as any).logger, 'log').mockImplementation(() => undefined);
const errorSpy = vi.spyOn((scheduler as any).logger, 'error').mockImplementation(() => undefined);
return { registry, proxmoxService, scheduler, logSpy, errorSpy };
}
describe('ProxmoxSchedulerService — ein Auftrag je Mandant (Aufgabe 4, <behavior>)', () => {
const registries: ReturnType<typeof makeFakeRegistry>[] = [];
afterEach(() => {
for (const registry of registries) {
for (const job of registry.__jobs.values()) job.stop();
registry.__jobs.clear();
}
registries.length = 0;
vi.restoreAllMocks();
});
it('Beim Start registriert der Planer je Mandant mit mindestens einem aktiven Server genau einen Auftrag unter proxmox-poll:<tenantId>', async () => {
const { registry, scheduler } = makeScheduler([
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect([...registry.__jobs.keys()]).toEqual(['proxmox-poll:t1']);
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/15 * * * *');
});
it('Das Abfrageintervall eines Mandanten ist das KLEINSTE pollIntervalMin seiner aktiven Server', async () => {
const { registry, scheduler } = makeScheduler([
{ id: 's1', tenantId: 't1', pollIntervalMin: 30, isActive: true },
{ id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true },
]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/5 * * * *');
});
it('Ein zweiter Mandant verdraengt den Auftrag des ersten nicht — beide Auftraege bestehen nebeneinander', async () => {
const { registry, scheduler } = makeScheduler([
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
{ id: 's2', tenantId: 't2', pollIntervalMin: 10, isActive: true },
]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(new Set(registry.__jobs.keys())).toEqual(new Set(['proxmox-poll:t1', 'proxmox-poll:t2']));
});
it('Keine aktiven Server bedeutet: kein Auftrag, ein Protokolleintrag, kein Fehler, nichts geloescht', async () => {
const { registry, scheduler, logSpy, errorSpy } = makeScheduler([]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(registry.__jobs.size).toBe(0);
expect(logSpy).toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
it('Ein Fehler beim Start wird gefangen und protokolliert, nie weitergeworfen', async () => {
const { scheduler, errorSpy } = makeScheduler(new Error('DB weg'));
await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
});
it('Der Planer haengt an onApplicationBootstrap, nicht an onModuleInit', () => {
const registry = makeFakeRegistry();
const scheduler = new ProxmoxSchedulerService(registry as any, {} as any);
expect(typeof (scheduler as unknown as { onApplicationBootstrap?: unknown }).onApplicationBootstrap).toBe(
'function',
);
expect((scheduler as unknown as { onModuleInit?: unknown }).onModuleInit).toBeUndefined();
});
it('Der Tick eines Mandanten geht ueber dessen Server und fragt jeden einzeln ab; ein fehlgeschlagener Server bricht die Schleife nicht ab', async () => {
const { registry, scheduler, proxmoxService, errorSpy } = makeScheduler(
[
{ id: 's1', tenantId: 't1', pollIntervalMin: 5, isActive: true },
{ id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true },
],
{ pollShouldThrowFor: ['s1'] },
);
registries.push(registry);
await scheduler.onApplicationBootstrap();
registry.__jobs.get('proxmox-poll:t1').fireOnTick();
await new Promise((resolve) => setImmediate(resolve));
expect((proxmoxService as any).__polledServerIds).toEqual(['s1', 's2']);
expect(errorSpy).toHaveBeenCalled();
});
it('refreshTenant zieht den Auftrag eines Mandanten sofort nach — ohne Neustart', async () => {
const { registry, scheduler } = makeScheduler([]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(registry.__jobs.size).toBe(0);
(scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => [
{ pollIntervalMin: 20 },
]);
await scheduler.refreshTenant('t1');
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/20 * * * *');
});
it('refreshTenant entfernt den Auftrag, wenn keine aktiven Server mehr uebrig sind', async () => {
const { registry, scheduler } = makeScheduler([
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(registry.__jobs.has('proxmox-poll:t1')).toBe(true);
(scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => []);
await scheduler.refreshTenant('t1');
expect(registry.__jobs.has('proxmox-poll:t1')).toBe(false);
});
});
@@ -0,0 +1,186 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { ProxmoxService } from './proxmox.service';
/**
* CronJob constructor — resolved at runtime via require() because `cron` is
* a transitive dependency of @nestjs/schedule (not a direct api dep under
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
* it as a peer dep. Reuses the exact DkvSchedulerService resolution
* workaround verbatim.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
require('cron').CronJob as new (cronTime: string, onTick: () => void) => { start(): void };
/**
* ProxmoxSchedulerService — Hintergrundabfrage je Mandant (Aufgabe 4).
* Kombiniert die zwei Bestandsmuster (Recherche, Block 3):
*
* - Das Mandanten-Auffaechern von `DkvSchedulerService`: EIN Cron-Auftrag
* je aktivem Mandanten, Registry-Name `proxmox-poll:<tenantId>` — die
* Vorgaengerform mit EINEM Auftragsfeld war genau der Fehler WINDOWS #21,
* ERSATZLOS vermieden.
* - Die Lebenszyklus-Stufe von `TenderSchedulerService`:
* `implements OnApplicationBootstrap`, NICHT `OnModuleInit` — die
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis
* zum zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
*
* Anders als bei DKV ist ein Mandant NICHT gleich ein Server: der Tick
* eines Mandanten geht ueber dessen Serverzeilen. Das Abfrageintervall
* eines Mandanten ist das KLEINSTE `pollIntervalMin` seiner aktiven Server.
* Ein fehlgeschlagener Server schreibt seinen Fehler ins Zwischenlager
* (das erledigt `ProxmoxService.pollServer` bereits selbst — ein
* geworfener Fehler waere hier ein echter Bug, nicht ein "nicht
* erreichbar") und die Schleife laeuft weiter.
*/
@Injectable()
export class ProxmoxSchedulerService implements OnApplicationBootstrap {
private readonly logger = new Logger(ProxmoxSchedulerService.name);
/** Praefix der Registry-Namen; der volle Name ist `<Praefix>:<tenantId>`. */
private readonly JOB_NAME_PREFIX = 'proxmox-poll';
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly proxmoxService: ProxmoxService,
) {}
private jobNameFor(tenantId: string): string {
return `${this.JOB_NAME_PREFIX}:${tenantId}`;
}
/**
* Beim Start: laedt ALLE aktiven `ProxmoxServer`-Zeilen (Systemkontext,
* `ProxmoxService.loadActiveServersForScheduler`) und registriert je
* aktivem Mandanten genau einen Cron-Auftrag. Eine LEERE Liste bedeutet
* "nichts tun" — kein Auftrag, ein Protokolleintrag, kein Fehler, nichts
* geloescht. Ein Fehler beim Start wird gefangen und protokolliert, nie
* weitergeworfen — die Anwendung startet trotzdem.
*/
async onApplicationBootstrap(): Promise<void> {
try {
const servers = await this.proxmoxService.loadActiveServersForScheduler();
if (!servers || servers.length === 0) {
this.logger.log('Proxmox scheduler: no active server found — cron job not registered');
return;
}
const byTenant = new Map<string, number[]>();
for (const server of servers) {
const intervals = byTenant.get(server.tenantId) ?? [];
intervals.push(server.pollIntervalMin);
byTenant.set(server.tenantId, intervals);
}
for (const [tenantId, intervals] of byTenant) {
this.setInterval(Math.min(...intervals), tenantId);
}
this.logger.log(`Proxmox scheduler initialized: ${byTenant.size} tenant(s)`);
} catch (err) {
this.logger.error(`Proxmox scheduler init failed: ${(err as Error).message}`);
}
}
/**
* Erzeugt (oder ersetzt) den Poll-Auftrag GENAU EINES Mandanten. Ersetzt
* nur den Auftrag unter diesem Registry-Namen — ein zweiter Mandant
* verdraengt den Auftrag des ersten nicht.
*/
setInterval(intervalMin: number, tenantId: string): void {
const jobName = this.jobNameFor(tenantId);
try {
this.schedulerRegistry.getCronJob(jobName).stop();
this.schedulerRegistry.deleteCronJob(jobName);
} catch {
/* Auftrag noch nicht registriert — beim ersten Aufruf erwartet */
}
let cronExpr: string;
if (intervalMin < 60) {
cronExpr = `*/${intervalMin} * * * *`;
} else {
const hours = Math.floor(intervalMin / 60);
cronExpr = `0 */${hours} * * *`;
}
const job = new CronJobClass(cronExpr, () => {
this.tick(tenantId).catch((err) =>
this.logger.error(
`Proxmox poll tick failed for tenant ${tenantId}: ${(err as Error).message}`,
),
);
});
// Cast noetig — dasselbe Muster wie DkvSchedulerService/TenderSchedulerService.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.schedulerRegistry.addCronJob(jobName, job as any);
job.start();
this.logger.log(
`Proxmox cron job registered: every ${intervalMin} minutes for tenant ${tenantId}`,
);
}
/** Entfernt NUR den Poll-Auftrag dieses Mandanten. */
stopJob(tenantId: string): void {
const jobName = this.jobNameFor(tenantId);
try {
this.schedulerRegistry.getCronJob(jobName).stop();
this.schedulerRegistry.deleteCronJob(jobName);
this.logger.log(`Proxmox cron job stopped and removed for tenant ${tenantId}`);
} catch {
/* Nicht registriert — kein Vorgang */
}
}
/**
* Vom Controller nach jedem Anlegen/Speichern eines Servers gerufen, damit
* der Planer ohne Neustart nachzieht (Vorbild `DkvController`). Ohne
* aktive Server dieses Mandanten wird der Auftrag entfernt.
*/
async refreshTenant(tenantId: string): Promise<void> {
const servers = await this.proxmoxService.loadActiveServersForTenantScheduling(tenantId);
if (!servers || servers.length === 0) {
this.stopJob(tenantId);
return;
}
this.setInterval(Math.min(...servers.map((s) => s.pollIntervalMin)), tenantId);
}
/**
* Der Tick EINES Mandanten: geht ueber dessen aktive Server und fragt
* jeden einzeln ab. `pollServer` faengt jeden Proxmox-seitigen Fehler
* bereits selbst ab (Ergebnis statt Wurf) — dieses try/catch schuetzt
* zusaetzlich vor einem echten Programmfehler (z. B. einem
* Datenbankfehler beim Schreiben), damit ein einzelner defekter Server
* die Abfrage der uebrigen Server desselben Mandanten nicht verhindert.
*/
private async tick(tenantId: string): Promise<void> {
const serverIds = await this.proxmoxService.listActiveServerIdsForTenant(tenantId);
for (const serverId of serverIds) {
try {
await this.proxmoxService.pollServer(tenantId, serverId);
} catch (err) {
this.logger.error(
`Proxmox poll failed for server ${serverId} (tenant ${tenantId}): ${(err as Error).message}`,
);
}
}
}
/**
* Alle Mandanten, fuer die derzeit ein Auftrag registriert ist — aus der
* Registry abgeleitet, fuer Tests und Diagnose.
*/
registeredTenantIds(): string[] {
const prefix = `${this.JOB_NAME_PREFIX}:`;
const names = [...this.schedulerRegistry.getCronJobs().keys()] as string[];
return names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length));
}
}
+123
View File
@@ -0,0 +1,123 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Post,
Put,
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
import {
CreateProxmoxServerDto,
TestProxmoxServerDto,
UpdateProxmoxServerDto,
} from './dto/proxmox-server.dto';
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
import { ProxmoxService } from './proxmox.service';
/**
* `@UseModule('proxmox')` auf Klassenebene (D-09, Vorbild
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/test`,
* `POST servers/:id/poll`, `POST servers/:id/test`) zusaetzlich
* `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05). `servers/test` (statisch, zwei
* Segmente) und `servers/:id/test` (drei Segmente) ueberschneiden sich
* nicht — beide POST, aber unterschiedliche Segmentzahl, deshalb keine
* Reihenfolge-Abhaengigkeit (anders als `GET :id` vs. statische Routen).
*/
@Controller('modules/proxmox')
@UseModule('proxmox')
export class ProxmoxController {
constructor(
private readonly proxmoxService: ProxmoxService,
private readonly scheduler: ProxmoxSchedulerService,
) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('servers')
async list(@Req() req: AuthenticatedRequest) {
return this.proxmoxService.listWithStatus(this.requireTenantId(req));
}
@Post('servers')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) {
const tenantId = this.requireTenantId(req);
const created = await this.proxmoxService.createServer(tenantId, dto);
// Planer sofort nachziehen — ohne Neustart (Aufgabe 4, Vorbild DkvController).
await this.scheduler.refreshTenant(tenantId);
return created;
}
@Put('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateProxmoxServerDto,
) {
const tenantId = this.requireTenantId(req);
const updated = await this.proxmoxService.updateServer(tenantId, id, dto);
await this.scheduler.refreshTenant(tenantId);
return updated;
}
@Delete('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this.requireTenantId(req);
const deleted = await this.proxmoxService.deleteServer(tenantId, id);
await this.scheduler.refreshTenant(tenantId);
return { deleted };
}
@Post('servers/:id/poll')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.proxmoxService.pollServer(this.requireTenantId(req), id);
}
/**
* Verbindungstest fuer einen gespeicherten Server (Aufgabe 4, `<behavior>`;
* Nachbesserung Befund 1: `dto` traegt den aktuellen Formularstand,
* `ProxmoxService.testConnection` prueft diesen statt blind des
* gespeicherten Stands). Liefert bei Erfolg eine Erfolgsmeldung und bei
* Misserfolg einen der sieben Fehlerschluessel samt kurzer Ergaenzung,
* OHNE den Zwischenlagerstand zu ueberschreiben.
*/
@Post('servers/:id/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async test(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: TestProxmoxServerDto,
) {
return this.proxmoxService.testConnection(this.requireTenantId(req), id, dto);
}
/**
* Verbindungstest waehrend der Neuanlage (Nachbesserung Befund 1): es gibt
* noch keinen gespeicherten Server, `dto` ist deshalb die einzige Quelle.
*/
@Post('servers/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testDraft(@Req() req: AuthenticatedRequest, @Body() dto: TestProxmoxServerDto) {
this.requireTenantId(req);
return this.proxmoxService.testDraftConnection(dto);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { ProxmoxController } from './proxmox.controller';
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
import { seedProxmoxModule } from './proxmox.seed';
import { ProxmoxService } from './proxmox.service';
/**
* NestJS module for the Proxmox feature (260923-dhh). Vorbild
* `DomaincheckModule`: seeds itself into the module registry on startup.
* `ScheduleModule` ist bereits global in `app.module.ts` registriert — der
* Planer (Aufgabe 4) braucht hier nichts zusaetzlich, nur die Aufnahme in
* `providers`.
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [ProxmoxController],
providers: [ProxmoxService, ProxmoxSchedulerService],
})
export class ProxmoxModule implements OnModuleInit {
private readonly logger = new Logger(ProxmoxModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedProxmoxModule(this.moduleRegistryService);
this.logger.log('Proxmox module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed proxmox module', error);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Seeds the proxmox module into the module registry (D-09).
* Vorbild `domaincheck.seed.ts`. Kategorie `infrastructure` — die erste
* Kachel/Modul in dieser Kategorie.
*/
export async function seedProxmoxModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'proxmox',
name: 'Proxmox',
version: '1.0.0',
category: 'infrastructure',
description: {
de: 'Proxmox VE/PBS/PMG beobachten — nur lesend',
en: 'Observe Proxmox VE/PBS/PMG — read-only',
},
isSystem: true,
});
}
@@ -0,0 +1,635 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz geht (Vorbild
* `icon-discovery.service.spec.ts`) — die Mock-Klasse zeichnet nur die
* uebergebenen `options` auf, `fetch` delegiert zur Laufzeit an
* `globalThis.fetch`, damit `vi.stubGlobal('fetch', …)` je Test greift.
*/
vi.mock('undici', () => ({
Agent: class Agent {
constructor(public readonly options: unknown) {}
},
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe, Signatur folgt dem Original
fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
}));
// `forTenant` gibt in diesem Test denselben Client zurueck — Mandantenbindung
// selbst ist nicht Gegenstand dieser Datei (siehe rls-access-inventory.spec.ts).
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
forSystem: vi.fn((p: unknown) => p),
}));
import { Agent } from 'undici';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { ProxmoxService } from './proxmox.service';
import type { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
/** Durchschaubarer Ersatz fuer AES-256-GCM — Zusammenspiel unter Test, nicht die Bibliothek. */
const crypto = {
encrypt: vi.fn((plaintext: string) =>
['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'),
),
decrypt: vi.fn((stored: string) => {
const [, , ciphertext] = stored.split(':');
return Buffer.from(ciphertext, 'hex').toString('utf8');
}),
};
function makeFakePrisma() {
const servers = new Map<string, any>();
const statuses = new Map<string, any>(); // key: serverId
function applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) {
if (key === 'status') {
out.status = statuses.get(row.id) ?? null;
continue;
}
if (select[key]) out[key] = row[key];
}
return out;
}
const proxmoxServer = {
create: vi.fn(async ({ data, select }: { data: any; select?: any }) => {
const id = `srv-${servers.size + 1}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
delete row.status; // nested create handled below
servers.set(id, row);
if (data.status?.create) {
statuses.set(id, { id: `status-${id}`, serverId: id, updatedAt: new Date(), ...data.status.create });
}
return applySelect(row, select);
}),
findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => {
let rows = [...servers.values()];
if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId);
if (where?.isActive !== undefined) rows = rows.filter((r) => r.isActive === where.isActive);
return rows.map((r) => applySelect(r, select));
}),
findUnique: vi.fn(
async ({ where, include }: { where: { id: string }; include?: { status?: boolean } }) => {
const row = servers.get(where.id);
if (!row) return null;
if (include?.status) {
return { ...row, status: statuses.get(row.id) ?? null };
}
return { ...row };
},
),
update: vi.fn(
async ({
where,
data,
select,
}: {
where: { id: string };
data: Record<string, unknown>;
select?: any;
}) => {
const existing = servers.get(where.id);
const updated = { ...existing, ...data, updatedAt: new Date() };
servers.set(where.id, updated);
return applySelect(updated, select);
},
),
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
const row = servers.get(where.id);
servers.delete(where.id);
statuses.delete(where.id); // Fremdschluessel mit Loeschweitergabe (onDelete: Cascade)
return row ? { ...row } : null;
}),
};
const proxmoxServerStatus = {
upsert: vi.fn(
async ({
where,
create,
update,
}: {
where: { serverId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
const existing = statuses.get(where.serverId);
const record = existing
? { ...existing, ...update }
: { id: `status-${where.serverId}`, updatedAt: new Date(), ...create };
statuses.set(where.serverId, record);
return { ...record };
},
),
};
return { proxmoxServer, proxmoxServerStatus, __servers: servers, __statuses: statuses };
}
const TOKEN_DTO: CreateProxmoxServerDto = {
name: 'pve-1',
productType: 'pve',
baseUrl: 'https://pve.intern:8006',
authMethod: 'token',
tokenId: 'root@pam!tessera',
tokenSecret: 'geheimes-token-secret',
};
function pveResourcesBody(overrides: Partial<Record<string, unknown>> = {}) {
return {
data: [
{ type: 'node', node: 'pve1', cpu: 0.12, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
{ type: 'qemu', node: 'pve1', vmid: 100, status: 'running' },
{ type: 'qemu', node: 'pve1', vmid: 101, status: 'stopped' },
{ type: 'lxc', node: 'pve1', vmid: 200, status: 'running' },
],
...overrides,
};
}
describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('legt einen Server verschluesselt an und liefert nie das Geheimnis zurueck', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
expect((created as any).encryptedTokenSecret).toBeUndefined();
expect((created as any).encryptedPassword).toBeUndefined();
const storedRow = [...prisma.__servers.values()][0];
expect(storedRow.encryptedTokenSecret).not.toBe(TOKEN_DTO.tokenSecret);
expect(storedRow.encryptedTokenSecret).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
});
it('listWithStatus liefert weder encryptedTokenSecret noch encryptedPassword', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', TOKEN_DTO);
const list = await service.listWithStatus('tenant-a');
expect(list).toHaveLength(1);
expect(JSON.stringify(list)).not.toContain(TOKEN_DTO.tokenSecret);
expect('encryptedTokenSecret' in (list[0] as object)).toBe(false);
expect('encryptedPassword' in (list[0] as object)).toBe(false);
});
it('pollServer fragt PVE ab, normalisiert nachsichtig und schreibt das Zwischenlager', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
const fetchSpy = vi.fn(async (url: string) => {
expect(url).toBe('https://pve.intern:8006/api2/json/cluster/resources');
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
expect(result?.metrics).toMatchObject({
productType: 'pve',
nodeCount: 1,
guestsRunning: 2,
guestsStopped: 1,
});
const status = prisma.__statuses.get((created as any).id);
expect(status.reachable).toBe(true);
expect(status.metrics).toMatchObject({ nodeCount: 1 });
expect(status.rawSample).toContain('"node":"pve1"');
});
it('sendet die Token-Kopfzeile im PVE-Schema (Gleichheitszeichen vor dem Geheimnis)', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let capturedAuth: string | null = null;
const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => {
capturedAuth = (options.headers as Record<string, string>).Authorization;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
await service.pollServer('tenant-a', (created as any).id);
expect(capturedAuth).toBe(
`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`,
);
});
it('uebergibt bei tlsRejectUnauthorized=true KEINEN Dispatcher, bei false genau einen mit abgeschalteter Pruefung', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const strictServer = await service.createServer('tenant-a', TOKEN_DTO);
const lenientServer = await service.createServer('tenant-a', {
...TOKEN_DTO,
name: 'pve-2',
tlsRejectUnauthorized: false,
});
const dispatchers: unknown[] = [];
const fetchSpy = vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
dispatchers.push(options.dispatcher);
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
await service.pollServer('tenant-a', (strictServer as any).id);
await service.pollServer('tenant-a', (lenientServer as any).id);
expect(dispatchers[0]).toBeUndefined();
expect(dispatchers[1]).toBeInstanceOf(Agent);
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ
expect((dispatchers[1] as any).options).toEqual({
connect: { rejectUnauthorized: false },
});
});
it('ein fehlendes Feld der Antwort fuehrt zu null, nicht zu einem Wurf', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
const bodyWithMissingFields = {
data: [{ type: 'node', node: 'pve1' /* cpu/maxcpu/mem/maxmem fehlen */ }],
};
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(bodyWithMissingFields), { status: 200 })),
);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
const metrics = result?.metrics as { nodes: { cpu: unknown; maxcpu: unknown; mem: unknown; maxmem: unknown }[] };
expect(metrics.nodes[0]).toEqual({
node: 'pve1',
cpu: null,
maxcpu: null,
mem: null,
maxmem: null,
});
});
it('nutzt forTenant fuer jeden Datenbankzugriff (D-08)', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', TOKEN_DTO);
await service.listWithStatus('tenant-a');
expect(forTenant).toHaveBeenCalled();
});
});
describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('fragt PBS ab: Belegung plus je Datenspeicher hoechstens 10 Folgeabfragen', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
...TOKEN_DTO,
name: 'pbs-1',
productType: 'pbs',
baseUrl: 'https://pbs.intern:8007',
});
const usageBody = {
data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}`, total: 100, used: 10, avail: 90 })),
};
let snapshotCalls = 0;
const fetchSpy = vi.fn(async (url: string) => {
if (url.includes('/status/datastore-usage')) {
return new Response(JSON.stringify(usageBody), { status: 200 });
}
snapshotCalls++;
return new Response(JSON.stringify({ data: [] }), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
expect(snapshotCalls).toBe(10);
expect((result?.metrics as { datastores: unknown[] }).datastores).toHaveLength(15);
});
it('fragt PMG ab und normalisiert die Tageszahlen', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
name: 'pmg-1',
productType: 'pmg',
baseUrl: 'https://pmg.intern:8006',
authMethod: 'password',
username: 'admin@pmg',
password: 'geheim',
});
const fetchSpy = vi.fn(async (url: string) => {
if (url.endsWith('/access/ticket')) {
return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:xyz' } }), { status: 200 });
}
return new Response(
JSON.stringify({ data: { count_in: 10, count_out: 5, spamcount_in: 1, spamcount_out: 0, viruscount_in: 0, viruscount_out: 0 } }),
{ status: 200 },
);
});
vi.stubGlobal('fetch', fetchSpy);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
expect(result?.metrics).toMatchObject({ productType: 'pmg', countIn: 10, countOut: 5, spamCount: 1 });
});
it('401/403/404/500 bleiben fuer PBS/PMG dieselben Fehlerschluessel wie fuer PVE', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
...TOKEN_DTO,
name: 'pbs-403',
productType: 'pbs',
baseUrl: 'https://pbs.intern:8007',
});
vi.stubGlobal('fetch', vi.fn(async () => new Response('forbidden', { status: 403 })));
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(false);
expect(result?.errorKind).toBe('rechte');
});
});
describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('testConnection liefert das Ergebnis, schreibt aber NICHT ins Zwischenlager', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(pveResourcesBody()), { status: 200 })),
);
const result = await service.testConnection('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
const status = prisma.__statuses.get((created as any).id);
// Die leere Zwischenlagerzeile aus createServer bleibt unveraendert.
expect(status.lastPolledAt).toBeUndefined();
expect(status.reachable).toBe(false);
});
it('Nachbesserung Befund 1: testConnection prueft die im Formular abgeschaltete Zertifikatspruefung, nicht den gespeicherten Stand (Server wurde MIT tlsRejectUnauthorized:true angelegt)', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
// Gespeichert: Zertifikatspruefung AN (Vorgabe).
const created = await service.createServer('tenant-a', TOKEN_DTO);
const dispatchers: unknown[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
dispatchers.push(options.dispatcher);
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
// Formular: Zertifikatspruefung wurde vom Nutzer AUSGESCHALTET, aber noch nicht gespeichert.
await service.testConnection('tenant-a', (created as any).id, {
tlsRejectUnauthorized: false,
});
// Vor der Korrektur wurde ausschliesslich der gespeicherte Server (Zertifikatspruefung AN)
// getestet — dieser Test waere ohne die Korrektur rot, weil dispatchers[0] dann `undefined` waere.
expect(dispatchers[0]).toBeInstanceOf(Agent);
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ
expect((dispatchers[0] as any).options).toEqual({ connect: { rejectUnauthorized: false } });
});
it('Nachbesserung Befund 1: ein im Formular NEU eingetipptes Token-Geheimnis wird getestet, nicht das gespeicherte', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let capturedAuth: string | null = null;
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, options: RequestInit) => {
capturedAuth = (options.headers as Record<string, string>).Authorization;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
await service.testConnection('tenant-a', (created as any).id, {
tokenSecret: 'ein-anderes-geheimnis',
});
// Ohne die Korrektur wuerde hier weiterhin TOKEN_DTO.tokenSecret gesendet — roter Test.
expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=ein-anderes-geheimnis`);
});
it('Nachbesserung Befund 1: leer gelassenes Geheimnisfeld im Formular nutzt weiterhin das gespeicherte Token-Geheimnis', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let capturedAuth: string | null = null;
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, options: RequestInit) => {
capturedAuth = (options.headers as Record<string, string>).Authorization;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
// Formular sendet kein tokenSecret (Feld leer gelassen) — wie `ServerForm.buildPayload()`.
await service.testConnection('tenant-a', (created as any).id, {});
expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`);
});
it('Nachbesserung Befund 1: testDraftConnection testet einen noch nicht gespeicherten Server ausschliesslich mit den Formularwerten', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
let capturedUrl: string | null = null;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
capturedUrl = url;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
const result = await service.testDraftConnection({
productType: 'pve',
baseUrl: 'https://neu.intern:8006',
authMethod: 'token',
tokenId: 'root@pam!neu',
tokenSecret: 'frisches-geheimnis',
});
expect(result.reachable).toBe(true);
expect(capturedUrl).toBe('https://neu.intern:8006/api2/json/cluster/resources');
});
it('Nachbesserung Befund 1: testDraftConnection ohne Geheimnis liefert den Fehlerschluessel "zugang", statt zu werfen', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const result = await service.testDraftConnection({
productType: 'pve',
baseUrl: 'https://neu.intern:8006',
authMethod: 'token',
tokenId: 'root@pam!neu',
});
expect(result.reachable).toBe(false);
expect(result.errorKind).toBe('zugang');
});
it('POST servers/:id/poll verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert den vorhandenen Stand', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let fetchCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCalls++;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
const first = await service.pollServer('tenant-a', (created as any).id);
const second = await service.pollServer('tenant-a', (created as any).id);
expect(fetchCalls).toBe(1);
expect(second).toEqual(first);
});
it('nach zehn Sekunden ist ein erneuter Durchlauf wieder erlaubt', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let fetchCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCalls++;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
await service.pollServer('tenant-a', (created as any).id);
const status = prisma.__statuses.get((created as any).id);
status.lastPolledAt = new Date(Date.now() - 11_000); // Sperre kuenstlich veraltern
await service.pollServer('tenant-a', (created as any).id);
expect(fetchCalls).toBe(2);
});
it('loadActiveServersForScheduler nutzt forSystem (D-08, der einzige Systemkontext-Aufruf des Moduls)', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', TOKEN_DTO);
const servers = await service.loadActiveServersForScheduler();
expect(servers).toHaveLength(1);
expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 });
});
});
describe('ProxmoxService — Aufgabe 5 (Bearbeiten, Loeschen)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('ein NICHT gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
const storedBefore = prisma.__servers.get((created as any).id).encryptedTokenSecret;
await service.updateServer('tenant-a', (created as any).id, { name: 'neuer-name' });
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBe(storedBefore);
expect(prisma.__servers.get((created as any).id).name).toBe('neuer-name');
});
it('eine LEERE Zeichenkette loescht das Geheimnis, ein gefuellter Wert verschluesselt neu', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: '' });
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBeNull();
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: 'neues-geheimnis' });
const stored = prisma.__servers.get((created as any).id).encryptedTokenSecret;
expect(stored).not.toBe('neues-geheimnis');
expect(stored).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
});
it('PMG plus Token wird auch beim Bearbeiten abgelehnt — auch wenn nur authMethod gesendet wird', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
name: 'pmg-1',
productType: 'pmg',
baseUrl: 'https://pmg.intern',
authMethod: 'password',
username: 'admin@pmg',
password: 'geheim',
});
const dto: UpdateProxmoxServerDto = { authMethod: 'token', tokenId: 'x', tokenSecret: 'y' };
await expect(service.updateServer('tenant-a', (created as any).id, dto)).rejects.toThrow();
});
it('loescht einen Server samt Zwischenlagerzeile', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
expect(prisma.__statuses.has((created as any).id)).toBe(true);
const deleted = await service.deleteServer('tenant-a', (created as any).id);
expect(deleted).toBe(true);
expect(prisma.__servers.has((created as any).id)).toBe(false);
expect(prisma.__statuses.has((created as any).id)).toBe(false);
});
it('deleteServer liefert false fuer einen unbekannten Server', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
expect(await service.deleteServer('tenant-a', 'unbekannt')).toBe(false);
});
});
+638
View File
@@ -0,0 +1,638 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import type { ProxmoxServer } from '@prisma/client';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
import type {
CreateProxmoxServerDto,
TestProxmoxServerDto,
UpdateProxmoxServerDto,
} from './dto/proxmox-server.dto';
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
/**
* Nur die Felder, die eine Abfrage tatsaechlich braucht (Nachbesserung
* Befund 1) — `pollOne`/`buildAuthHeaders`/`getWithRetry` nehmen diesen
* schmalen Ausschnitt statt der vollen `ProxmoxServer`-Zeile entgegen, damit
* `resolveEffectiveTestServer` unten eine rein im Speicher gebaute Mischung
* aus Formular- und gespeicherten Werten uebergeben kann, ohne eine
* vollstaendige Datenbankzeile vorzutaeuschen.
*/
type ProxmoxCredentialSource = Pick<
ProxmoxServer,
| 'productType'
| 'baseUrl'
| 'authMethod'
| 'tokenId'
| 'encryptedTokenSecret'
| 'username'
| 'encryptedPassword'
| 'tlsRejectUnauthorized'
>;
/**
* Erkennungsform fuer "schon verschluesselt" — woertlich aus
* `ldap-config.service.ts:17` uebernommen (Format `iv:authTag:ciphertext`,
* hex, Doppelpunkt-getrennt). Fuer Proxmox als NEUES Feature ab Tag 1
* irrelevant (keine Altdaten), aber derselbe defensive Riegel wie ueberall
* sonst im Projekt.
*/
const ENCRYPTED_VALUE_SHAPE = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i;
/** Rohantwort wird auf hoechstens diese Zeichenzahl gekuerzt in `rawSample` abgelegt. */
const RAW_SAMPLE_MAX_CHARS = 20000;
/**
* `select` OHNE die beiden Geheimnisfelder — die Felder verlassen die
* Datenbank gar nicht erst, statt nachtraeglich maskiert zu werden
* (T-DHH-01, `must_haves.truths`).
*/
const SAFE_SERVER_SELECT = {
id: true,
tenantId: true,
name: true,
productType: true,
baseUrl: true,
authMethod: true,
tokenId: true,
username: true,
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: true,
position: true,
createdAt: true,
updatedAt: true,
status: true,
} as const;
/**
* Deckel der Folgeabfragen je PBS-Durchlauf (Aufgabe 3, `<behavior>`): ein
* PBS-Server mit vielen Datenspeichern soll den Planer nicht mit
* unbegrenzt vielen Anfragen belasten — hoechstens diese Zahl an
* `/snapshots`-Abfragen je Poll-Durchlauf, unabhaengig davon, wie viele
* Datenspeicher der Server tatsaechlich hat.
*/
const PBS_SNAPSHOT_QUERY_CAP = 10;
/**
* Zehn-Sekunden-Sperre fuer `pollServer` (Aufgabe 4, T-DHH-06): ein Klick
* auf "Jetzt aktualisieren" darf nicht zu ungebremsten Anfragen gegen die
* Fremd-API werden. Regulaer fragt ohnehin nur der Planer mit begrenzter
* Frequenz ab (D-05).
*/
const POLL_LOCK_MS = 10_000;
function truncateRaw(body: unknown): string {
let text: string;
try {
text = JSON.stringify(body) ?? String(body);
} catch {
text = String(body);
}
return text.length > RAW_SAMPLE_MAX_CHARS ? text.slice(0, RAW_SAMPLE_MAX_CHARS) : text;
}
type AuthHeaderResult =
| { ok: true; headers: Record<string, string> }
| { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string };
@Injectable()
export class ProxmoxService {
private readonly logger = new Logger(ProxmoxService.name);
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService,
) {}
/**
* Entschluesselt fuer den internen Gebrauch in GENAU dieser einen
* privaten Methode (Vorbild `LdapConfigService.decryptBindPassword`) —
* ein Wert, der nicht in `iv:authTag:ciphertext`-Form ist, wird
* unveraendert durchgereicht.
*/
private decryptSecret(stored: string | null): string | null {
if (!stored) return null;
if (!ENCRYPTED_VALUE_SHAPE.test(stored)) return stored;
return this.crypto.decrypt(stored);
}
/**
* Server anlegen (Aufgabe 1: nur `pve`+Token gepflegt vom Aufrufer;
* Aufgabe 2 ergaenzt den Passwort-Zweig, Aufgabe 5 das Bearbeiten). Legt
* zugleich eine leere Zwischenlagerzeile an, damit `listWithStatus` immer
* eine Statuszeile findet.
*/
async createServer(tenantId: string, dto: CreateProxmoxServerDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.create({
data: {
tenantId,
name: dto.name,
productType: dto.productType,
baseUrl: dto.baseUrl,
authMethod: dto.authMethod,
tokenId: dto.authMethod === 'token' ? (dto.tokenId ?? null) : null,
encryptedTokenSecret:
dto.authMethod === 'token' && dto.tokenSecret
? this.crypto.encrypt(dto.tokenSecret)
: null,
username: dto.authMethod === 'password' ? (dto.username ?? null) : null,
encryptedPassword:
dto.authMethod === 'password' && dto.password
? this.crypto.encrypt(dto.password)
: null,
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? true,
pollIntervalMin: dto.pollIntervalMin ?? 5,
isActive: dto.isActive ?? true,
status: { create: { tenantId, reachable: false } },
},
select: SAFE_SERVER_SELECT,
});
}
/**
* Serverliste samt Zwischenlager, OHNE jedes Geheimnisfeld (T-DHH-01).
* Liest ausschliesslich aus dem Zwischenlager — kein Live-Zugriff bei
* Proxmox (D-05).
*/
async listWithStatus(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.findMany({
where: { tenantId },
orderBy: { position: 'asc' },
select: SAFE_SERVER_SELECT,
});
}
/**
* Bearbeiten (Aufgabe 5). Dieselbe Regel wie
* `LdapConfigService.updateConfig`: ein NICHT gesendetes Geheimnisfeld
* laesst den gespeicherten Wert unveraendert, eine LEERE Zeichenkette
* bedeutet "loeschen", ein gefuellter Wert wird neu verschluesselt. Die
* Ablehnung "PMG plus Token" gilt auch hier — geprueft gegen den
* EFFEKTIVEN Stand nach dem Zusammenfuehren mit der vorhandenen Zeile,
* nicht nur gegen die gesendeten Felder (ein Teil-Update, das nur
* `authMethod` aendert, wuerde die DTO-eigene Pruefung sonst umgehen,
* weil `productType` in diesem Aufruf gar nicht gesendet wird).
*/
async updateServer(tenantId: string, serverId: string, dto: UpdateProxmoxServerDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
if (!existing || existing.tenantId !== tenantId) {
return null;
}
const effectiveProductType = dto.productType ?? existing.productType;
const effectiveAuthMethod = dto.authMethod ?? existing.authMethod;
if (effectiveProductType === 'pmg' && effectiveAuthMethod === 'token') {
throw new BadRequestException(
'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.',
);
}
const data: Record<string, unknown> = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.productType !== undefined) data.productType = dto.productType;
if (dto.baseUrl !== undefined) data.baseUrl = dto.baseUrl;
if (dto.authMethod !== undefined) data.authMethod = dto.authMethod;
if (dto.tokenId !== undefined) data.tokenId = dto.tokenId || null;
if (dto.tokenSecret !== undefined) {
data.encryptedTokenSecret = dto.tokenSecret ? this.crypto.encrypt(dto.tokenSecret) : null;
}
if (dto.username !== undefined) data.username = dto.username || null;
if (dto.password !== undefined) {
data.encryptedPassword = dto.password ? this.crypto.encrypt(dto.password) : null;
}
if (dto.tlsRejectUnauthorized !== undefined) {
data.tlsRejectUnauthorized = dto.tlsRejectUnauthorized;
}
if (dto.pollIntervalMin !== undefined) data.pollIntervalMin = dto.pollIntervalMin;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
return tenantPrisma.proxmoxServer.update({
where: { id: serverId },
data,
select: SAFE_SERVER_SELECT,
});
}
/**
* Loeschen (Aufgabe 5) — entfernt den Server samt Zwischenlagerzeile
* (Fremdschluessel mit Loeschweitergabe, `onDelete: Cascade`). Liefert
* `false`, wenn der Server unter diesem Mandanten nicht existiert.
*/
async deleteServer(tenantId: string, serverId: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
if (!existing || existing.tenantId !== tenantId) {
return false;
}
await tenantPrisma.proxmoxServer.delete({ where: { id: serverId } });
return true;
}
/**
* Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber
* `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine
* Ticket-Anmeldung aus (die einzige nicht-lesende Anfrage des Moduls,
* D-01) — deshalb `async`.
*/
private async buildAuthHeaders(server: ProxmoxCredentialSource): Promise<AuthHeaderResult> {
if (server.authMethod === 'token') {
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
if (!server.tokenId || !tokenSecret) {
return {
ok: false,
errorKind: 'zugang',
errorDetail: 'Kein Token hinterlegt.',
};
}
return {
ok: true,
headers: buildTokenAuthHeader(
server.productType as 'pve' | 'pbs' | 'pmg',
server.tokenId,
tokenSecret,
),
};
}
const password = this.decryptSecret(server.encryptedPassword);
if (!server.username || !password) {
return {
ok: false,
errorKind: 'zugang',
errorDetail: 'Kein Benutzer/Passwort hinterlegt.',
};
}
const login = await loginTicket(
{ baseUrl: server.baseUrl, tlsRejectUnauthorized: server.tlsRejectUnauthorized },
server.productType as 'pve' | 'pbs' | 'pmg',
server.username,
password,
);
if (!login.ok) {
return { ok: false, errorKind: login.errorKind, errorDetail: login.errorDetail };
}
return {
ok: true,
headers: buildTicketCookieHeader(server.productType as 'pve' | 'pbs' | 'pmg', login.ticket),
};
}
/**
* Fragt EINEN Proxmox-Pfad ab, gebunden an die Kopfzeilen dieses
* Poll-Durchlaufs. Ticket-Erneuerung (Aufgabe 2, `<behavior>`): laeuft
* der Zugang ueber `password` und antwortet Proxmox mit 401
* (`errorKind: 'zugang'`), wird GENAU EINMAL je Durchlauf neu angemeldet
* (nicht je Aufruf — ein PBS-Durchlauf mit mehreren Folgeabfragen soll
* nicht mehrfach neu einloggen) und die Abfrage wiederholt; die neuen
* Kopfzeilen gelten danach fuer den Rest des Durchlaufs. Bei einer
* Ticketdauer von zwei Stunden erzeugt ein normaler Ablauf sonst alle
* zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`.
*/
private async getWithRetry(
server: ProxmoxCredentialSource,
session: { headers: Record<string, string>; retried: boolean },
path: string,
): Promise<ProxmoxGetResult> {
const target = {
baseUrl: server.baseUrl,
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
headers: session.headers,
};
let result = await proxmoxGet(target, path);
if (
!result.ok &&
result.errorKind === 'zugang' &&
server.authMethod === 'password' &&
!session.retried
) {
session.retried = true;
const retryHeaders = await this.buildAuthHeaders(server);
if (retryHeaders.ok) {
session.headers = retryHeaders.headers;
result = await proxmoxGet({ ...target, headers: retryHeaders.headers }, path);
}
}
return result;
}
/**
* EIN Abfragedurchlauf gegen genau diesen Server — `pve` (Aufgabe 1),
* `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort
* (Aufgabe 2).
*/
private async pollOne(server: ProxmoxCredentialSource): Promise<ProxmoxPollResult> {
const authHeaders = await this.buildAuthHeaders(server);
if (!authHeaders.ok) {
return {
reachable: false,
errorKind: authHeaders.errorKind,
errorDetail: authHeaders.errorDetail,
metrics: null,
rawSample: null,
};
}
const session = { headers: authHeaders.headers, retried: false };
const productType = server.productType as ProxmoxProductType;
if (productType === 'pve') {
const result = await this.getWithRetry(server, session, '/api2/json/cluster/resources');
if (!result.ok) {
return {
reachable: false,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: null,
rawSample: result.body === null ? null : truncateRaw(result.body),
};
}
const normalized = normalizePve(result.body);
if (normalized.errorKind) {
return {
reachable: false,
errorKind: normalized.errorKind,
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
metrics: null,
rawSample: truncateRaw(result.body),
};
}
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalized.metrics,
rawSample: truncateRaw(result.body),
};
}
if (productType === 'pbs') {
const usageResult = await this.getWithRetry(server, session, '/api2/json/status/datastore-usage');
if (!usageResult.ok) {
return {
reachable: false,
errorKind: usageResult.errorKind,
errorDetail: usageResult.errorDetail,
metrics: null,
rawSample: usageResult.body === null ? null : truncateRaw(usageResult.body),
};
}
const storeNames = listPbsDatastoreNames(usageResult.body).slice(0, PBS_SNAPSHOT_QUERY_CAP);
const snapshotsByStore: Record<string, unknown> = {};
for (const storeName of storeNames) {
const snapResult = await this.getWithRetry(
server,
session,
`/api2/json/admin/datastore/${encodeURIComponent(storeName)}/snapshots`,
);
if (snapResult.ok) {
snapshotsByStore[storeName] = snapResult.body;
}
}
const normalized = normalizePbs(usageResult.body, snapshotsByStore);
const rawSample = truncateRaw({ usage: usageResult.body, snapshots: snapshotsByStore });
if (normalized.errorKind) {
return {
reachable: false,
errorKind: normalized.errorKind,
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
metrics: null,
rawSample,
};
}
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalized.metrics,
rawSample,
};
}
// pmg
const result = await this.getWithRetry(server, session, '/api2/json/statistics/mail');
if (!result.ok) {
return {
reachable: false,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: null,
rawSample: result.body === null ? null : truncateRaw(result.body),
};
}
const normalized = normalizePmg(result.body);
if (normalized.errorKind) {
return {
reachable: false,
errorKind: normalized.errorKind,
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
metrics: null,
rawSample: truncateRaw(result.body),
};
}
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalized.metrics,
rawSample: truncateRaw(result.body),
};
}
/**
* Fragt genau einen Server ab und schreibt das Ergebnis ins Zwischenlager.
* Liefert `null`, wenn der Server unter diesem Mandanten nicht existiert.
*
* Zehn-Sekunden-Sperre (Aufgabe 4, `<behavior>`, T-DHH-06): ein zweiter
* Durchlauf innerhalb von zehn Sekunden nach dem letzten fragt Proxmox
* NICHT erneut, sondern liefert den vorhandenen Zwischenlagerstand —
* Schutz davor, dass ein Klick in der Oberflaeche zu ungebremsten
* Anfragen gegen die Fremd-API wird.
*/
async pollServer(tenantId: string, serverId: string): Promise<ProxmoxPollResult | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const server = await tenantPrisma.proxmoxServer.findUnique({
where: { id: serverId },
include: { status: true },
});
if (!server || server.tenantId !== tenantId) {
return null;
}
const cachedStatus = server.status;
if (cachedStatus?.lastPolledAt) {
const ageMs = Date.now() - cachedStatus.lastPolledAt.getTime();
if (ageMs < POLL_LOCK_MS) {
return {
reachable: cachedStatus.reachable,
errorKind: cachedStatus.errorKind as ProxmoxErrorKind | null,
errorDetail: cachedStatus.errorDetail,
metrics: cachedStatus.metrics as ProxmoxPollResult['metrics'],
rawSample: cachedStatus.rawSample,
};
}
}
const result = await this.pollOne(server);
const now = new Date();
await tenantPrisma.proxmoxServerStatus.upsert({
where: { serverId },
create: {
serverId,
tenantId,
lastPolledAt: now,
lastOkAt: result.reachable ? now : null,
reachable: result.reachable,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: result.metrics as never,
rawSample: result.rawSample as never,
},
update: {
lastPolledAt: now,
...(result.reachable ? { lastOkAt: now } : {}),
reachable: result.reachable,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: result.metrics as never,
rawSample: result.rawSample as never,
},
});
return result;
}
/**
* Mischt Formularwerte (`dto`, ungespeichert) mit dem gespeicherten Server
* (`existing`, `null` bei der Neuanlage) zu genau den Feldern, die eine
* Abfrage braucht (Nachbesserung Befund 1). Zwei Regeln, je nachdem, ob
* das Formular das Feld beim Laden vorbefuellt (Vorbild `serverToForm`):
*
* - Normale Felder (`productType`, `baseUrl`, `authMethod`, `tokenId`,
* `username`, `tlsRejectUnauthorized`): das Formular zeigt immer den
* zuletzt gespeicherten Wert an, bis der Nutzer ihn aendert — ein vom
* Aufrufer GESENDETES Feld gilt also als Formularwert, auch wenn es
* absichtlich geleert wurde (`tokenId: ''` -> `null`). Nur ein NICHT
* gesendetes Feld (Aufrufer ohne diesen Schluessel im Body) faellt auf
* den gespeicherten Wert zurueck.
* - Geheimnisfelder (`tokenSecret`/`password`): `ServerForm` befuellt
* diese beim Laden bewusst NIE aus der Datenbank (Geheimnis nie im
* Klartext anzeigen). Ein leeres Feld bedeutet hier deshalb NICHT
* "Nutzer will loeschen", sondern "Nutzer hat nichts eingetippt" ->
* gespeicherten (verschluesselten) Wert weiterverwenden. Ein gefuelltes
* Feld ist der eingetippte Klartext und wird unveraendert durchgereicht;
* `decryptSecret()` erkennt anhand der Form `iv:authTag:ciphertext`
* automatisch, ob entschluesselt werden muss, und laesst Klartext sonst
* unangetastet.
*/
private resolveEffectiveTestServer(
existing: ProxmoxServer | null,
dto: TestProxmoxServerDto,
): ProxmoxCredentialSource {
return {
productType: dto.productType ?? existing?.productType ?? 'pve',
baseUrl: dto.baseUrl ?? existing?.baseUrl ?? '',
authMethod: dto.authMethod ?? existing?.authMethod ?? 'token',
tokenId: dto.tokenId !== undefined ? dto.tokenId || null : (existing?.tokenId ?? null),
encryptedTokenSecret: dto.tokenSecret
? dto.tokenSecret
: (existing?.encryptedTokenSecret ?? null),
username: dto.username !== undefined ? dto.username || null : (existing?.username ?? null),
encryptedPassword: dto.password ? dto.password : (existing?.encryptedPassword ?? null),
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? existing?.tlsRejectUnauthorized ?? true,
};
}
/**
* Verbindungstest fuer einen GESPEICHERTEN Server (Aufgabe 4, `<behavior>`,
* `POST servers/:id/test`; Nachbesserung Befund 1: prueft jetzt die
* Formularwerte aus `dto`, nicht mehr blind den gespeicherten Stand).
* Benutzt denselben Klienten und dieselbe Fehleruebersetzung wie der
* Planer, schreibt aber NICHT ins Zwischenlager — ein Testklick darf den
* zuletzt gemessenen Stand nicht ueberschreiben (Vorbild
* `TenderEmailConfigService.testConnection`/LDAP-Test). Keine
* Zehn-Sekunden-Sperre: ein Test ist ein bewusster Einzelklick, kein
* automatisierter Auffrischungsweg.
*/
async testConnection(
tenantId: string,
serverId: string,
dto: TestProxmoxServerDto = {},
): Promise<ProxmoxPollResult | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
if (!server || server.tenantId !== tenantId) {
return null;
}
return this.pollOne(this.resolveEffectiveTestServer(server, dto));
}
/**
* Verbindungstest waehrend der Neuanlage (Nachbesserung Befund 1,
* `POST servers/test`, ohne `:id`) — es gibt noch keinen gespeicherten
* Server, also ausschliesslich die Formularwerte aus `dto`. Fehlende
* Pflichtangaben (z. B. kein Geheimnis) fuehren zum selben Fehlerschluessel
* wie beim Abfragen eines gespeicherten Servers ohne Zugang (`zugang`).
*/
async testDraftConnection(dto: TestProxmoxServerDto): Promise<ProxmoxPollResult> {
return this.pollOne(this.resolveEffectiveTestServer(null, dto));
}
/**
* Aktive Server-IDs eines Mandanten fuer den Planer-Tick (gebunden).
*/
async listActiveServerIdsForTenant(tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = await tenantPrisma.proxmoxServer.findMany({
where: { tenantId, isActive: true },
select: { id: true },
});
return rows.map((r) => r.id);
}
/**
* Abfrageintervalle der aktiven Server eines Mandanten (gebunden) — der
* Controller ruft dies nach jedem Anlegen/Speichern, um den Planer
* sofort nachzuziehen (`ProxmoxSchedulerService.refreshTenant`).
*/
async loadActiveServersForTenantScheduling(
tenantId: string,
): Promise<{ pollIntervalMin: number }[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.findMany({
where: { tenantId, isActive: true },
select: { pollIntervalMin: true },
});
}
/**
* Startpfad des Planers — der EINZIGE Systemkontext-Aufruf dieses Moduls
* (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`, Aufgabe 4):
* `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`, einmal lesen,
* viele bedienen).
*/
async loadActiveServersForScheduler(): Promise<
{ id: string; tenantId: string; pollIntervalMin: number }[]
> {
const systemPrisma = forSystem(this.prisma);
return systemPrisma.proxmoxServer.findMany({
where: { isActive: true },
select: { id: true, tenantId: true, pollIntervalMin: true },
});
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Gemeinsame Typen des Proxmox-Moduls (260923-dhh). Diese Datei enthaelt
* ausschliesslich Typen — keine Logik, kein Prisma-Bezug — und wird von
* `proxmox-auth.ts`, `proxmox-client.service.ts`, `proxmox-normalize.ts`
* und `proxmox.service.ts` gleichermassen gelesen.
*/
/** Drei Proxmox-Produkte, die dieses Modul beobachtet (D-01). */
export type ProxmoxProductType = 'pve' | 'pbs' | 'pmg';
/** PMG kennt nur `password` (Recherche, Annahme A1) — DTO lehnt `token` fuer PMG ab. */
export type ProxmoxAuthMethod = 'token' | 'password';
/**
* Sieben stabile Fehlerschluessel. Sie landen so in der Datenbank
* (`ProxmoxServerStatus.errorKind`) und werden ERST im Frontend uebersetzt
* (`proxmox.errors.*`) — stabile Schluessel, uebersetzbarer Text (D-06).
* Eine Erweiterung dieser Liste ist eine bewusste Entscheidung, keine
* beilaeufige — siehe `proxmox-nur-lesen.spec.ts` fuer den maschinellen
* Riegel auf D-01, der denselben Gedanken fuer den Anfrageweg durchsetzt.
*/
export type ProxmoxErrorKind =
| 'netz'
| 'zugang'
| 'rechte'
| 'zertifikat'
| 'antwortform'
| 'server'
| 'unbekannt';
/** Ergebnis EINES Abfragedurchlaufs — was `proxmox.service.ts` ins Zwischenlager schreibt. */
export interface ProxmoxPollResult {
reachable: boolean;
errorKind: ProxmoxErrorKind | null;
errorDetail: string | null;
metrics: ProxmoxMetrics | null;
rawSample: unknown;
}
/**
* Messwertform je Produkt (Aufgabe 3 fuellt `pbs`/`pmg`; hier bereits als
* unterscheidbare Union angelegt, damit das Frontend ab Aufgabe 6 ueber
* `productType` typsicher verzweigen kann, D-Recherche "unterscheidbare Union").
*/
export type ProxmoxMetrics = ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
export interface ProxmoxPveNodeMetric {
node: string;
cpu: number | null; // Anteil 0..1
maxcpu: number | null;
mem: number | null; // Bytes
maxmem: number | null;
}
export interface ProxmoxPveStorageMetric {
storage: string;
node: string;
disk: number | null;
maxdisk: number | null;
}
export interface ProxmoxPveMetrics {
productType: 'pve';
nodeCount: number;
guestsRunning: number;
guestsStopped: number;
nodes: ProxmoxPveNodeMetric[];
storages: ProxmoxPveStorageMetric[];
}
export interface ProxmoxPbsDatastoreMetric {
name: string;
total: number | null;
used: number | null;
free: number | null;
lastBackupAt: number | null; // Unix-Sekunden, wie Proxmox sie liefert
lastVerifyState: string | null;
}
export interface ProxmoxPbsMetrics {
productType: 'pbs';
datastores: ProxmoxPbsDatastoreMetric[];
}
export interface ProxmoxPmgMetrics {
productType: 'pmg';
countIn: number | null;
countOut: number | null;
spamCount: number | null;
virusCount: number | null;
}
@@ -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(() => {
@@ -0,0 +1,468 @@
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, ProxmoxServerStatus } from '@/lib/proxmox-api';
import de from '@/messages/de.json';
/**
* 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',
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: null,
...overrides,
};
}
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
async function card(server: ProxmoxServer, isAdmin?: boolean) {
const { ServerCard } = await import('./ServerCard');
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: 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');
});
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: [],
},
}),
}),
);
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');
});
});
describe('ServerCard — PBS', () => {
it('je Datenspeicher Fuellstand, letzte Sicherung relativ und Pruefpille', async () => {
const el = await card(
makeServer({
productType: 'pbs',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{
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');
});
it('Sicherung aelter als 26 h und fehlgeschlagene Pruefung -> Warnung', async () => {
const el = await card(
makeServer({
productType: 'pbs',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{
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');
});
});
describe('ServerCard — PMG', () => {
it('vier Zahlfelder: eingehend, ausgehend, Spam, Viren', async () => {
const el = await card(
makeServer({
productType: 'pmg',
status: makeStatus({
metrics: {
productType: 'pmg',
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');
});
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(el).toHaveAttribute('data-health', 'warn');
expect(within(within(el).getByTestId('pmg-virus')).getByText('3')).toHaveClass(
'text-status-down-fg',
);
});
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: '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();
});
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();
});
});
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(
within(el).getByText(
'Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.',
),
).toBeInTheDocument();
expect(
within(el).queryByText('Ein unerwarteter Fehler ist aufgetreten.'),
).not.toBeInTheDocument();
expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument();
});
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
const el = await card(makeServer({ status: UNPOLLED }), false);
expect(
within(el).getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
});
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: [],
},
});
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(
within(el).getByText('Dieser Server ist deaktiviert und wird nicht mehr abgefragt.'),
).toBeInTheDocument();
expect(within(el).queryByTestId('guests-running')).not.toBeInTheDocument();
expect(within(el).queryByText('altknoten')).not.toBeInTheDocument();
expect(within(el).queryAllByTestId('meter')).toHaveLength(0);
});
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();
});
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 user = await card(makeServer({ isActive: false, status: staleMetrics }), false);
expect(within(user).queryByRole('link')).not.toBeInTheDocument();
expect(within(user).queryByText(/wieder aktivieren/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,489 @@
'use client';
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';
type Translator = ReturnType<typeof useTranslations>;
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);
}
/**
* „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 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]}`;
}
/** 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);
}
/** 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 (
<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>
<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>
<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}
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>
))}
</div>
);
}
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">
{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>
<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>
);
}
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 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 (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, now = Date.now() }: ServerCardProps) {
const t = useTranslations('proxmox');
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);
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>
)}
</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>
);
}
@@ -0,0 +1,6 @@
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
import type { ReactNode } from 'react';
export default function ProxmoxLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="proxmox">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,203 @@
'use client';
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 (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');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
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((list) => {
setServers(list);
setNow(Date.now());
})
.catch(() => setError(t('loadError')));
}, [t]);
useEffect(() => {
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);
try {
await Promise.all(servers.map((server) => pollServer(server.id).catch(() => undefined)));
reload();
} finally {
setIsRefreshing(false);
}
};
const sorted = useMemo(() => (servers ? sortServersByHealth(servers, now) : []), [servers, now]);
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
return (
<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>
{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="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>
)}
</div>
)}
</div>
{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 && (
<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={`${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-sm font-medium text-foreground hover:underline"
>
{t('card.settingsLink')}
</Link>
)}
</div>
)}
{!error && servers !== null && servers.length > 0 && (
<>
<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>
);
}
@@ -0,0 +1,279 @@
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.
const mockListServers = vi.fn();
const mockPollServer = vi.fn();
vi.mock('@/lib/proxmox-api', () => ({
listServers: (...args: unknown[]) => mockListServers(...args),
pollServer: (...args: unknown[]) => mockPollServer(...args),
}));
// Mock the auth store — mirrors settings-roles.test.tsx (selector-passthrough).
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>
),
}));
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
selector({ user }),
);
}
// Ein nie abgefragter Server (wie im Befund-2-Test von ServerCard.test.tsx):
// leere Zwischenlagerzeile direkt nach `createServer`.
function makeUnpolledServer(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: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: null,
lastOkAt: null,
reachable: false,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-01-01T00:00:00.000Z',
metrics: null,
},
...overrides,
} as ProxmoxServer;
}
afterEach(() => {
cleanup();
mockListServers.mockReset();
mockPollServer.mockReset();
mockAuthStore.mockReset();
});
describe('ProxmoxPage role gating (260923-le6)', () => {
it('Rolle USER: kein Knopf "Jetzt aktualisieren", Karten-Hinweis ist der automatische Text', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
expect(
screen.getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
});
it('kein Benutzer geladen (user: null): kein Knopf', async () => {
mockUser(null);
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
});
it('Rolle ADMIN: Knopf sichtbar, Karten-Hinweis ist der Admin-Text mit Knopfverweis', async () => {
mockUser({ role: 'ADMIN' });
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.getByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
expect(
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
).toBeInTheDocument();
});
it('Rolle SUPER_ADMIN: Knopf sichtbar', async () => {
mockUser({ role: 'SUPER_ADMIN' });
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.getByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
});
it('leere Serverliste bei ADMIN: weiterhin kein Knopf', async () => {
mockUser({ role: 'ADMIN' });
mockListServers.mockResolvedValue([]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await waitFor(() => {
expect(
screen.getByText(
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
),
).toBeInTheDocument();
});
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',
);
});
});
@@ -0,0 +1,244 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockCreateServer = vi.fn();
const mockUpdateServer = vi.fn();
const mockTestServer = vi.fn();
const mockTestDraftServer = vi.fn();
vi.mock('@/lib/proxmox-api', () => ({
createServer: (...args: unknown[]) => mockCreateServer(...args),
updateServer: (...args: unknown[]) => mockUpdateServer(...args),
testServer: (...args: unknown[]) => mockTestServer(...args),
testDraftServer: (...args: unknown[]) => mockTestDraftServer(...args),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
'settings.nameLabel': 'Name',
'settings.productTypeLabel': 'Typ',
'settings.productTypePve': 'PVE',
'settings.productTypePbs': 'PBS',
'settings.productTypePmg': 'PMG',
'settings.baseUrlLabel': 'Adresse',
'settings.authMethodLabel': 'Zugangsart',
'settings.authMethodToken': 'API-Token',
'settings.authMethodPassword': 'Benutzer/Passwort',
'settings.tokenIdLabel': 'Token-Kennung',
'settings.tokenSecretLabel': 'Token-Geheimnis',
'settings.usernameLabel': 'Benutzername',
'settings.passwordLabel': 'Passwort',
'settings.secretUnchangedPlaceholder': 'Leer lassen, um das gespeicherte Geheimnis beizubehalten',
'settings.pollIntervalLabel': 'Abfrageintervall (Minuten)',
'settings.tlsRejectLabel': 'Zertifikat prüfen',
'settings.tlsRejectHint': 'Die Ausnahme gilt nur für diesen einen Server, niemals für alle Server gemeinsam.',
'settings.activeLabel': 'Aktiv',
'settings.save': 'Speichern',
'settings.saving': 'Wird gespeichert...',
'settings.saveError': 'Die Einstellungen konnten nicht gespeichert werden.',
'settings.cancel': 'Abbrechen',
'settings.testConnection': 'Verbindung testen',
'settings.testTesting': 'Verbindung wird getestet...',
'settings.testSuccess': 'Verbindung erfolgreich.',
'errors.netz': 'Der Server ist nicht erreichbar.',
'errors.zugang': 'Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.',
'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}}`, v);
}
}
return result;
},
}));
afterEach(() => {
cleanup();
mockCreateServer.mockReset();
mockUpdateServer.mockReset();
mockTestServer.mockReset();
mockTestDraftServer.mockReset();
});
const EXISTING_SERVER = {
id: 'srv-1',
tenantId: 't1',
name: 'pmg-1',
productType: 'pmg' as const,
baseUrl: 'https://pmg.intern',
authMethod: 'password' as const,
tokenId: null,
username: 'admin@pmg',
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: 5,
position: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: null,
};
describe('ServerForm', () => {
it('bei Typ pmg erscheint die Auswahl "API-Token" gar nicht', async () => {
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
const authSelect = screen.getByLabelText('Zugangsart') as HTMLSelectElement;
const options = [...authSelect.options].map((o) => o.value);
expect(options).toEqual(['password']);
});
it('bei pve/pbs mit Token erscheinen Token-Kennung und -Geheimnis; bei Passwort Benutzer und Passwort', async () => {
const { ServerForm } = await import('./ServerForm');
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByLabelText('Token-Kennung')).toBeInTheDocument();
expect(screen.getByLabelText('Token-Geheimnis')).toBeInTheDocument();
expect(screen.queryByLabelText('Benutzername')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Zugangsart'), { target: { value: 'password' } });
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
expect(screen.queryByLabelText('Token-Kennung')).not.toBeInTheDocument();
});
it('ein gespeichertes Geheimnis wird nie im Klartext angezeigt — das Feld ist leer', async () => {
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
const passwordInput = screen.getByLabelText('Passwort') as HTMLInputElement;
expect(passwordInput.value).toBe('');
});
it('ein leer gelassenes Geheimnisfeld sendet kein password-Feld beim Speichern (Wert bleibt unveraendert)', async () => {
mockUpdateServer.mockResolvedValue(EXISTING_SERVER);
const onSaved = vi.fn();
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={onSaved} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Speichern'));
await waitFor(() => expect(mockUpdateServer).toHaveBeenCalled());
const payload = mockUpdateServer.mock.calls[0][1];
expect(payload.password).toBeUndefined();
});
it('der Schalter fuer die Zertifikatspruefung steht beim Anlegen auf "pruefen" mit Hinweistext', async () => {
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
const checkbox = screen.getByLabelText('Zertifikat prüfen') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
expect(
screen.getByText('Die Ausnahme gilt nur für diesen einen Server, niemals für alle Server gemeinsam.'),
).toBeInTheDocument();
});
it('Verbindung testen zeigt bei Erfolg eine gruene Bestaetigung', async () => {
mockTestServer.mockResolvedValue({
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => expect(screen.getByText('Verbindung erfolgreich.')).toBeInTheDocument());
});
it('Verbindung testen zeigt bei Misserfolg den Klartext der Ursache', async () => {
mockTestServer.mockResolvedValue({
reachable: false,
errorKind: 'zugang',
errorDetail: null,
metrics: null,
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() =>
expect(
screen.getByText(
'Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.',
),
).toBeInTheDocument(),
);
});
it('Nachbesserung Befund 1: bei der Neuanlage (kein gespeicherter Server) steht der Testen-Knopf zur Verfuegung und ruft den Neuanlage-Testweg auf', async () => {
mockTestDraftServer.mockResolvedValue({
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.change(screen.getByLabelText('Adresse'), {
target: { value: 'https://pve.neu:8006' },
});
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => expect(mockTestDraftServer).toHaveBeenCalled());
expect(mockTestServer).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByText('Verbindung erfolgreich.')).toBeInTheDocument());
});
it('Nachbesserung Befund 1: der Test prueft die im Formular abgeschaltete Zertifikatspruefung, nicht den gespeicherten Stand', async () => {
mockTestServer.mockResolvedValue({
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
// EXISTING_SERVER wurde MIT Zertifikatspruefung gespeichert — im Formular jetzt abschalten.
fireEvent.click(screen.getByLabelText('Zertifikat prüfen'));
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => expect(mockTestServer).toHaveBeenCalled());
const [, payload] = mockTestServer.mock.calls[0];
// Ohne die Korrektur wuerde `testServer` ganz ohne Formularstand aufgerufen — roter Test.
expect(payload.tlsRejectUnauthorized).toBe(false);
});
it('Nachbesserung Befund 1: ein leer gelassenes Geheimnisfeld sendet beim Testen kein tokenSecret (gespeicherter Wert bleibt massgeblich)', async () => {
mockTestServer.mockResolvedValue({
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
});
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => expect(mockTestServer).toHaveBeenCalled());
const [, payload] = mockTestServer.mock.calls[0];
expect(payload.tokenSecret).toBeUndefined();
});
});
@@ -0,0 +1,402 @@
'use client';
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
createServer,
testDraftServer,
testServer,
updateServer,
type ProxmoxAuthMethod,
type ProxmoxProductType,
type ProxmoxServer,
type ProxmoxTestResult,
} from '@/lib/proxmox-api';
interface FormState {
name: string;
productType: ProxmoxProductType;
baseUrl: string;
authMethod: ProxmoxAuthMethod;
tokenId: string;
tokenSecret: string; // absichtlich leer beim Laden — nie aus dem Server vorbefuellt
username: string;
password: string; // absichtlich leer beim Laden — nie aus dem Server vorbefuellt
pollIntervalMin: string;
tlsRejectUnauthorized: boolean;
isActive: boolean;
}
function serverToForm(server: ProxmoxServer | null): FormState {
if (!server) {
return {
name: '',
productType: 'pve',
baseUrl: '',
authMethod: 'token',
tokenId: '',
tokenSecret: '',
username: '',
password: '',
pollIntervalMin: '5',
tlsRejectUnauthorized: true,
isActive: true,
};
}
return {
name: server.name,
productType: server.productType,
baseUrl: server.baseUrl,
authMethod: server.authMethod,
tokenId: server.tokenId ?? '',
tokenSecret: '',
username: server.username ?? '',
password: '',
pollIntervalMin: String(server.pollIntervalMin),
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
isActive: server.isActive,
};
}
interface ServerFormProps {
server: ProxmoxServer | null;
isAdmin: boolean;
onSaved: (server: ProxmoxServer) => void;
onCancel: () => void;
}
/**
* Server anlegen/bearbeiten (Aufgabe 5). Bei Typ `pmg` bietet die Auswahl
* "API-Token" gar nicht erst an (D-03) — serverseitig lehnt das DTO diese
* Kombination zusaetzlich ab (Verteidigung in der Tiefe). Ein gespeichertes
* Geheimnis wird nie im Klartext angezeigt: das Feld ist leer, ein leer
* gelassenes Feld laesst den gespeicherten Wert unveraendert.
*/
export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormProps) {
const t = useTranslations('proxmox');
const [form, setForm] = useState<FormState>(() => serverToForm(server));
const [savedServer, setSavedServer] = useState<ProxmoxServer | null>(server);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [testResult, setTestResult] = useState<ProxmoxTestResult | null>(null);
const update = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((f) => ({ ...f, [key]: value }));
setSaveError(null);
setTestResult(null);
},
[],
);
const handleProductTypeChange = (productType: ProxmoxProductType) => {
setForm((f) => ({
...f,
productType,
// PMG kennt keinen Token — bei Wechsel auf PMG automatisch auf Passwort umstellen.
authMethod: productType === 'pmg' ? 'password' : f.authMethod,
}));
setSaveError(null);
setTestResult(null);
};
const buildPayload = () => ({
name: form.name,
productType: form.productType,
baseUrl: form.baseUrl,
authMethod: form.authMethod,
tokenId: form.authMethod === 'token' ? form.tokenId : undefined,
tokenSecret: form.authMethod === 'token' && form.tokenSecret ? form.tokenSecret : undefined,
username: form.authMethod === 'password' ? form.username : undefined,
password: form.authMethod === 'password' && form.password ? form.password : undefined,
pollIntervalMin: Number(form.pollIntervalMin) || 5,
tlsRejectUnauthorized: form.tlsRejectUnauthorized,
isActive: form.isActive,
});
/**
* Wie `buildPayload()`, aber OHNE `name` (Nachbesserung Befund 1): der
* Verbindungstest braucht den Namen nicht, und ein waehrend der Neuanlage
* noch leer gelassenes Namensfeld wuerde sonst die serverseitige
* `@IsNotEmpty()`-Pruefung auf `name` bei jedem Testklick blockieren.
*/
const buildTestPayload = () => {
const { name: _name, ...rest } = buildPayload();
return rest;
};
const handleSave = async () => {
setIsSaving(true);
setSaveError(null);
try {
const result = savedServer
? await updateServer(savedServer.id, buildPayload())
: await createServer(buildPayload());
setSavedServer(result);
setForm(serverToForm(result));
onSaved(result);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t('settings.saveError'));
} finally {
setIsSaving(false);
}
};
const handleTest = async () => {
setIsTesting(true);
setTestResult(null);
try {
// Nachbesserung Befund 1: der Test prueft immer den aktuellen
// Formularstand (`buildPayload()`), nie nur den gespeicherten Stand.
// Ein leer gelassenes Geheimnisfeld wird dabei als `undefined`
// gesendet — der Server faellt dann auf den gespeicherten Wert
// zurueck (siehe `ProxmoxService.resolveEffectiveTestServer`).
const result = savedServer
? await testServer(savedServer.id, buildTestPayload())
: await testDraftServer(buildTestPayload());
setTestResult(result);
} catch (err) {
setTestResult({
reachable: false,
errorKind: 'unbekannt',
errorDetail: err instanceof Error ? err.message : t('settings.saveError'),
metrics: null,
rawSample: null,
});
} finally {
setIsTesting(false);
}
};
const inputCls =
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50';
const labelCls = 'mb-1 block text-sm text-foreground';
const errorMessage = (kind: ProxmoxTestResult['errorKind']) => {
if (!kind) return null;
return t(`errors.${kind}`);
};
return (
<div className="space-y-4 rounded-lg border border-border bg-card p-4 shadow-sm">
<div>
<label htmlFor="proxmox-name" className={labelCls}>
{t('settings.nameLabel')}
</label>
<input
id="proxmox-name"
type="text"
className={inputCls}
value={form.name}
disabled={!isAdmin}
onChange={(e) => update('name', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-product-type" className={labelCls}>
{t('settings.productTypeLabel')}
</label>
<select
id="proxmox-product-type"
className={inputCls}
value={form.productType}
disabled={!isAdmin}
onChange={(e) => handleProductTypeChange(e.target.value as ProxmoxProductType)}
>
<option value="pve">{t('settings.productTypePve')}</option>
<option value="pbs">{t('settings.productTypePbs')}</option>
<option value="pmg">{t('settings.productTypePmg')}</option>
</select>
</div>
<div>
<label htmlFor="proxmox-base-url" className={labelCls}>
{t('settings.baseUrlLabel')}
</label>
<input
id="proxmox-base-url"
type="text"
placeholder="https://pve.intern:8006"
className={inputCls}
value={form.baseUrl}
disabled={!isAdmin}
onChange={(e) => update('baseUrl', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-auth-method" className={labelCls}>
{t('settings.authMethodLabel')}
</label>
<select
id="proxmox-auth-method"
className={inputCls}
value={form.authMethod}
disabled={!isAdmin}
onChange={(e) => update('authMethod', e.target.value as ProxmoxAuthMethod)}
>
{/* D-03: PMG kennt keinen API-Token — die Auswahl bietet ihn bei diesem Typ gar nicht erst an. */}
{form.productType !== 'pmg' && <option value="token">{t('settings.authMethodToken')}</option>}
<option value="password">{t('settings.authMethodPassword')}</option>
</select>
</div>
{form.authMethod === 'token' ? (
<>
<div>
<label htmlFor="proxmox-token-id" className={labelCls}>
{t('settings.tokenIdLabel')}
</label>
<input
id="proxmox-token-id"
type="text"
placeholder="root@pam!tessera"
className={inputCls}
value={form.tokenId}
disabled={!isAdmin}
onChange={(e) => update('tokenId', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-token-secret" className={labelCls}>
{t('settings.tokenSecretLabel')}
</label>
<input
id="proxmox-token-secret"
type="password"
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
className={inputCls}
value={form.tokenSecret}
disabled={!isAdmin}
onChange={(e) => update('tokenSecret', e.target.value)}
/>
</div>
</>
) : (
<>
<div>
<label htmlFor="proxmox-username" className={labelCls}>
{t('settings.usernameLabel')}
</label>
<input
id="proxmox-username"
type="text"
placeholder="admin@pam"
className={inputCls}
value={form.username}
disabled={!isAdmin}
onChange={(e) => update('username', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-password" className={labelCls}>
{t('settings.passwordLabel')}
</label>
<input
id="proxmox-password"
type="password"
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
className={inputCls}
value={form.password}
disabled={!isAdmin}
onChange={(e) => update('password', e.target.value)}
/>
</div>
</>
)}
<div>
<label htmlFor="proxmox-poll-interval" className={labelCls}>
{t('settings.pollIntervalLabel')}
</label>
<input
id="proxmox-poll-interval"
type="number"
min={1}
max={1440}
className={inputCls}
value={form.pollIntervalMin}
disabled={!isAdmin}
onChange={(e) => update('pollIntervalMin', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-tls-reject" className="flex items-center gap-2 text-sm text-foreground">
<input
id="proxmox-tls-reject"
type="checkbox"
checked={form.tlsRejectUnauthorized}
disabled={!isAdmin}
onChange={(e) => update('tlsRejectUnauthorized', e.target.checked)}
/>
{t('settings.tlsRejectLabel')}
</label>
<p className="mt-1 text-xs text-muted-foreground">{t('settings.tlsRejectHint')}</p>
</div>
<div>
<label htmlFor="proxmox-active" className="flex items-center gap-2 text-sm text-foreground">
<input
id="proxmox-active"
type="checkbox"
checked={form.isActive}
disabled={!isAdmin}
onChange={(e) => update('isActive', e.target.checked)}
/>
{t('settings.activeLabel')}
</label>
</div>
{saveError && <p className="text-sm text-destructive">{saveError}</p>}
{testResult && (
<p
className={`text-sm ${testResult.reachable ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}
>
{testResult.reachable
? t('settings.testSuccess')
: `${errorMessage(testResult.errorKind)}${testResult.errorDetail ? ` (${testResult.errorDetail})` : ''}`}
</p>
)}
{isAdmin && (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving || !form.name || !form.baseUrl}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? t('settings.saving') : t('settings.save')}
</button>
{/*
Nachbesserung Befund 1: der Testen-Knopf steht IMMER zur
Verfuegung, auch waehrend der Neuanlage vor dem ersten Speichern
(vorher: nur bei `savedServer`) — `handleTest` waehlt selbst den
passenden Endpunkt (`testServer` vs. `testDraftServer`).
*/}
<button
type="button"
onClick={handleTest}
disabled={isTesting || !form.baseUrl}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button>
<button
type="button"
onClick={onCancel}
className="rounded px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
>
{t('settings.cancel')}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,197 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { deleteServer, listServers, type ProxmoxServer } from '@/lib/proxmox-api';
import { ServerForm } from './components/ServerForm';
interface DeleteDialogProps {
name: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogProps) {
const t = useTranslations('proxmox');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded border border-border bg-card px-6 py-5 shadow-lg">
<h3 className="mb-2 text-base font-semibold text-foreground">
{t('settings.deleteConfirmTitle')}
</h3>
<p className="mb-5 text-sm text-muted-foreground">
{t('settings.deleteConfirmBody', { name })}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
{t('settings.deleteCancelButton')}
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('settings.deleteConfirmButton')}
</button>
</div>
</div>
</div>
);
}
/**
* Moduleinstellungen (Aufgabe 5) — ADMINISTRATION ONLY. Die Rollenpruefung
* hier ist reine Anzeige (Ladezustand solange die Rolle unbekannt ist,
* damit die Verwaltungsteile fuer einen normalen Benutzer nie kurz
* aufblitzen) — der verbindliche Riegel liegt serverseitig
* (`@Roles(ADMIN, SUPER_ADMIN)` auf jedem Schreibweg, Vorbild
* `tender-radar/settings/page.tsx`).
*/
export default function ProxmoxSettingsPage() {
const t = useTranslations('proxmox');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [editingId, setEditingId] = useState<string | 'new' | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProxmoxServer | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const reload = useCallback(() => {
listServers()
.then(setServers)
.catch(() => setLoadError(t('loadError')));
}, [t]);
useEffect(() => {
reload();
}, [reload]);
const handleSaved = () => {
setEditingId(null);
reload();
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
await deleteServer(deleteTarget.id);
setDeleteTarget(null);
reload();
} finally {
setIsDeleting(false);
}
};
if (user === null) {
return (
<div className="mx-auto max-w-2xl p-6">
<div className="mb-6 h-8 w-64 animate-pulse rounded bg-muted" />
<div className="h-40 animate-pulse rounded bg-muted" />
</div>
);
}
if (!isAdmin) {
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.accessDeniedText')}</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
{editingId === null && (
<button
type="button"
onClick={() => setEditingId('new')}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('settings.addServer')}
</button>
)}
</div>
{loadError && <p className="text-sm text-destructive">{loadError}</p>}
{editingId === 'new' && (
<ServerForm
server={null}
isAdmin={isAdmin}
onSaved={handleSaved}
onCancel={() => setEditingId(null)}
/>
)}
{servers !== null && servers.length === 0 && editingId === null && (
<p className="text-sm text-muted-foreground">{t('settings.noServers')}</p>
)}
<ul className="space-y-3">
{servers?.map((server) =>
editingId === server.id ? (
<li key={server.id}>
<ServerForm
server={server}
isAdmin={isAdmin}
onSaved={handleSaved}
onCancel={() => setEditingId(null)}
/>
</li>
) : (
<li
key={server.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 shadow-sm"
>
<div>
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.productType.toUpperCase()} — {server.baseUrl}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setEditingId(server.id)}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
{t('settings.edit')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(server)}
className="rounded border border-destructive px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10"
>
{t('settings.delete')}
</button>
</div>
</li>
),
)}
</ul>
{deleteTarget && (
<DeleteDialog
name={deleteTarget.name}
isDeleting={isDeleting}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
+12
View File
@@ -20,6 +20,12 @@ vi.mock('next-intl', () => ({
}));
const mockStore = {
// quick-260923-ad9: Reiter — leere Liste rendert die Reiterleiste ohne
// Reiter-Knoepfe, damit diese Datei bei den bestehenden Zusammensetzungs-
// Pruefungen bleibt, statt den Reiter-Speicher selbst nachzubauen.
dashboards: [] as Array<{ id: string; name: string; position: number }>,
activeDashboardId: null as string | null,
isSwitchingDashboard: false,
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [] as Array<{ id: string; widgetType: string; config: Record<string, unknown> }>,
isEditMode: false,
@@ -32,6 +38,11 @@ const mockStore = {
removeWidget: vi.fn(),
loadDashboard: vi.fn(),
saveLayout: vi.fn(),
selectDashboard: vi.fn(),
createDashboard: vi.fn(),
renameDashboard: vi.fn(),
deleteDashboard: vi.fn(),
reorderDashboards: vi.fn(),
};
vi.mock('@/lib/stores/dashboard-store', () => ({
@@ -68,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;
+33 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { registerWidget } from '@/components/dashboard/widget-registry';
@@ -15,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';
@@ -33,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 {
@@ -48,6 +51,9 @@ export default function DashboardPage() {
const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null);
const {
dashboards,
activeDashboardId,
isSwitchingDashboard,
layouts,
widgets,
isEditMode,
@@ -58,6 +64,11 @@ export default function DashboardPage() {
addWidget,
removeWidget,
loadDashboard,
selectDashboard,
createDashboard,
renameDashboard,
deleteDashboard,
reorderDashboards,
} = useDashboardStore();
// Load dashboard data on mount
@@ -105,7 +116,27 @@ export default function DashboardPage() {
return (
<div className="relative p-2">
{/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */}
{/* 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}
isEditMode={isEditMode}
onSelect={selectDashboard}
onCreate={createDashboard}
onRename={renameDashboard}
onDelete={deleteDashboard}
onReorder={reorderDashboards}
/>
{isSwitchingDashboard ? (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
</div>
) : (
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
<DashboardGrid
layouts={layouts}
widgets={widgets}
@@ -113,6 +144,7 @@ export default function DashboardPage() {
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
)}
{/* Feste Aktionsleiste unten rechts.
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
@@ -3,11 +3,20 @@
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel';
import { fetchWidgets } from '@/lib/dashboard-api';
import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
/**
* Settings > Dashboard > Widgets page (D-03).
* Fetches user's widget instances and renders per-instance config forms.
* Fetches the widget instances of the user's FIRST dashboard tab (the
* standard tab, quick-260923-ad9) and renders per-instance config forms.
*
* quick-260923-ad9 (Task 3, deviation Rule 3 — blocking compile issue):
* `fetchWidgets` now requires a `dashboardId` since widgets are scoped per
* tab. This page is not itself tab-aware (out of this plan's scope — see
* "Nicht im Umfang") and previously showed every widget the user had, back
* when there was exactly one dashboard per user; it now shows the first
* tab's widgets, which is the same set for the (overwhelmingly common)
* case of a user who has not yet created a second tab.
*/
export default function WidgetSettingsPage() {
const t = useTranslations('settings');
@@ -18,12 +27,17 @@ export default function WidgetSettingsPage() {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchWidgets()
.then(setWidgets)
.catch(() => {
(async () => {
try {
const dashboards = await fetchDashboards();
const first = dashboards[0];
setWidgets(first ? await fetchWidgets(first.id) : []);
} catch {
// Silent fail — empty widget list shown
})
.finally(() => setIsLoading(false));
} finally {
setIsLoading(false);
}
})();
}, []);
return (
+45
View File
@@ -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 {
@@ -0,0 +1,434 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das
* schlichtere Muster in `widget-catalog-modal.test.tsx` (das Parameter
* ignoriert), weil Test 8 unten die eingesetzte Reiterbezeichnung im
* Loeschdialog prueft.
*/
vi.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string, params?: Record<string, string>) => {
const translations: Record<string, Record<string, string>> = {
widgets: {
'tabs.navLabel': 'Dashboard-Reiter',
'tabs.add': 'Dashboard hinzufügen',
'tabs.renameButtonLabel': 'Dashboard umbenennen',
'tabs.renameInputLabel': 'Name des Dashboards',
'tabs.deleteButtonLabel': 'Dashboard löschen',
'tabs.deleteDialogTitle': 'Dashboard löschen',
'tabs.deleteDialogBody': 'Möchten Sie das Dashboard „{name}“ wirklich löschen?',
'tabs.dragHint': 'Ziehen Sie einen Reiter, um ihn zum Standard zu machen.',
},
common: {
cancel: 'Abbrechen',
delete: 'Löschen',
},
};
let text = translations[namespace]?.[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, v);
}
}
return text;
},
}));
import { DashboardTabs } from './dashboard-tabs';
const DASHBOARDS = [
{ id: 'd1', name: 'Dashboard', position: 0 },
{ id: 'd2', name: 'Dashboard 2', position: 1 },
];
const THREE_DASHBOARDS = [
{ id: 'd1', name: 'Dashboard', position: 0 },
{ id: 'd2', name: 'Dashboard 2', position: 1 },
{ id: 'd3', name: 'Dashboard 3', position: 2 },
];
/**
* Stubbt `getBoundingClientRect` fuer JEDEN Reiter-Wrapper so, dass Reiter i
* (nach seiner AKTUELLEN Stelle im DOM, `data-tab-index`) die Spanne von
* i·100 bis i·100+100 belegt — echte Masse liefert jsdom hier nicht (Plan-
* Vorgabe, Task 4). Die Zeigerpositionen der Tests unten rechnen gegen
* genau diese Spannen.
*/
function stubTabRects() {
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLDivElement,
) {
const idx = Number(this.dataset.tabIndex ?? '0');
return {
x: idx * 100,
y: 0,
left: idx * 100,
top: 0,
right: idx * 100 + 100,
bottom: 32,
width: 100,
height: 32,
toJSON() {
return {};
},
} as DOMRect;
});
}
function defaultProps(overrides: Partial<Parameters<typeof DashboardTabs>[0]> = {}) {
return {
dashboards: DASHBOARDS,
activeDashboardId: 'd1',
isEditMode: false,
onSelect: vi.fn(),
onCreate: vi.fn(),
onRename: vi.fn(),
onDelete: vi.fn(),
onReorder: vi.fn(),
...overrides,
};
}
// 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();
});
describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
render(<DashboardTabs {...defaultProps()} />);
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('tab', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
it('Test 3: der aktive Reiter traegt aria-selected=true, der andere false', () => {
render(<DashboardTabs {...defaultProps()} />);
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', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: false })} />);
expect(screen.queryByLabelText('Dashboard umbenennen')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Dashboard hinzufügen')).not.toBeInTheDocument();
});
it('Test 5: im Bearbeitungsmodus mit mehreren Reitern sind Loeschen-Knoepfe und ein Hinzufuegen-Knopf da', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
expect(screen.getAllByLabelText('Dashboard löschen')).toHaveLength(2);
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
});
it('Test 6: beim letzten verbleibenden Reiter wird Loeschen gar nicht erst angeboten', () => {
render(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
});
it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => {
const onCreate = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onCreate })} />);
fireEvent.click(screen.getByLabelText('Dashboard hinzufügen'));
expect(onCreate).toHaveBeenCalledTimes(1);
});
it('Test 8: Umbenennen-Knopf des AKTIVEN Reiters verwandelt den Namen in ein Eingabefeld; Enter uebernimmt beschnitten', () => {
const onRename = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
const input = screen.getByLabelText('Name des Dashboards');
fireEvent.change(input, { target: { value: ' Finanzen ' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onRename).toHaveBeenCalledWith('d1', 'Finanzen');
});
it('Test 9: Escape verwirft die Umbenennung, ohne onRename aufzurufen', () => {
const onRename = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
const input = screen.getByLabelText('Name des Dashboards');
fireEvent.change(input, { target: { value: 'Verworfen' } });
fireEvent.keyDown(input, { key: 'Escape' });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
});
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
expect(screen.getAllByLabelText('Dashboard umbenennen')).toHaveLength(1);
});
it('Test 11: Loeschen fragt mit dem Namen des Reiters zurueck; Bestaetigen ruft onDelete auf', () => {
const onDelete = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[1]);
const dialog = screen.getByRole('alertdialog');
expect(dialog).toHaveTextContent('Dashboard 2');
fireEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
expect(onDelete).toHaveBeenCalledWith('d2');
});
it('Test 12: Abbrechen im Loeschdialog ruft onDelete NICHT auf', () => {
const onDelete = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
const dialog = screen.getByRole('alertdialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
expect(onDelete).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
});
describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', () => {
it('Test 13: Druecken und Loslassen OHNE nennenswerte Bewegung sendet KEINE neue Reihenfolge (der nachfolgende Klick waehlt weiterhin)', () => {
stubTabRects();
const onReorder = vi.fn();
const onSelect = vi.fn();
render(<DashboardTabs {...defaultProps({ onReorder, onSelect })} />);
const tab = screen.getByTestId('dashboard-tab-d2');
fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 });
fireEvent.pointerUp(tab, { clientX: 150, pointerId: 1 });
expect(onReorder).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
it('Test 14: Druecken, mehr als die Schwelle nach RECHTS bewegen und loslassen verschiebt den Reiter hinter seinen rechten Nachbarn', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d1'); // Spanne 0..100
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
// ueber die Schwelle hinaus, weit rechts von d2s Mittelpunkt (150)
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
});
it('Test 15: dasselbe nach LINKS verschiebt vor den linken Nachbarn', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d2'); // Spanne 100..200
fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 });
// weit links von d1s Mittelpunkt (50)
fireEvent.pointerMove(tab, { clientX: 10, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 10, pointerId: 1 });
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
});
it('Test 16: waehrend des Ziehens zeigt die Leiste die Vorschau-Reihenfolge; Abbruch des Zeigers verwirft sie, ohne zu senden', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
// Vorschau: d2 steht jetzt vor d1 im DOM.
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('tab')
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']);
});
it('Test 17: ein Ziehen, das den ersten Reiter verdraengt, macht den vorgezogenen Reiter zum ersten in der gesendeten Liste', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d3'); // Spanne 200..300
fireEvent.pointerDown(tab, { clientX: 250, pointerId: 1, button: 0 });
// weit links von d1s Mittelpunkt (50) -> wird der neue Erste
fireEvent.pointerMove(tab, { clientX: 5, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 5, pointerId: 1 });
expect(onReorder).toHaveBeenCalledWith(['d3', 'd1', 'd2']);
});
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() })}
/>,
);
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('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', () => {
stubTabRects();
const onSelect = vi.fn();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onSelect, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
expect(onReorder).toHaveBeenCalledTimes(1);
// Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen.
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' }));
expect(onSelect).not.toHaveBeenCalled();
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();
rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
expect(
screen.queryByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'),
).not.toBeInTheDocument();
});
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 })} />,
);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
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);
});
});
@@ -0,0 +1,540 @@
'use client';
import { useTranslations } from 'next-intl';
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 {
dashboards: DashboardTab[];
activeDashboardId: string | null;
isEditMode: boolean;
onSelect: (id: string) => void;
onCreate: () => void;
onRename: (id: string, name: string) => void;
onDelete: (id: string) => void;
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;
// jsdom kennt kein setPointerCapture — im Browser wird gefangen, im Test
// feuern Move/Up auf demselben Element (Muster xframe-config-form.tsx).
function capturePointer(el: HTMLElement, id: number) {
if (typeof el.setPointerCapture === 'function') el.setPointerCapture(id);
}
function releasePointer(el: HTMLElement, id: number) {
if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(id);
}
interface DragState {
id: string;
pointerId: number;
startX: number;
}
/**
* Berechnet die neue Reihenfolge, wenn `draggedId` an die Zeigerposition
* `clientX` verschoben wird: der gezogene Reiter wird aus der Liste
* entfernt, dann vor dem ERSTEN verbleibenden Reiter eingefuegt, dessen
* Mittelpunkt rechts vom Zeiger liegt (ansonsten ans Ende).
*/
function computeReorderedIds(
order: string[],
draggedId: string,
rects: Map<string, { left: number; width: number }>,
clientX: number,
): string[] {
const others = order.filter((id) => id !== draggedId);
let insertAt = others.length;
for (let i = 0; i < others.length; i++) {
const rect = rects.get(others[i]);
if (!rect) continue;
const midpoint = rect.left + rect.width / 2;
if (clientX < midpoint) {
insertAt = i;
break;
}
}
const next = others.slice();
next.splice(insertAt, 0, draggedId);
return next;
}
/**
* 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)
* und Löschen (mit Rückfrage) sind nur im Bearbeitungsmodus sichtbar; der
* Löschen-Knopf fehlt zusätzlich beim letzten verbleibenden Reiter (D-10 —
* der Server weist das ohnehin ab, die Oberfläche bietet es gar nicht erst
* an).
*
* Ziehen (Task 4, D-05 — keine neue Abhängigkeit, Pointer-Ereignisse wie in
* `xframe-config-form.tsx`) ist IMMER möglich, nicht nur im Bearbeitungs-
* modus: nach vorn ziehen IST das Festlegen des Standards (D-09). Unter der
* Schwelle von {@link DRAG_THRESHOLD_PX} bleibt es ein Klick (`onClick`
* wechselt den Reiter, kein `onReorder`); darüber wird der Zeiger
* eingefangen, die Leiste zeigt die Vorschau-Reihenfolge, und beim
* Loslassen geht die VOLLSTÄNDIGE Kennungsliste an `onReorder`. Ein
* `hasDraggedRef`-Merker unterdrückt den `onClick`, der nach einem echten
* Ziehen im echten Browser folgt (jsdom feuert ihn in Tests nicht von
* selbst nach). Abbruch des Zeigers verwirft die Vorschau, ohne zu senden.
*/
export function DashboardTabs({
dashboards,
activeDashboardId,
isEditMode,
onSelect,
onCreate,
onRename,
onDelete,
onReorder,
}: DashboardTabsProps) {
const t = useTranslations('widgets');
const tCommon = useTranslations('common');
const [renamingId, setRenamingId] = useState<string | null>(null);
const [draftName, setDraftName] = useState('');
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
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
// `widget-catalog-modal.tsx` (dialogRef.current?.focus()).
useEffect(() => {
if (renamingId) {
renameInputRef.current?.focus();
renameInputRef.current?.select();
}
}, [renamingId]);
const order = previewOrder ?? dashboards.map((d) => d.id);
const orderedTabs = order
.map((id) => dashboards.find((d) => d.id === id))
.filter((d): d is DashboardTab => d !== undefined);
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
if (e.button !== 0 || renamingId) return;
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
if (!drag) return;
const wasNotDragging = previewOrder === null;
if (wasNotDragging) {
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return;
hasDraggedRef.current = true;
capturePointer(e.currentTarget, drag.pointerId);
}
// Auf dem SELBEN Ereignis, das die Schwelle ueberschreitet, wird sofort
// die Zielposition berechnet — nicht erst beim naechsten Move. Ohne das
// wuerde ein einzelner grosser Sprung (Druecken, weit bewegen, Loslassen
// — die Form, in der ein Ziehen unter jsdom typischerweise ausgeloest
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
// Ziehzustand wechselt, ohne die Position auszuwerten.
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
const rects = new Map<string, { left: number; width: number }>();
for (const id of baseOrder) {
const el = tabRefs.current.get(id);
if (el) {
const rect = el.getBoundingClientRect();
rects.set(id, { left: rect.left, width: rect.width });
}
}
const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX);
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
setPreviewOrder(next);
}
}
function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
dragRef.current = null;
if (!drag) return;
if (previewOrder !== null) {
releasePointer(e.currentTarget, drag.pointerId);
const finalOrder = previewOrder;
setPreviewOrder(null);
onReorder(finalOrder);
}
}
function handlePointerCancel(e: PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
dragRef.current = null;
if (drag && previewOrder !== null) {
releasePointer(e.currentTarget, drag.pointerId);
}
setPreviewOrder(null);
}
function handleTabClick(tabId: string) {
if (hasDraggedRef.current) {
hasDraggedRef.current = false;
return;
}
onSelect(tabId);
}
function startRename(tab: DashboardTab) {
setRenamingId(tab.id);
setDraftName(tab.name);
}
function commitRename() {
const id = renamingId;
const name = draftName.trim();
setRenamingId(null);
if (id && name) {
onRename(id, name);
}
}
function cancelRename() {
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;
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
key={tab.id}
ref={(el) => {
if (el) tabRefs.current.set(tab.id, el);
else tabRefs.current.delete(tab.id);
}}
data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`}
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}
onPointerCancel={handlePointerCancel}
>
{isRenaming ? (
<input
ref={renameInputRef}
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitRename();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
}}
maxLength={40}
aria-label={t('tabs.renameInputLabel')}
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)}
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}
</button>
)}
{isEditMode && isActive && !isRenaming && (
<button
type="button"
onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')}
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="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</button>
)}
{isEditMode && dashboards.length > 1 && (
<button
type="button"
onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')}
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="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</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="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="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
)}
{/* 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)}
aria-label={tCommon('cancel')}
className="fixed inset-0 bg-black/50"
/>
<div
role="alertdialog"
aria-modal="true"
aria-label={t('tabs.deleteDialogTitle')}
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')}
</h3>
<p className="mb-4 text-sm text-muted-foreground">
{t('tabs.deleteDialogBody', { name: pendingDeleteTab.name })}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setPendingDeleteId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
{tCommon('cancel')}
</button>
<button
type="button"
onClick={() => {
onDelete(pendingDeleteTab.id);
setPendingDeleteId(null);
}}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
{tCommon('delete')}
</button>
</div>
</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());
});
});
+16 -5
View File
@@ -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)]';

Some files were not shown because too many files have changed in this diff Show More