Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd09c08311 | |||
| 602a45c8b6 | |||
| 586da44fa1 | |||
| 377b6e37b2 | |||
| a217d606bb | |||
| 92bf1307f6 | |||
| a906c6705d |
+2
-1
@@ -7,7 +7,7 @@ status: verified
|
|||||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||||
last_updated: "2026-09-23T15:30:00.000Z"
|
last_updated: "2026-09-23T15:30:00.000Z"
|
||||||
last_activity: 2026-09-23
|
last_activity: 2026-09-23
|
||||||
last_activity_desc: Quick 260924-h7x — Proxmox-Seite mit Statusfarben/Tiefe, Offline & verwaist, Dashboard-Reiter in der Kopfzeile
|
last_activity_desc: Quick 260924-i8v — Proxmox-Kachel fuers Dashboard; davor CI-Runner haengte (Neustart, Lauf 423 gruen)
|
||||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||||
progress:
|
progress:
|
||||||
total_phases: 18
|
total_phases: 18
|
||||||
@@ -473,6 +473,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
|||||||
| 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-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/) |
|
| 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-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/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -10,6 +10,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
- 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.
|
- 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
|
- 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
|
- 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
|
### Geändert
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,18 @@ describe('widget-module-map (quick-260922-m1h)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
|
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle
|
||||||
for (const type of WIDGET_TYPES) {
|
// 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();
|
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', () => {
|
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
|
||||||
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,17 +23,16 @@ import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
|
|||||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
* 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).
|
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||||
*
|
*
|
||||||
* Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
|
* Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` →
|
||||||
* (clock/search/calendar/note/calculator/favorites/stopwatch/
|
* `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/
|
||||||
* picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
|
* calculator/favorites/stopwatch/picture-frame/xframe) sind
|
||||||
* modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
|
* Plattform-Widgets ohne Modulbezug.
|
||||||
*/
|
*/
|
||||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
* 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
|
* der Typ kein Modul-Widget ist (alle Typen außer `proxmox`). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
|
||||||
* damit Tests sie gezielt mocken können.
|
* damit Tests sie gezielt mocken können.
|
||||||
*/
|
*/
|
||||||
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
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 {
|
import type {
|
||||||
ProxmoxErrorKind,
|
ProxmoxErrorKind,
|
||||||
ProxmoxPbsMetrics,
|
ProxmoxPbsMetrics,
|
||||||
@@ -10,16 +22,6 @@ import type {
|
|||||||
ProxmoxPveMetrics,
|
ProxmoxPveMetrics,
|
||||||
ProxmoxServer,
|
ProxmoxServer,
|
||||||
} from '@/lib/proxmox-api';
|
} from '@/lib/proxmox-api';
|
||||||
import {
|
|
||||||
formatAge,
|
|
||||||
isBackupStale,
|
|
||||||
meterLevel,
|
|
||||||
ratio,
|
|
||||||
type ServerHealth,
|
|
||||||
serverHealth,
|
|
||||||
toEpochMs,
|
|
||||||
} from './proxmox-status';
|
|
||||||
import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from './status-styles';
|
|
||||||
|
|
||||||
type Translator = ReturnType<typeof useTranslations>;
|
type Translator = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
@@ -50,16 +52,6 @@ function formatBytePair(used: number | null, total: number | null, locale: strin
|
|||||||
return `${nf.format(used / divisor)} / ${nf.format(total / divisor)} ${BYTE_UNITS[exponent]}`;
|
return `${nf.format(used / divisor)} / ${nf.format(total / divisor)} ${BYTE_UNITS[exponent]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercent(fraction: number, locale: string): string {
|
|
||||||
return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }).format(
|
|
||||||
fraction,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCount(value: number, locale: string): string {
|
|
||||||
return new Intl.NumberFormat(locale).format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exakter Zeitpunkt fuer das `title`-Attribut der relativen Angaben. */
|
/** Exakter Zeitpunkt fuer das `title`-Attribut der relativen Angaben. */
|
||||||
function exactTime(value: number | string | null | undefined, locale: string): string | undefined {
|
function exactTime(value: number | string | null | undefined, locale: string): string | undefined {
|
||||||
const ms = toEpochMs(value);
|
const ms = toEpochMs(value);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { useTranslations } from 'next-intl';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||||
import { HealthBar } from './components/HealthBar';
|
import { HealthBar } from '@/components/proxmox/HealthBar';
|
||||||
import { sortServersByHealth, summarizeHealth } from './components/proxmox-status';
|
import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status';
|
||||||
import { ServerCard } from './components/ServerCard';
|
import { ServerCard } from './components/ServerCard';
|
||||||
import { WELL } from './components/status-styles';
|
import { WELL } from '@/components/proxmox/status-styles';
|
||||||
|
|
||||||
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
||||||
const CLOCK_TICK_MS = 60_000;
|
const CLOCK_TICK_MS = 60_000;
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWid
|
|||||||
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
||||||
vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => 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/xframe-widget', () => ({ XframeWidget: () => null }));
|
||||||
|
vi.mock('@/components/dashboard/widgets/proxmox-widget', () => ({ ProxmoxWidget: () => null }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
catalogProps.accessibleModuleSlugs = undefined;
|
catalogProps.accessibleModuleSlugs = undefined;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget
|
|||||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-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 { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ registerWidget('stopwatch', StopwatchWidget);
|
|||||||
registerWidget('favorites', FavoritesWidget);
|
registerWidget('favorites', FavoritesWidget);
|
||||||
registerWidget('picture-frame', PictureFrameWidget);
|
registerWidget('picture-frame', PictureFrameWidget);
|
||||||
registerWidget('xframe', XframeWidget);
|
registerWidget('xframe', XframeWidget);
|
||||||
|
registerWidget('proxmox', ProxmoxWidget);
|
||||||
|
|
||||||
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
||||||
interface ActiveModule {
|
interface ActiveModule {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ vi.mock('next-intl', () => ({
|
|||||||
'pictureFrame.description': 'Bilder als Diashow',
|
'pictureFrame.description': 'Bilder als Diashow',
|
||||||
'xframe.name': 'XFrame',
|
'xframe.name': 'XFrame',
|
||||||
'xframe.description': 'Webseite einbetten',
|
'xframe.description': 'Webseite einbetten',
|
||||||
|
'proxmox.name': 'Proxmox',
|
||||||
|
'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
close: 'Schließen',
|
close: 'Schließen',
|
||||||
@@ -40,8 +42,9 @@ import { WIDGET_TYPES } from '@tessera/shared';
|
|||||||
import { WIDGET_REGISTRY } from './widget-registry';
|
import { WIDGET_REGISTRY } from './widget-registry';
|
||||||
import { WidgetCatalogModal } from './widget-catalog-modal';
|
import { WidgetCatalogModal } from './widget-catalog-modal';
|
||||||
|
|
||||||
// Alle neun Kacheln sind heute Plattform-Kacheln ohne moduleSlug, also zeigt
|
// Neun Kacheln sind Plattform-Kacheln ohne moduleSlug und erscheinen auch bei
|
||||||
// der Katalog sie auch bei leerer Modulliste vollstaendig an.
|
// leerer Modulliste; die Proxmox-Kachel (quick-260924-i8v) nur mit Zugriff
|
||||||
|
// auf das Modul 'proxmox'.
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
@@ -141,44 +144,39 @@ describe('WidgetCatalogModal', () => {
|
|||||||
* sie aus der Registry ab und filtert nach Modulzugriff.
|
* sie aus der Registry ab und filtert nach Modulzugriff.
|
||||||
*/
|
*/
|
||||||
describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => {
|
describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => {
|
||||||
it('zeigt alle neun Kacheln in der Reihenfolge der Registry', () => {
|
function catalogTypes(): (string | null)[] {
|
||||||
render(<WidgetCatalogModal {...baseProps} />);
|
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
|
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
|
||||||
const cards = Array.from(
|
return Array.from(
|
||||||
dialog.querySelectorAll<HTMLButtonElement>('button[data-widget-type]'),
|
dialog.querySelectorAll<HTMLButtonElement>('button[data-widget-type]'),
|
||||||
);
|
).map((c) => c.getAttribute('data-widget-type'));
|
||||||
|
}
|
||||||
|
|
||||||
expect(cards.map((c) => c.getAttribute('data-widget-type'))).toEqual([
|
it("zeigt mit Zugriff auf 'proxmox' alle zehn Kacheln in der Reihenfolge der Registry, Proxmox zuletzt", () => {
|
||||||
...WIDGET_TYPES,
|
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||||
]);
|
|
||||||
|
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
|
||||||
|
expect(catalogTypes().at(-1)).toBe('proxmox');
|
||||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine Kachel MIT moduleSlug fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
it('zeigt ohne Modulzugriff die neun Plattform-Kacheln ohne Proxmox', () => {
|
||||||
// Die Registry traegt heute keine Modul-Kachel — fuer den Nachweis am
|
render(<WidgetCatalogModal {...baseProps} />);
|
||||||
// echten Bauteil wird clock voruebergehend zu einer gemacht.
|
|
||||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
expect(catalogTypes()).toEqual(WIDGET_TYPES.filter((type) => type !== 'proxmox'));
|
||||||
try {
|
});
|
||||||
|
|
||||||
|
it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
||||||
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
|
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
|
||||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
expect(screen.queryByRole('button', { name: /Proxmox/ })).toBeNull();
|
||||||
|
|
||||||
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||||
expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Proxmox/ })).toBeInTheDocument();
|
||||||
} finally {
|
|
||||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fail-closed: schlaegt der Modulabruf fehl (null), verschwinden Kacheln MIT moduleSlug, Plattform-Kacheln bleiben', () => {
|
it('fail-closed: schlaegt der Modulabruf fehl (null), fehlt „Proxmox“, Plattform-Kacheln bleiben', () => {
|
||||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
|
||||||
try {
|
|
||||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
|
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();
|
expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument();
|
||||||
} finally {
|
|
||||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
|||||||
'picture-frame',
|
'picture-frame',
|
||||||
// XFrame (quick-260921-qd3)
|
// XFrame (quick-260921-qd3)
|
||||||
'xframe',
|
'xframe',
|
||||||
|
// Proxmox — erste Modul-Kachel (quick-260924-i8v)
|
||||||
|
'proxmox',
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||||
@@ -64,7 +66,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
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
|
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
|
||||||
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
||||||
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
||||||
@@ -79,6 +81,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||||
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
||||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||||
|
proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 },
|
||||||
});
|
});
|
||||||
|
|
||||||
let counted = 0;
|
let counted = 0;
|
||||||
@@ -88,7 +91,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
counted++;
|
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]);
|
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);
|
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) {
|
for (const type of WIDGET_TYPES) {
|
||||||
|
if (type === 'proxmox') {
|
||||||
|
expect(WIDGET_REGISTRY[type].moduleSlug).toBe('proxmox');
|
||||||
|
} else {
|
||||||
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
|
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,15 +161,15 @@ describe('registerWidget (quick-260922-m1h)', () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
|
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
|
||||||
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
|
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
|
||||||
registerWidget('proxmox' as WidgetType, makeComponent()),
|
registerWidget('gibt-es-nicht' as WidgetType, makeComponent()),
|
||||||
).toThrow(/proxmox/);
|
).toThrow(/gibt-es-nicht/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
|
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
|
||||||
const previous = process.env.NODE_ENV;
|
const previous = process.env.NODE_ENV;
|
||||||
vi.stubEnv('NODE_ENV', 'production');
|
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.stubEnv('NODE_ENV', previous ?? 'test');
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@@ -182,8 +189,14 @@ describe('visibleWidgetTypes (quick-260922-m1h)', () => {
|
|||||||
note: {},
|
note: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('behaelt die Reihenfolge der Registry bei', () => {
|
it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlt nur Proxmox', () => {
|
||||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual([...WIDGET_TYPES]);
|
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', () => {
|
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
|||||||
* decken den Fall ab).
|
* decken den Fall ab).
|
||||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||||
|
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
|
||||||
*/
|
*/
|
||||||
export type { WidgetType };
|
export type { WidgetType };
|
||||||
|
|
||||||
@@ -71,6 +72,11 @@ export const WIDGET_CONSTRAINTS: Record<
|
|||||||
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
|
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
|
||||||
// Rahmen noch Sinn hat.
|
// Rahmen noch Sinn hat.
|
||||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +89,7 @@ export interface WidgetDefinition {
|
|||||||
/**
|
/**
|
||||||
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
|
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
|
||||||
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel
|
* `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;
|
moduleSlug?: string;
|
||||||
/** Inline SVG icon as React component */
|
/** Inline SVG icon as React component */
|
||||||
@@ -317,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
|
* Registry of all widget types. Used by the widget catalog modal
|
||||||
* and the grid renderer to look up components and constraints.
|
* and the grid renderer to look up components and constraints.
|
||||||
@@ -403,6 +432,17 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
moduleSlug: WIDGET_MODULE_SLUGS.xframe,
|
moduleSlug: WIDGET_MODULE_SLUGS.xframe,
|
||||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
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
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,642 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderWidget(
|
||||||
|
props: { config?: Record<string, unknown>; isEditMode?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const { ProxmoxWidget } = await import('./proxmox-widget');
|
||||||
|
return render(
|
||||||
|
<ProxmoxWidget
|
||||||
|
instanceId="w-1"
|
||||||
|
config={props.config ?? {}}
|
||||||
|
isEditMode={props.isEditMode ?? false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { ProxmoxWidget } = await import('./proxmox-widget');
|
||||||
|
const ui = (isEditMode: boolean) => (
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
<ProxmoxWidget instanceId="w-1" config={{}} isEditMode={isEditMode} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
const { 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>
|
||||||
|
)}
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
+38
-12
@@ -6,6 +6,14 @@ import { HEALTH_STYLE } from './status-styles';
|
|||||||
|
|
||||||
interface HealthBarProps {
|
interface HealthBarProps {
|
||||||
counts: Record<ServerHealth, number>;
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,8 +22,11 @@ interface HealthBarProps {
|
|||||||
* (down, warn, ok, idle, orphan; leere Zustaende entfallen), darunter die
|
* (down, warn, ok, idle, orphan; leere Zustaende entfallen), darunter die
|
||||||
* Legende „Punkt + Zahl + Wort“. Der Balken liest seine Zusammenfassung als
|
* Legende „Punkt + Zahl + Wort“. Der Balken liest seine Zusammenfassung als
|
||||||
* `role="img"` vor.
|
* `role="img"` vor.
|
||||||
|
*
|
||||||
|
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel
|
||||||
|
* (dort als `variant="compact"`).
|
||||||
*/
|
*/
|
||||||
export function HealthBar({ counts }: HealthBarProps) {
|
export function HealthBar({ counts, variant = 'full' }: HealthBarProps) {
|
||||||
const t = useTranslations('proxmox');
|
const t = useTranslations('proxmox');
|
||||||
const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({
|
const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({
|
||||||
health,
|
health,
|
||||||
@@ -24,23 +35,38 @@ export function HealthBar({ counts }: HealthBarProps) {
|
|||||||
}));
|
}));
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
const summary = entries.map((e) => `${e.count} ${e.word}`).join(', ');
|
const segments = entries.map((e) => (
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2.5" data-testid="health-bar">
|
|
||||||
<div
|
|
||||||
role="img"
|
|
||||||
aria-label={t('legend.barLabel', { summary })}
|
|
||||||
className="flex h-2 w-full gap-[3px] overflow-hidden rounded-full"
|
|
||||||
>
|
|
||||||
{entries.map((e) => (
|
|
||||||
<div
|
<div
|
||||||
key={e.health}
|
key={e.health}
|
||||||
data-segment={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}`}
|
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 }}
|
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>
|
</div>
|
||||||
<ul className="flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-hidden="true">
|
<ul className="flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-hidden="true">
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+13
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { ProxmoxMetrics, ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
|
import type { ProxmoxMetrics, ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
|
||||||
import {
|
import {
|
||||||
formatAge,
|
formatAge,
|
||||||
|
formatCount,
|
||||||
|
formatPercent,
|
||||||
HEALTH_ORDER,
|
HEALTH_ORDER,
|
||||||
isBackupStale,
|
isBackupStale,
|
||||||
meterLevel,
|
meterLevel,
|
||||||
@@ -307,3 +309,14 @@ describe('formatAge', () => {
|
|||||||
expect(formatAge('2026-09-24T11:57:00.000Z', NOW, 'en')).toBe('3 min. ago');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+19
@@ -8,6 +8,9 @@ import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
|
|||||||
* Warnung aus. Die Feldnamen von PBS und PMG sind nur abgeleitet (siehe
|
* Warnung aus. Die Feldnamen von PBS und PMG sind nur abgeleitet (siehe
|
||||||
* ServerCard), ein Fehlalarm aus einem fehlenden Feld waere schlimmer als
|
* ServerCard), ein Fehlalarm aus einem fehlenden Feld waere schlimmer als
|
||||||
* ein ehrliches „unbekannt“.
|
* 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. */
|
/** Gesamtzustand eines Servers — bestimmt Farbe, Pille und Sortierung. */
|
||||||
@@ -156,3 +159,19 @@ export function formatAge(
|
|||||||
if (hours < 24) return rtf.format(-hours, 'hour');
|
if (hours < 24) return rtf.format(-hours, 'hour');
|
||||||
return rtf.format(-Math.floor(hours / 24), 'day');
|
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);
|
||||||
|
}
|
||||||
+2
@@ -8,6 +8,8 @@ import type { MeterLevel, ServerHealth } from './proxmox-status';
|
|||||||
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
|
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
|
||||||
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
|
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
|
||||||
* zweischichtiger Schatten, der untere im Statuston getoent.
|
* zweischichtiger Schatten, der untere im Statuston getoent.
|
||||||
|
*
|
||||||
|
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel.
|
||||||
*/
|
*/
|
||||||
export const HEALTH_STYLE: Record<
|
export const HEALTH_STYLE: Record<
|
||||||
ServerHealth,
|
ServerHealth,
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungsformular der Proxmox-Kachel (quick-260924-i8v) fuer
|
||||||
|
* Einstellungen > Dashboard: Titel und Serverauswahl.
|
||||||
|
*/
|
||||||
|
function render(ui: ReactElement) {
|
||||||
|
return rtlRender(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T-I8V-02: `pollServer` nur als Spion — das Formular darf ihn nie rufen.
|
||||||
|
const mockListServers = vi.fn();
|
||||||
|
const mockPollServer = vi.fn();
|
||||||
|
vi.mock('@/lib/proxmox-api', () => ({
|
||||||
|
listServers: (...args: unknown[]) => mockListServers(...args),
|
||||||
|
pollServer: (...args: unknown[]) => mockPollServer(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ProxmoxWidgetConfigForm } from './proxmox-widget-config-form';
|
||||||
|
|
||||||
|
function server(id: string, position: number): ProxmoxServer {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
tenantId: 't1',
|
||||||
|
name: id,
|
||||||
|
productType: 'pve',
|
||||||
|
baseUrl: 'https://x.intern',
|
||||||
|
authMethod: 'token',
|
||||||
|
tokenId: 'a@pam!b',
|
||||||
|
username: null,
|
||||||
|
tlsRejectUnauthorized: true,
|
||||||
|
isActive: true,
|
||||||
|
pollIntervalMin: 5,
|
||||||
|
position,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
status: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ProxmoxWidgetConfigForm (quick-260924-i8v)', () => {
|
||||||
|
it('laedt die Server einmal und zeigt die Auswahl, vorbelegt aus der Konfiguration', async () => {
|
||||||
|
mockListServers.mockResolvedValue([server('pve-a', 0), server('pve-b', 1)]);
|
||||||
|
render(
|
||||||
|
<ProxmoxWidgetConfigForm
|
||||||
|
config={{ title: 'Rechenzentrum', serverIds: ['pve-b'] }}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Lade Serverliste...')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('checkbox', { name: /pve-b/ })).toBeChecked();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /pve-a/ })).not.toBeChecked();
|
||||||
|
expect((screen.getByLabelText('Titel') as HTMLInputElement).value).toBe('Rechenzentrum');
|
||||||
|
expect(mockListServers).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehlersatz, wenn die Liste nicht geladen werden kann', async () => {
|
||||||
|
mockListServers.mockRejectedValue(new Error('500'));
|
||||||
|
render(<ProxmoxWidgetConfigForm config={{}} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Die Serverliste konnte nicht geladen werden.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Leersatz ohne Server', async () => {
|
||||||
|
mockListServers.mockResolvedValue([]);
|
||||||
|
render(<ProxmoxWidgetConfigForm config={{}} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('checkbox')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Titelaenderung ruft onChange({ title }) mit dem rohen Tippwert', async () => {
|
||||||
|
mockListServers.mockResolvedValue([server('pve-a', 0)]);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ProxmoxWidgetConfigForm config={{}} onChange={onChange} />);
|
||||||
|
await screen.findByRole('checkbox');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Keller ' } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ title: 'Keller ' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Auswahlaenderung ruft onChange({ serverIds })', async () => {
|
||||||
|
mockListServers.mockResolvedValue([server('pve-a', 0), server('pve-b', 1)]);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ProxmoxWidgetConfigForm config={{ serverIds: ['pve-a'] }} onChange={onChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('checkbox', { name: /pve-b/ }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ serverIds: ['pve-a', 'pve-b'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ruft nie die manuelle Abfrage auf (T-I8V-02)', async () => {
|
||||||
|
mockListServers.mockResolvedValue([server('pve-a', 0)]);
|
||||||
|
render(<ProxmoxWidgetConfigForm config={{}} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
await screen.findByRole('checkbox');
|
||||||
|
await waitFor(() => expect(mockListServers).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockPollServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { type ReactNode, useEffect, useState } from 'react';
|
||||||
|
import { resolveProxmoxWidgetConfig } from '@/components/dashboard/widgets/proxmox-widget-model';
|
||||||
|
import { ProxmoxServerPicker } from '@/components/proxmox/proxmox-server-picker';
|
||||||
|
// T-I8V-02: nur die Leseabfrage des Zwischenlagers, nie die manuelle Abfrage.
|
||||||
|
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||||
|
|
||||||
|
interface ProxmoxWidgetConfigFormProps {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
onChange: (cfg: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungen der Proxmox-Kachel unter Einstellungen > Dashboard
|
||||||
|
* (quick-260924-i8v): Titel und Serverauswahl. Laedt die Serverliste einmal;
|
||||||
|
* dieselbe Auswahl gibt es an der Kachel im Bearbeitungsmodus (dort auch
|
||||||
|
* fuer Kacheln auf weiteren Reitern, die diese Seite nicht zeigt).
|
||||||
|
*/
|
||||||
|
export function ProxmoxWidgetConfigForm({ config, onChange }: ProxmoxWidgetConfigFormProps) {
|
||||||
|
const t = useTranslations('widgets');
|
||||||
|
const tp = useTranslations('proxmox');
|
||||||
|
const { title: rawTitle, serverIds } = resolveProxmoxWidgetConfig(config);
|
||||||
|
// Anzeige getrimmt (Leerraum-Titel sind fachlich „kein Titel“), gesendet
|
||||||
|
// wird der rohe Tippwert — Muster FavoritesConfig.
|
||||||
|
const title = rawTitle.trim() === '' ? '' : rawTitle;
|
||||||
|
|
||||||
|
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
listServers()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setServers(list);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadFailed(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let serverPart: ReactNode;
|
||||||
|
if (loadFailed) {
|
||||||
|
serverPart = <p className="text-sm text-muted-foreground">{tp('loadError')}</p>;
|
||||||
|
} else if (servers === null) {
|
||||||
|
serverPart = <p className="text-sm text-muted-foreground">{tp('loading')}</p>;
|
||||||
|
} else if (servers.length === 0) {
|
||||||
|
serverPart = <p className="text-sm text-muted-foreground">{t('proxmox.empty')}</p>;
|
||||||
|
} else {
|
||||||
|
serverPart = (
|
||||||
|
<ProxmoxServerPicker
|
||||||
|
servers={servers}
|
||||||
|
selectedIds={serverIds}
|
||||||
|
onChange={(next) => onChange({ serverIds: next })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="proxmox-widget-title" className="mb-1 block text-sm text-foreground">
|
||||||
|
{t('proxmox.titleLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="proxmox-widget-title"
|
||||||
|
type="text"
|
||||||
|
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
value={title}
|
||||||
|
placeholder={t('proxmox.titlePlaceholder')}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{serverPart}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,15 @@ vi.mock('@/components/settings/search-provider-form', () => ({
|
|||||||
SearchProviderForm: () => null,
|
SearchProviderForm: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// quick-260924-i8v: das Proxmox-Formular laedt die Serverliste; `pollServer`
|
||||||
|
// nur als Spion (T-I8V-02).
|
||||||
|
const mockListServers = vi.fn();
|
||||||
|
const mockPollServer = vi.fn();
|
||||||
|
vi.mock('@/lib/proxmox-api', () => ({
|
||||||
|
listServers: (...args: unknown[]) => mockListServers(...args),
|
||||||
|
pollServer: (...args: unknown[]) => mockPollServer(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
const clockTexts = (de as { widgets: { clock: Record<string, string> } }).widgets.clock;
|
const clockTexts = (de as { widgets: { clock: Record<string, string> } }).widgets.clock;
|
||||||
const cal = (de as { widgets: { calendar: Record<string, string> } }).widgets.calendar;
|
const cal = (de as { widgets: { calendar: Record<string, string> } }).widgets.calendar;
|
||||||
|
|
||||||
@@ -285,3 +294,42 @@ describe('WidgetSettingsPanel — Bilderrahmen-Kopfzeile (Kosmetik nach quick-26
|
|||||||
expect(screen.getByRole('button', { name: /Bilderrahmen #3/ })).not.toHaveTextContent('—');
|
expect(screen.getByRole('button', { name: /Bilderrahmen #3/ })).not.toHaveTextContent('—');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('WidgetSettingsPanel — Proxmox (quick-260924-i8v)', () => {
|
||||||
|
it('Test PX1: der Zweig rendert das Proxmox-Formular, Kopfzeile zeigt "— Titel", Auswahl ruft updateWidgetConfig/onWidgetUpdate', async () => {
|
||||||
|
mockListServers.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'srv-a',
|
||||||
|
name: 'pve-a',
|
||||||
|
productType: 'pve',
|
||||||
|
position: 0,
|
||||||
|
isActive: true,
|
||||||
|
status: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const onWidgetUpdate = vi.fn();
|
||||||
|
const widget = { id: 'px1', widgetType: 'proxmox', config: { title: 'Rechenzentrum' } };
|
||||||
|
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
|
||||||
|
|
||||||
|
const header = screen.getByRole('button', { name: /Proxmox #1/ });
|
||||||
|
expect(header).toHaveTextContent('— Rechenzentrum');
|
||||||
|
fireEvent.click(header);
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('Titel') as HTMLInputElement).value).toBe('Rechenzentrum');
|
||||||
|
fireEvent.click(await screen.findByRole('checkbox', { name: /pve-a/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(onWidgetUpdate).toHaveBeenCalledWith('px1', { serverIds: ['srv-a'] }),
|
||||||
|
);
|
||||||
|
expect(updateWidgetConfig).toHaveBeenCalledWith('px1', { serverIds: ['srv-a'] });
|
||||||
|
expect(mockPollServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test PX2: ohne Titel kein Zusatz in der Kopfzeile', () => {
|
||||||
|
mockListServers.mockResolvedValue([]);
|
||||||
|
const widget = { id: 'px2', widgetType: 'proxmox', config: {} };
|
||||||
|
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /Proxmox #1/ })).not.toHaveTextContent('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
|
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
|
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
|
||||||
|
import { ProxmoxWidgetConfigForm } from '@/components/settings/proxmox-widget-config-form';
|
||||||
import { SearchProviderForm } from '@/components/settings/search-provider-form';
|
import { SearchProviderForm } from '@/components/settings/search-provider-form';
|
||||||
import { XframeConfigForm } from '@/components/settings/xframe-config-form';
|
import { XframeConfigForm } from '@/components/settings/xframe-config-form';
|
||||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
@@ -122,7 +123,8 @@ export function WidgetSettingsPanel({
|
|||||||
{t(def.nameKey)} #{idx + 1}
|
{t(def.nameKey)} #{idx + 1}
|
||||||
{(widget.widgetType === 'note' ||
|
{(widget.widgetType === 'note' ||
|
||||||
widget.widgetType === 'favorites' ||
|
widget.widgetType === 'favorites' ||
|
||||||
widget.widgetType === 'xframe') &&
|
widget.widgetType === 'xframe' ||
|
||||||
|
widget.widgetType === 'proxmox') &&
|
||||||
typeof widget.config.title === 'string' &&
|
typeof widget.config.title === 'string' &&
|
||||||
widget.config.title.trim() !== '' && (
|
widget.config.title.trim() !== '' && (
|
||||||
<span className="ml-2 text-muted-foreground">
|
<span className="ml-2 text-muted-foreground">
|
||||||
@@ -232,6 +234,16 @@ export function WidgetSettingsPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Proxmox: Titel und Serverauswahl (quick-260924-i8v) */}
|
||||||
|
{widget.widgetType === 'proxmox' && (
|
||||||
|
<ProxmoxWidgetConfigForm
|
||||||
|
config={widget.config}
|
||||||
|
onChange={(cfg) =>
|
||||||
|
handleConfigChange(widget.id, cfg)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -417,6 +417,23 @@
|
|||||||
"zoomLabel": "Vergrößerung der ganzen Seite",
|
"zoomLabel": "Vergrößerung der ganzen Seite",
|
||||||
"zoomOption": "{n} %",
|
"zoomOption": "{n} %",
|
||||||
"readOnly": "Nur anzeigen – Klicks und Scrollen im Rahmen sperren"
|
"readOnly": "Nur anzeigen – Klicks und Scrollen im Rahmen sperren"
|
||||||
|
},
|
||||||
|
"proxmox": {
|
||||||
|
"name": "Proxmox",
|
||||||
|
"description": "Zustand Ihrer Proxmox-Server auf einen Blick",
|
||||||
|
"allOk": "Alles in Ordnung",
|
||||||
|
"empty": "Noch kein Proxmox-Server eingetragen.",
|
||||||
|
"guests": "{running}/{total} {total, plural, one {Gast läuft} other {Gäste laufen}}",
|
||||||
|
"noGuests": "keine Gäste",
|
||||||
|
"load": "Auslastung {percent}",
|
||||||
|
"backupAgo": "Sicherung {age}",
|
||||||
|
"mailIn": "{count} eingehend",
|
||||||
|
"selectionGone": "Die ausgewählten Server gibt es nicht mehr. Wählen Sie im Bearbeitungsmodus andere aus.",
|
||||||
|
"titleLabel": "Titel",
|
||||||
|
"titlePlaceholder": "Titel (optional)",
|
||||||
|
"serversLabel": "Angezeigte Server",
|
||||||
|
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
|
||||||
|
"chooseServers": "Server auswählen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -417,6 +417,23 @@
|
|||||||
"zoomLabel": "Zoom of the full page",
|
"zoomLabel": "Zoom of the full page",
|
||||||
"zoomOption": "{n} %",
|
"zoomOption": "{n} %",
|
||||||
"readOnly": "View only – block clicks and scrolling inside the frame"
|
"readOnly": "View only – block clicks and scrolling inside the frame"
|
||||||
|
},
|
||||||
|
"proxmox": {
|
||||||
|
"name": "Proxmox",
|
||||||
|
"description": "Health of your Proxmox servers at a glance",
|
||||||
|
"allOk": "All good",
|
||||||
|
"empty": "No Proxmox server added yet.",
|
||||||
|
"guests": "{running}/{total} {total, plural, one {guest running} other {guests running}}",
|
||||||
|
"noGuests": "no guests",
|
||||||
|
"load": "Load {percent}",
|
||||||
|
"backupAgo": "Backup {age}",
|
||||||
|
"mailIn": "{count} incoming",
|
||||||
|
"selectionGone": "The selected servers no longer exist. Choose others in edit mode.",
|
||||||
|
"titleLabel": "Title",
|
||||||
|
"titlePlaceholder": "Title (optional)",
|
||||||
|
"serversLabel": "Servers shown",
|
||||||
|
"serversHint": "With nothing selected, the tile shows all servers.",
|
||||||
|
"chooseServers": "Choose servers"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
|||||||
| Stoppuhr | Zeitmessung mit Rundenzeiten |
|
| Stoppuhr | Zeitmessung mit Rundenzeiten |
|
||||||
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
||||||
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
||||||
|
| Proxmox | Zeigt den Zustand Ihrer Proxmox-Server: oben ein farbiger Balken mit „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar, 1 mit Warnung“, darunter die Server, auffällige zuerst, jeweils mit einer Kennzahl (laufende Gäste, Auslastung bei einer Warnung, letzte Sicherung oder eingehende E-Mails). Ein Klick auf einen Server öffnet die Proxmox-Seite. Die Kachel steht nur Benutzern zur Verfügung, die das Proxmox-Modul nutzen dürfen. Sie aktualisiert sich jede Minute aus dem zuletzt gespeicherten Stand und fragt die Server dabei nicht neu ab. Einen Titel und die Auswahl der angezeigten Server (ohne Auswahl: alle) legen Sie im Bearbeitungsmodus direkt an der Kachel über das Titelfeld und „Server auswählen“ fest oder unter Einstellungen > Dashboard. In einer schmalen Kachel stehen nur Punkte und Namen, in einer sehr kleinen nur Balken und Zusammenfassung |
|
||||||
|
|
||||||
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen und XFrame gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen, XFrame und Proxmox gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame, Titel und angezeigte Server der Proxmox-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||||
|
|
||||||
## Marktplatz
|
## Marktplatz
|
||||||
|
|
||||||
@@ -162,6 +163,8 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
|
|||||||
|
|
||||||
**„Verbindung testen"** prüft den hinterlegten Zugang gegen den echten Server, ohne die zuletzt gemessenen Werte auf der Modulseite zu überschreiben. Bei Erfolg erscheint eine grüne Bestätigung; scheitert der Test, nennt die Meldung die Ursache in Alltagssprache — etwa „nicht erreichbar", „Zugang abgelehnt", „Rechte reichen nicht" oder ein Zertifikatsproblem.
|
**„Verbindung testen"** prüft den hinterlegten Zugang gegen den echten Server, ohne die zuletzt gemessenen Werte auf der Modulseite zu überschreiben. Bei Erfolg erscheint eine grüne Bestätigung; scheitert der Test, nennt die Meldung die Ursache in Alltagssprache — etwa „nicht erreichbar", „Zugang abgelehnt", „Rechte reichen nicht" oder ein Zertifikatsproblem.
|
||||||
|
|
||||||
|
**Die Dashboard-Kachel „Proxmox“:** Wer das Modul nutzen darf, kann sich den Zustand der Server auch als Kachel auf das Dashboard legen („Widget hinzufügen“, ganz unten im Katalog). Sie zeigt den farbigen Balken der Modulseite in klein, darunter in Worten, ob alles in Ordnung ist, und die Server mit je einer Kennzahl; ein Klick auf einen Server öffnet diese Seite. Die Kachel liest jede Minute den zuletzt gespeicherten Stand und löst selbst nie eine Abfrage bei Proxmox aus — wie aktuell die Werte sind, bestimmt weiterhin der Abfrageabstand je Server. Welche Server die Kachel zeigt, stellen Sie im Bearbeitungsmodus an der Kachel oder unter Einstellungen > Dashboard ein.
|
||||||
|
|
||||||
## Persönliche Einstellungen
|
## Persönliche Einstellungen
|
||||||
|
|
||||||
Öffnen Sie **Einstellungen** über das Benutzermenü oben rechts. Der Bereich gliedert sich in zwei Kategorien in der linken Unterleiste:
|
Öffnen Sie **Einstellungen** über das Benutzermenü oben rechts. Der Bereich gliedert sich in zwei Kategorien in der linken Unterleiste:
|
||||||
@@ -171,7 +174,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
|
|||||||
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
||||||
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
||||||
|
|
||||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert.
|
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert. Bei der Proxmox-Kachel legen Sie einen Titel fest und haken an, welche Server sie zeigt; ohne Haken zeigt sie alle Server. Dieselbe Auswahl erreichen Sie im Bearbeitungsmodus des Dashboards auch direkt an der Kachel über „Server auswählen“ — das ist der Weg für eine Proxmox-Kachel auf einem weiteren Reiter, denn diese Seite zeigt nur die Kacheln des ersten Reiters.
|
||||||
|
|
||||||
**Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.
|
**Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.
|
||||||
|
|
||||||
|
|||||||
@@ -454,6 +454,22 @@ verbindliche Prüfung sitzt serverseitig in `DashboardService.getWidgets()` (fil
|
|||||||
gesperrter Module fail-closed aus `GET /dashboard/widgets`) — und die Daten, die eine Modul-Kachel
|
gesperrter Module fail-closed aus `GET /dashboard/widgets`) — und die Daten, die eine Modul-Kachel
|
||||||
anzeigt, holt sie über die Endpunkte ihres Moduls, die `@UseModule('<slug>')` tragen müssen.
|
anzeigt, holt sie über die Endpunkte ihres Moduls, die `@UseModule('<slug>')` tragen müssen.
|
||||||
|
|
||||||
|
**Erstes echtes Beispiel: Proxmox (`quick-260924-i8v`).** Die Kachel steht in
|
||||||
|
`apps/web/src/components/dashboard/widgets/proxmox-widget.tsx`, ihre reinen Modellfunktionen
|
||||||
|
(`resolveProxmoxWidgetConfig`, `selectServers`, `healthSummary`, `widgetKeyFigure`) in
|
||||||
|
`proxmox-widget-model.ts` daneben; `WIDGET_MODULE_SLUGS` trägt `proxmox: 'proxmox'`. Die
|
||||||
|
Statuslogik, die Modulseite und Kachel teilen (`proxmox-status.ts` samt `formatPercent`/`formatCount`,
|
||||||
|
`HealthBar.tsx` mit `variant="compact"`, `status-styles.ts`, das Auswahl-Bauteil
|
||||||
|
`proxmox-server-picker.tsx`), liegt seit 260924-i8v unter `apps/web/src/components/proxmox/` — eine
|
||||||
|
Kachel unter `components/` greift so nicht in einen Routenordner unter `app/`. Das
|
||||||
|
Einstellungsformular für Einstellungen > Dashboard ist `components/settings/proxmox-widget-config-form.tsx`.
|
||||||
|
Zwei Regeln, die für jede weitere Modul-Kachel gelten: Die Kachel liest nur Daten, die das Modul
|
||||||
|
ohnehin vorhält (hier das Zwischenlager über `GET /modules/proxmox/servers`), und löst nie selbst
|
||||||
|
eine Abfrage beim Fremdsystem aus — ein Minutentakt je Kachel und Benutzer wäre sonst eine Last auf
|
||||||
|
den Proxmox-Hosts. Und Zeilen, die im Ansichtsmodus Links sind, werden im Bearbeitungsmodus zu
|
||||||
|
schlichten Elementen ohne Ziel: Links stehen im Abbruch-Selektor von `dashboard-grid.tsx`, eine Kachel
|
||||||
|
aus Links ließe sich sonst kaum noch ziehen.
|
||||||
|
|
||||||
## Mandantentrennung
|
## Mandantentrennung
|
||||||
|
|
||||||
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export const WIDGET_TYPES = [
|
|||||||
'stopwatch',
|
'stopwatch',
|
||||||
'picture-frame',
|
'picture-frame',
|
||||||
'xframe',
|
'xframe',
|
||||||
|
'proxmox',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||||
@@ -114,8 +115,11 @@ export type WidgetType = (typeof WIDGET_TYPES)[number];
|
|||||||
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
|
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
|
||||||
* serverseitig in `DashboardService.getWidgets` (fail-closed).
|
* serverseitig in `DashboardService.getWidgets` (fail-closed).
|
||||||
*
|
*
|
||||||
* Heute bewusst leer: alle neun Kacheln sind Plattform-Kacheln ohne
|
* Die neun Kacheln von vor quick-260924-i8v sind Plattform-Kacheln ohne
|
||||||
* Modulbezug. Die erste modulgebundene Kachel (Proxmox) traegt hier ihren
|
* Modulbezug. Proxmox ist die erste modulgebundene Kachel; ihr Slug ist
|
||||||
* Slug ein.
|
* derselbe wie `@UseModule('proxmox')` im Controller und `slug: 'proxmox'`
|
||||||
|
* in `proxmox.seed.ts`.
|
||||||
*/
|
*/
|
||||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
|
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {
|
||||||
|
proxmox: 'proxmox',
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user