7 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:27:05 +02:00
22 changed files with 2692 additions and 773 deletions
+2 -1
View File
@@ -7,7 +7,7 @@ status: verified
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-09-23
last_activity_desc: Quick 260923-lrr — Favoriten-Symbol hochladen/sofort aktualisieren; davor Bildschirmfoto im Client repariert, Favoriten-Kachel schmaler; alles gepusht
last_activity_desc: Quick 260924-h7x — Proxmox-Seite mit Statusfarben/Tiefe, Offline & verwaist, Dashboard-Reiter in der Kopfzeile
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress:
total_phases: 18
@@ -472,6 +472,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260923-fst | **Favoriten-Kachel bis auf eine Spalte schmal ziehbar** (fast): `WIDGET_CONSTRAINTS.favorites.minW` 3 -> 1; Titel kuerzt, Symbol bleibt. Im Browser gezogen: 321 -> 47 px. | 2026-09-23 | b03ffb5 | — |
| 260923-bug | **Fehler melden: Bildschirmfoto scheiterte an einem fremden Bild** (fast): `html-to-image` bricht die ganze Aufnahme ab, sobald ein `<img>` ohne CORS nicht nachladbar ist -> Haekchen gesperrt. Jetzt `imagePlaceholder` + `onImageErrorHandler`, zweiter Versuch ohne Bilder/Rahmen. Im echten Linux-Client 1.3.1 nachgestellt (Probe-Bild google favicon) und nach dem Fix gegengeprueft. | 2026-09-23 | bf4384a | — |
| 260923-lrr | **Favoriten: eigenes Symbol hochladen, Symbol sofort aktualisiert, Cloudflare-Meldung.** Versionszaehler `iconVersion` an der Symboladresse (`?v=`) statt 24-h-Zwischenspeicher mit fester Adresse (Ursache „neue Logo-Adresse, nichts passiert“); `Cache-Control: private`. Upload PNG/JPEG/GIF/WebP/ICO/SVG bis 512 KB nach Dateiinhalt, Ablage `user-files/favorite-icons/<userId>/<id>.<ext>`, Vorrang vor Logo-Adresse, Entfernen-Knopf. Neue Logo-Adresse wird beim Speichern einmal zur Probe abgerufen; scheitert es (Cloudflare-Pruefung, 403), bleibt das Formular offen mit deutscher Meldung und Hinweis aufs Hochladen. Aufraeumen der Dateien auch beim Loeschen einer Kachel/eines Reiters (T-LRR-07 geschlossen). Browser-Nachweis: rot hochgeladen -> sofort rot (v=1), blau -> sofort blau (v=2), Entfernen -> altes Logo (v=3), httpbin 403 -> Meldung, google favicon -> sofort (v=4). Nachtrag Orchestrator: Zeile `dashboard.service.ts`/`favoriteLink` in der Zugriffsklassifikation. api 1368, web 742 gruen. | 2026-09-23 | 7704372,61f95c8 | [260923-lrr-favoriten-eigenes-symbol-hochladen-und-s](./quick/260923-lrr-favoriten-eigenes-symbol-hochladen-und-s/) |
| 260924-h7x | **Proxmox-Seite neu gestaltet (Status bestimmt das Bild) und Dashboard-Reiter in die Kopfzeile.** Nutzer hob am 24.09. die Umbausperre vom 23.09. selbst auf. Design-Plan aus dem frontend-design-Skill: Statusfarben als OKLCH-Tokens (`--status-ok/warn/down/idle/orphan`, dazu `-fg`-Textvarianten fuer 4,5:1), Gesundheitsbalken mit Legende, Karten mit Statusleiste links und im Statuston getoentem Schatten, eingelassene Messfelder, Knoten als Einschuebe mit Balken nach Schwellen (80/92 %, Sicherung > 26 h), PMG-Zahlfelder. **Deaktivierter Server = „Offline & verwaist“** (Vorrang vor allem, keine alten Werte, gestrichelt). Sortierung down/warn/ok/idle/orphan, Spaltenfluss statt Raster. Reiter als eingelassener Umschalter per Portal in der Kopfzeilenmitte (`header-center-slot`), eigene Zeile entfallen, Pfeiltasten, weiche Randausblendung bei Ueberlauf; unter 640 px Logo nur Bildmarke. Browser: hell/dunkel 1400 px, 390 px ohne Ueberlauf. web 789 gruen. | 2026-09-24 | 0fa7ce0,57c338f,7416a92,0b659d6,57a4196 | [260924-h7x-proxmox-seite-status-design-und-dashboar](./quick/260924-h7x-proxmox-seite-status-design-und-dashboar/) |
## Deferred Items
@@ -0,0 +1,173 @@
---
quick_id: 260924-h7x
type: quick
wave: 1
autonomous: true
files_modified:
- apps/web/src/app/globals.css
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts (new)
- apps/web/src/app/(portal)/modules/proxmox/components/*.test.tsx
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/dashboard/dashboard-tabs.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/messages/de.json, en.json
- CHANGELOG.md
---
# Quick 260924-h7x — Proxmox-Seite: Status-Design mit Tiefe; Dashboard-Reiter in die Kopfzeile
Nutzerauftrag (24.09.): „Kennzeichnen als offline & verwaist. Das Proxmox-Modul scheint zu
funktionieren, aber es gefällt mir optisch gar nicht. Benutze den Design-Skill und überarbeite
etwas — mehr Tiefe, Farben nach Status etc. Die Reiter sind da, aber die müssen woanders hin,
die nehmen zu viel Platz ein. Auch mit Design.“
Die frühere Sperre „KEIN Umbau der Proxmox-Modulseite“ (23.09.) ist damit durch den Nutzer
selbst aufgehoben.
Design-Plan vom Orchestrator (frontend-design-Skill) — verbindlich, nicht neu erfinden.
Keine neuen Pakete. App-Texte deutsch in Sie-Form, alle Schlüssel in de.json UND en.json.
Keine Großbuchstaben-Etiketten (kein `uppercase`, kein `tracking-widest`), keine
Mittelpunkt-Ketten „A · B · C“ in neuen Texten, kein „→“ an Knöpfen.
## Design-Tokens (in `globals.css`, `:root` und `.dark`)
Statusfarben als OKLCH-Variablen plus Tailwind-Abbildung in `@theme inline`
(`--color-status-ok` usw., damit `bg-status-ok`, `text-status-ok`, `border-status-ok/40` gehen):
| Token | hell | dunkel | Bedeutung |
|---|---|---|---|
| `--status-ok` | `oklch(0.62 0.15 152)` | `oklch(0.72 0.15 152)` | in Ordnung |
| `--status-warn` | `oklch(0.72 0.15 70)` | `oklch(0.80 0.14 75)` | Warnung |
| `--status-down` | `oklch(0.58 0.21 27)` | `oklch(0.68 0.19 27)` | nicht erreichbar / Fehler |
| `--status-idle` | `oklch(0.62 0.02 260)` | `oklch(0.60 0.02 260)` | noch nicht abgefragt |
| `--status-orphan` | `oklch(0.55 0.01 260)` | `oklch(0.52 0.01 260)` | offline & verwaist |
| `--well` | `oklch(0.975 0.003 260)` | `oklch(0.235 0.01 260)` | eingelassene Messfelder |
Kontrast prüfen: Text in Statusfarbe auf `--card` muss ≥ 4.5:1 (Fließtext) bzw. ≥ 3:1
(große/fette Zahlen) erreichen — sonst Helligkeit anpassen und die gemessene Zahl in der
SUMMARY nennen.
## Task 1 — Statuslogik als reine Funktionen (TDD)
Neue Datei `components/proxmox-status.ts`:
- `serverHealth(server): 'ok' | 'warn' | 'down' | 'idle' | 'orphan'`
- `!server.isActive` → `orphan` (VORRANG vor allem anderen — „offline & verwaist“, auch wenn
alte Messwerte im Zwischenlager liegen)
- kein `status` oder `status.lastPolledAt === null` → `idle`
- `!status.reachable` → `down`
- erreichbar + irgendein Messwert über Warnschwelle → `warn`
- sonst `ok`
- Schwellen als EINE benannte Konstante `THRESHOLDS`: Last/Arbeitsspeicher/Datenspeicher
warn ≥ 0.80, kritisch ≥ 0.92; PBS letzte Sicherung älter als 26 h → warn; `lastVerifyState`
ungleich `ok` (und nicht null) → warn; PMG `virusCount > 0` → warn.
- `meterLevel(fraction | null): 'ok' | 'warn' | 'crit' | 'unknown'` für die Balken.
- `formatAge(epochSeconds|iso, now)` → „vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“ über
`Intl.RelativeTimeFormat('de')` bzw. next-intl — keine neue Abhängigkeit.
- Tests für jede Verzweigung inkl. `null`-Werte (unbekannt ≠ 0, nie Warnung aus `null`).
## Task 2 — Proxmox-Seite und Karte neu gestalten
**Seitenkopf:** Titel „Proxmox“ links, darunter die Beschreibung. Rechts „Jetzt aktualisieren“
(nur Admins, wie heute) als richtiger Knopf mit Kreispfeil-Symbol (dreht sich während des Laufs;
`motion-reduce:animate-none`) und „Einstellungen“ als ruhiger Textlink. Breite `max-w-5xl`.
**Gesundheitsbalken (das eine prägnante Element der Seite):** direkt unter dem Kopf ein
waagrechter Balken, 8 px hoch, voll gerundet, anteilig in Segmente je Zustand geteilt
(Reihenfolge down, warn, ok, idle, orphan; leere Zustände entfallen). Darunter eine Zeile mit
Legende: farbiger Punkt + Zahl + Wort („2 in Ordnung“, „1 nicht erreichbar“, „1 offline &
verwaist“ …, ICU-Plural). Segmente `role="img"` mit `aria-label`, das die Zusammenfassung vorliest.
Keine Zahl-in-riesig-plus-Verlauf-Heldenkachel.
**Kartenraster:** `grid gap-4 lg:grid-cols-2`. Sortierung: down, warn, ok, idle, orphan, darin
`position`. (Die Einstellungsseite behält ihre Reihenfolge.)
**Karte (Tiefe über Status, nicht Einheits-Schatten):**
- Karte `rounded-xl bg-card` mit Rand `border-border`, links eine 4 px breite Statusleiste
(absolut positioniert, volle Höhe, Farbe = Status).
- Schatten zweischichtig und im Statuston getönt, z. B.
`shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-x)]` — bei `orphan` KEIN
farbiger Schatten, stattdessen gestrichelter Rand (`border-dashed`), Inhalt
`opacity-70 saturate-50`.
- Kopfzeile: Produktsymbol (kleines inline-SVG je Typ: PVE = Server-Einschübe, PBS =
Archivkiste/Datenträger, PMG = Briefumschlag) in einem 32 px Feld mit `bg-well`, daneben Name
(fett) und darunter Produktname ausgeschrieben („Virtualisierung“, „Datensicherung“,
„Mail-Gateway“) plus Adresse klein und gedämpft. Rechts eine Statuspille: Punkt + Wort
(„In Ordnung“, „Warnung“, „Nicht erreichbar“, „Noch nicht abgefragt“, „Offline & verwaist“),
Hintergrund Statusfarbe /12, Text in Statusfarbe. Bei `ok` pulsiert der Punkt EINMAL beim
Laden nicht — keine Dauer-Animation.
- Fuß der Karte: „Letzte Abfrage vor 4 Min.“ (relativ, `title` mit exaktem Zeitpunkt).
**Messbereich je Produkt, als eingelassene Felder** (`bg-well`, `rounded-lg`,
`shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]`):
- **PVE:** oben zwei Kennzahlen nebeneinander: laufende Gäste (groß, `tabular-nums`) und
gestoppte Gäste (gedämpft), Knotenzahl klein. Darunter je Knoten ein „Einschub“: Knotenname
links, rechts zwei schmale Balken „Prozessor“ und „Arbeitsspeicher“ mit Prozentzahl
(`tabular-nums`) und bei Speicher „12,0 / 64,0 GB“ als `title`/kleine Zeile. Balkenfarbe
nach `meterLevel`. Unbekannter Wert: Balken schraffiert/leer + Text „unbekannt“, NIE 0 %.
Deutsche Zahlformate (Komma) über `Intl.NumberFormat('de-DE')`.
- **PBS:** je Datenspeicher ein Einschub: Name, Füllstandsbalken mit „1,2 / 4,0 TB“, darunter
„Letzte Sicherung vor 5 Std.“ (warn-Farbe, wenn > 26 h; „noch keine Sicherung“ gedämpft) und
Prüfstatus als kleine Pille (ok grün, sonst warn, null „unbekannt“ grau).
- **PMG:** vier Zahlfelder im 2×2-Raster: Eingehend, Ausgehend, Spam, Viren; Viren > 0 in
down-Farbe, sonst normal. `null` → „unbekannt“.
**Zustände ohne Messwerte:**
- `down`: roter Hinweisblock im Well (`bg-status-down/8`, Rand links in Statusfarbe) mit der
bekannten Fehlermeldung (`errors.*`) und darunter gedämpft „Zuletzt erreichbar: vor 2 Tagen“
bzw. nichts, wenn nie. `errorDetail` klein und gedämpft in eigener Zeile, nicht in Klammern
an den Satz gehängt.
- `idle`: ruhiger Text wie heute (Admin-/Nicht-Admin-Variante bleibt, 260923-le6).
- `orphan`: Text „Dieser Server ist deaktiviert und wird nicht mehr abgefragt.“ plus für Admins
der Hinweis, dass er in den Einstellungen wieder aktiviert werden kann (Link). Alte Messwerte
werden bei `orphan` NICHT angezeigt.
**Leer-, Lade-, Fehlerzustand der Seite:** Leerzustand als Well mit Serversymbol und Satz +
Link (Admins); Laden als zwei Skelett-Karten (`animate-pulse`, `motion-reduce:animate-none`).
Tests: bestehende ServerCard-/Seitentests anpassen (Texte/Strukturen), neue Tests für
orphan (Vorrang, keine alten Werte), Sortierung, Gesundheitsbalken-Zusammenfassung, unbekannte
Werte als „unbekannt“. Die Nur-Admin-Regeln aus 260923-le6 müssen weiter getestet sein.
## Task 3 — Dashboard-Reiter in die Kopfzeile
Heute belegt `DashboardTabs` eine eigene Zeile über dem Raster (`app/(portal)/page.tsx`), die
Kopfzeilenmitte (`components/layout/header.tsx`) zeigt nur den Text „Startseite“.
- Kopfzeile bekommt in der Mitte einen Einhängepunkt `<div id="header-center-slot">`. Auf der
Startseite (`pathname === '/'`) entfällt der Text „Startseite“; auf allen anderen Seiten bleibt
alles wie heute.
- `DashboardTabs` rendert per `createPortal` in diesen Einhängepunkt (sobald er im DOM ist —
`useEffect` + State; Rückfall: solange kein Einhängepunkt da ist, nichts rendern). Die eigene
Zeile über dem Raster entfällt ersatzlos.
- Gestaltung als kompakter Umschalter mit Tiefe: eine eingelassene Spur (`bg-muted`, `rounded-lg`,
`p-0.5`, `shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)]`, Höhe 32 px), darin die Reiter als
Textknöpfe (`text-sm`, `px-3`, `h-7`); der aktive Reiter liegt erhaben darauf (`bg-card`,
`shadow-sm`, `font-medium`, Text `foreground`), inaktive gedämpft mit Hover. Keine
Unterstreichung, kein Gelb-Flächen-Reiter.
- Viele Reiter: Spur höchstens `max-w-[min(56vw,720px)]`, waagrecht scrollbar ohne sichtbare
Leiste, an den Rändern weich ausgeblendet (`mask-image` nur wenn überläuft), aktiver Reiter
wird ins Bild gescrollt.
- Bearbeitungsmodus: Umbenennen/Löschen/Ziehen/Anlegen bleiben funktional gleich (alle
bestehenden Tests grün halten bzw. nur Selektoren anpassen). „+“ als 28-px-Rundknopf am Ende
der Spur (immer sichtbar, wie heute der Anlegen-Knopf sichtbar ist — Verhalten beibehalten).
Löschen-Kreuz erscheint im Bearbeitungsmodus klein im Reiter.
- Genau EIN Reiter: Umschalter trotzdem zeigen (sonst weiß niemand, dass es Reiter gibt), aber
nur mit „+“ daneben.
- Mobil (< md): die Spur darf die Kopfzeilenmitte füllen; Logo-Schriftzug bleibt, Aktionen rechts
bleiben erreichbar — nichts darf die Kopfzeile sprengen (overflow prüfen).
- Tastatur: Pfeiltasten links/rechts zwischen Reitern (`role="tablist"`/`tab`, `aria-selected`),
sichtbarer Fokusring.
- `navigation "Dashboard-Reiter"`-Beschriftung bleibt als `aria-label`.
Tests: dashboard-tabs.test.tsx an Portal anpassen (Einhängepunkt im Test anlegen), neuer Test:
Kopfzeile zeigt „Startseite“ nicht auf `/`, aber auf anderen Pfaden; Pfeiltasten.
## Tore
- `pnpm --filter web exec vitest run` komplett grün
- `pnpm turbo run type-check lint` grün, Biome-Warnungen web nicht mehr als 53
- CHANGELOG „Unveröffentlicht“: je ein Eintrag unter „Neu“/„Geändert“ in Nutzersprache
- Browser-Nachweis macht der Orchestrator (hell UND dunkel, 1400 px und 390 px Breite)
@@ -0,0 +1,172 @@
---
quick_id: 260924-h7x
phase: quick
plan: 260924-h7x
subsystem: web / proxmox-modul, dashboard, kopfzeile
status: complete
tags: [proxmox, design, statusfarben, dashboard-reiter, kopfzeile, a11y]
requires: [quick-260923-dhh (Proxmox-Modul), quick-260923-le6 (Nur-Admin-Regeln), quick-260923-ad9 (Dashboard-Reiter)]
provides:
- Statuslogik proxmox-status.ts (serverHealth, THRESHOLDS, meterLevel, formatAge, Sortierung, Zusammenfassung)
- Statusfarben-Tokens --status-* / --status-*-fg / --well in globals.css
- Einhaengepunkt header-center-slot in der Kopfzeile
affects:
- apps/web/src/app/(portal)/modules/proxmox/*
- apps/web/src/components/dashboard/dashboard-tabs.tsx
- apps/web/src/components/layout/header.tsx
tech-stack:
added: []
patterns:
- Statusklassen als statische Tailwind-Zeichenketten (status-styles.ts), damit Tailwind sie findet
- Tests mit echtem NextIntlClientProvider + de.json statt Uebersetzungs-Attrappe (ICU-Plural, Zahlformate mitgeprueft)
- createPortal in einen Kopfzeilen-Einhaengepunkt, Rueckfall = nichts rendern
key-files:
created:
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.test.ts
- apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/status-styles.ts
- apps/web/src/components/layout/header-slot.ts
modified:
- apps/web/src/app/globals.css
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
- apps/web/src/components/dashboard/dashboard-tabs.tsx
- apps/web/src/components/dashboard/dashboard-tabs.test.tsx
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/layout/header.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
decisions:
- Statusfarben bekommen eine Textvariante --status-*-fg; die Flaechentoene aus dem Plan bleiben fuer Balken, Punkte und Leisten, erreichen als Schrift auf Weiss aber nur 2,6 bis 4,9:1
- Bei „offline & verwaist“ werden nur Symbol und Name gedaempft; gedaempfte muted-Schrift fiele unter 3:1
- Der „+“-Knopf der Reiter bleibt wie bisher nur im Bearbeitungsmodus sichtbar, sitzt aber ausserhalb der scrollenden Spur („immer sichtbar“ = nie weggescrollt)
- Pfeiltasten verschieben nur den Fokus, gewaehlt wird mit Eingabe/Leertaste (ein Reiterwechsel laedt das ganze Dashboard)
- Ziehhinweis als sr-only-Beschreibung der Spur plus Tooltip statt eigener Zeile
metrics:
duration: 16min
completed: 2026-09-24
tasks: 3
files: 19
plan_head_before: 3d266418fc83f4b0e5f5240f0fcd5017aebe0d10
actuals:
tokens: 35900
tasks: 3
commits: 4
---
# Quick 260924-h7x: Proxmox-Seite mit Status-Design, Dashboard-Reiter in der Kopfzeile
Die Proxmox-Seite zeigt den Zustand jetzt über Farbe und Tiefe: oben ein Gesundheitsbalken mit Legende, darunter Karten, sortiert nach Zustand, mit Statusleiste, im Statuston getöntem Schatten und Statuspille. Die Messwerte liegen in eingelassenen Feldern mit Balken, relativen Zeitangaben und deutschem Zahlformat. Deaktivierte Server erscheinen als „Offline & verwaist“, ohne veraltete Messwerte. Die Dashboard-Reiter sitzen als kompakter Umschalter per Portal in der Mitte der Kopfzeile.
## Was gebaut wurde
**Task 1 — Statuslogik (TDD, 0fa7ce0).** `proxmox-status.ts` enthält nur reine Funktionen:
- `serverHealth`: `orphan` hat Vorrang vor allem anderen, dann `idle`, `down`, `warn`, `ok`.
- `THRESHOLDS` ist die einzige Quelle für die Schwellen: 0,80 Warnung, 0,92 kritisch, 26 h Sicherungsalter.
- `meterLevel`, `ratio`, `isBackupStale`, `sortServersByHealth`, `summarizeHealth`.
- `formatAge` nutzt `Intl.RelativeTimeFormat` und liefert „vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“ sowie „jetzt“ unter einer Minute.
Ein `null` löst nirgends eine Warnung aus. 24 Tests. Die RED-Phase war belegt, weil das Modul fehlte.
**Task 2 — Seite und Karte (57c338f, Nachbesserung 0b659d6).**
- **Tokens:** `--status-ok/warn/down/idle/orphan` und `--well` nach der Tabelle im Plan, jeweils hell und dunkel. Dazu kommt eine Textvariante `--status-*-fg` (Begründung unter Kontrast). Alle Tokens sind in `@theme inline` abgebildet. Mit einem Tailwind-Probelauf ist nachgewiesen, dass `bg-status-ok/12`, `bg-status-down/8`, `bg-well`, die getönten Schatten und `motion-reduce:animate-none` wirklich erzeugt werden.
- **Kopf:** Titel und Beschreibung stehen links. Rechts steht „Einstellungen“ als ruhiger Textlink, danach „Jetzt aktualisieren“ als Hauptknopf mit Kreispfeil. Der Kreispfeil dreht sich während des Laufs, bei reduzierter Bewegung nicht. Beides ist nur für Admins sichtbar. Die Seitenbreite ist `max-w-5xl`.
- **Gesundheitsbalken:** 8 px hoch, anteilig geteilt in der Reihenfolge down, warn, ok, idle, orphan. Leere Zustände entfallen. Der Balken ist `role="img"` und liest die Zusammenfassung vor, zum Beispiel „Zustand der Server: 1 nicht erreichbar, 2 in Ordnung“. Die Legende zeigt Punkt, fette Zahl und Wort über ICU-Plural.
- **Karte:** Statusleiste links, 4 px breit. Zweischichtiger, im Statuston getönter Schatten; bei `orphan` gibt es keinen farbigen Schatten, sondern einen gestrichelten Rand. Das Produktsymbol steht in einem 32-px-Well (Server-Einschübe, Archivkiste, Briefumschlag). Darunter stehen Name, ausgeschriebener Produktname und Adresse. Die Statuspille hat keine Animation. Im Fuß steht „Letzte Abfrage vor 4 Min.“, der exakte Zeitpunkt liegt im `title`.
- **PVE:** Laufende Gäste groß, gestoppte gedämpft, dazu die Knotenzahl. Je Knoten ein Einschub mit Balken für Prozessor und Arbeitsspeicher und „12,0 / 64,0 GB“. Unbekannte Werte erscheinen als schraffierte Spur mit „unbekannt“, nie als 0 %.
- **PBS:** Je Datenspeicher der Füllstand mit „1,2 / 4,0 TB“ und „Letzte Sicherung vor 5 Std.“; bei mehr als 26 h in Warnfarbe. Dazu eine Prüfpille: grün bei ok, Warnfarbe bei anderem Ergebnis, grau bei „unbekannt“.
- **PMG:** Ein 2×2-Raster aus Zahlfeldern. Viren über 0 stehen in der Fehlerfarbe.
- **Zustände:**
- `down`: Hinweisblock mit linkem Rand. Die Fehlermeldung steht im Klartext, `errorDetail` in einer eigenen Zeile, darunter „Zuletzt erreichbar vor 2 Tagen“.
- `idle`: Admin- und Nicht-Admin-Text wie bisher.
- `orphan`: Hinweissatz; Admins bekommen zusätzlich einen Verweis auf die Einstellungen.
- Laden: zwei Skelettkarten.
- Keine Server: Well mit Symbol.
- **Aktuelle Zeitangaben:** Ein Minutentakt hält die relativen Zeitangaben aktuell.
**Task 3 — Reiter in der Kopfzeile (7416a92).**
- **Einhängepunkt:** Die Kopfzeile rendert einen leeren `#header-center-slot`. Die Kennung steht in `components/layout/header-slot.ts`. Auf `/` entfällt „Startseite“, auf allen anderen Seiten bleibt alles wie bisher. Die Kopfzeilenmitte ist `min-w-0`, Logo und Aktionen rechts sind `shrink-0`.
- **Portal:** `DashboardTabs` rendert per `createPortal` in den Einhängepunkt. Ohne Einhängepunkt rendert die Leiste nichts. Die eigene Zeile über dem Raster ist entfallen.
- **Gestaltung:** Eine eingelassene Spur (`bg-muted`, inset-Schatten, 32 px), darauf liegt der aktive Reiter erhaben (`bg-card`, `shadow-sm`, `font-medium`).
- **Überlauf:** Die Spur ist `max-w-[min(56vw,720px)]` breit, auf Mobilgeräten volle Breite. Sie scrollt ohne sichtbare Leiste. Die Ränder werden über `mask-image` weich ausgeblendet, aber nur bei Überlauf. Der aktive Reiter wird ins Bild gescrollt.
- **Tastatur:** `role="tablist"`/`tab` mit `aria-selected` und wanderndem `tabIndex`. Pfeil links/rechts, Pos1 und Ende bewegen den Fokus, mit Umlauf. Der Fokusring ist sichtbar.
- **Bearbeitungsmodus:** Der „+“-Knopf ist ein 28-px-Rundknopf außerhalb der Spur. Umbenennen und Löschen sitzen klein im Reiter. Der Löschdialog hängt am Dokumentkörper statt im Stapelkontext der Kopfzeile. Die Beschriftung `navigation "Dashboard-Reiter"` bleibt.
**CHANGELOG** unter „Unveröffentlicht“: ein Eintrag unter „Neu“ (Proxmox-Seite) und ein neuer Abschnitt „Geändert“ (Reiter in der Kopfzeile).
## Kontrast (gemessen, WCAG-Formel, OKLCH → sRGB)
Die Flächentöne aus dem Plan erreichen als Schrift auf `--card` (hell) nur diese Werte:
| Token | hell | dunkel |
|---|---|---|
| ok | 3,41 | 6,46 |
| warn | 2,55 | 7,92 |
| down | 4,76 | 4,80 |
| idle | 3,64 | 3,82 |
| orphan | 4,85 | 2,74 |
Nach der Planregel wurde deshalb die Helligkeit angepasst, und zwar als eigene Textvariante `--status-*-fg`. Die Flächentöne bleiben wie geplant.
| Textvariante | hell auf card | hell auf Pille (12 %) | dunkel auf card | dunkel auf Pille |
|---|---|---|---|---|
| ok `0.50 0.13 152` / `0.76 0.15 152` | 5,64 | 4,94 | 7,44 | 6,02 |
| warn `0.52 0.12 60` / `0.82 0.14 75` | 5,72 | 5,15 | 8,47 | 6,63 |
| down `0.52 0.20 27` / `0.74 0.16 27` | 6,11 | 5,10 | 6,09 | 5,24 |
| idle `0.50 0.02 260` / `0.72 0.02 260` | 6,00 | 5,28 | 6,08 | 5,24 |
| orphan `0.50 0.01 260` / `0.70 0.01 260` | 6,00 | 5,17 | 5,64 | 5,06 |
Alle Werte liegen bei mindestens 4,5:1. Gedämpfte Schrift auf verwaisten Karten (`opacity-70`) hätte bei `muted-foreground` nur 2,75:1 (hell) bzw. 3,02:1 (dunkel). Deshalb wird dort nur der Name in Vordergrundfarbe gedämpft (7,54 bzw. 7,13).
Die Fläche `warn` hat im hellen Modus als Grafik 2,55:1 zu Weiß. Balken und Punkte in Warnfarbe stehen aber immer neben einer Zahl oder einem Wort, die Farbe trägt die Aussage also nicht allein.
## Abweichungen vom Plan
**1. [Rule 2 – Kontrast] Textvariante der Statusfarben.** Der Plan erlaubt ausdrücklich, die Helligkeit anzupassen. Umgesetzt ist das als zusätzliches `-fg`-Token und nicht als Änderung der Flächentöne, damit Balken und Leisten die geplante Leuchtkraft behalten. Betrifft `globals.css` und `status-styles.ts`.
**2. [Rule 2 – Kontrast] Dämpfung bei „verwaist“ eingegrenzt.** Der Plan sieht `Inhalt opacity-70 saturate-50` vor. Umgesetzt ist das nur an Symbol und Name. Pille, Produktname, Adresse, Hinweis und Fuß bleiben voll lesbar, weil gedämpfte Schrift unter 3:1 fiele. Commit 0b659d6.
**3. Auslegung „+“ „immer sichtbar“.** Der Knopf bleibt wie bisher nur im Bearbeitungsmodus sichtbar, so verlangt es „Verhalten beibehalten“ und bestehender Test 4. Er sitzt jedoch außerhalb der scrollenden Spur, damit er nie weggescrollt wird.
**4. Zusätzliche Dateien:**
- `HealthBar.tsx` und `status-styles.ts` (Proxmox): die Klassen werden an zwei Stellen gebraucht.
- `components/layout/header-slot.ts`: die Kopfzeile soll nicht die Reiter-Komponente importieren.
- `umlaut-dictionary.ts`: „Prozessor“ und „Arbeitsspeicher“ sind korrektes Deutsch mit „ss“ und stehen jetzt auf der Freigabeliste der Umlaut-Wache.
**5. Übersetzungsschlüssel.** Nicht mehr genutzte Schlüssel wurden ersetzt:
- aus `card.pve.*`: `nodeCount`, `guests`
- aus `card.pbs.*`: `lastBackup`, `verifyState`
- aus `card.*`: `lastPolledLabel`, `lastOkLabel`
Neu sind `health.*`, `legend.*`, `card.product.*` und weitere. Die Änderungen stehen in de.json und en.json gleichermaßen.
**6. Formatierung.** `biome format` wurde auf die geänderten Proxmox-Dateien angewendet. Dadurch ist auch `proxmox-status.ts` aus Task 1 in Commit 57c338f rein umformatiert.
## Tore
- `pnpm --filter web exec vitest run`: 87 Dateien, 789 Tests grün. Proxmox allein: 65.
- `pnpm turbo run type-check lint`: 9/9 erfolgreich.
- Biome-Warnungen web: 53, nicht mehr als vorher (53).
- Verbotene Muster: kein `uppercase`, kein `tracking-widest`, keine Mittelpunkt-Ketten und kein „→“ in den neuen Dateien (per grep geprüft).
- Den Browser-Nachweis (hell/dunkel, 1400 px/390 px) macht wie vereinbart der Orchestrator. Docker wurde nicht neu gebaut.
## Hinweise für die Browser-Prüfung
- **Mobile Kopfzeile bei 390 px:** Rechnerisch bleiben für die Mitte etwa 80 px, also etwa ein Reiter sichtbar, der Rest ist scrollbar. Im Bearbeitungsmodus kommen 28 px für „+“ dazu. Bitte prüfen, dass nichts überläuft.
- **Ränder der Spur:** Die weiche Ausblendung erscheint nur bei Überlauf. Mit drei oder mehr langen Reiternamen kann man das prüfen.
- **Verwaiste Karte:** gestrichelter Rand, graue Leiste, kein farbiger Schatten, keine alten Messwerte.
## Known Stubs
Keine.
## Self-Check: PASSED
- FOUND: proxmox-status.ts, proxmox-status.test.ts, HealthBar.tsx, status-styles.ts, header-slot.ts
- FOUND: 0fa7ce0, 57c338f, 7416a92, 0b659d6
+5
View File
@@ -9,6 +9,11 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
- Neues Modul „Proxmox“: zeigt Zustand und Auslastung Ihrer Proxmox-Server (Virtualisierung, Datensicherung und Mail-Gateway) – nur lesend, Tessera ändert dort nichts. Die Server trägt ein Administrator in den Einstellungen ein.
- Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig
- Proxmox-Seite neu gestaltet: ein farbiger Balken oben zeigt auf einen Blick, wie viele Server in Ordnung, mit Warnung, nicht erreichbar, noch nicht abgefragt oder offline sind; jede Karte trägt ihren Zustand in Farbe und als Wort, auffällige Server stehen vorn; Auslastung als Balken mit Prozentzahl (ab 80 % Warnung), letzte Sicherung und letzte Abfrage als „vor 5 Std.“; ein deaktivierter Server erscheint als „Offline & verwaist“ ohne veraltete Messwerte
### Geändert
- Dashboard: die Reiter sitzen jetzt als kompakter Umschalter in der Mitte der Kopfzeile statt in einer eigenen Zeile über den Kacheln – das Dashboard gewinnt dadurch Platz nach oben; viele Reiter lassen sich waagrecht durchblättern, mit den Pfeiltasten wechseln Sie zwischen ihnen; Anlegen, Umbenennen, Löschen und Umsortieren per Ziehen funktionieren wie bisher
### Behoben
@@ -0,0 +1,58 @@
'use client';
import { useTranslations } from 'next-intl';
import { HEALTH_ORDER, type ServerHealth } from './proxmox-status';
import { HEALTH_STYLE } from './status-styles';
interface HealthBarProps {
counts: Record<ServerHealth, number>;
}
/**
* Gesundheitsbalken (quick-260924-h7x) — das eine praegnante Element der
* Seite: ein 8 px hoher, voll gerundeter Balken, anteilig je Zustand geteilt
* (down, warn, ok, idle, orphan; leere Zustaende entfallen), darunter die
* Legende „Punkt + Zahl + Wort“. Der Balken liest seine Zusammenfassung als
* `role="img"` vor.
*/
export function HealthBar({ counts }: HealthBarProps) {
const t = useTranslations('proxmox');
const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({
health,
count: counts[health],
word: t(`legend.${health}`, { count: counts[health] }),
}));
if (entries.length === 0) return null;
const summary = entries.map((e) => `${e.count} ${e.word}`).join(', ');
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
key={e.health}
data-segment={e.health}
className={`h-full min-w-2 first:rounded-l-full last:rounded-r-full ${HEALTH_STYLE[e.health].fill}`}
style={{ flexGrow: e.count, flexBasis: 0 }}
/>
))}
</div>
<ul className="flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-hidden="true">
{entries.map((e) => (
<li key={e.health} className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${HEALTH_STYLE[e.health].fill}`} />
<span>
<span className="font-semibold tabular-nums">{e.count}</span>{' '}
<span className="text-muted-foreground">{e.word}</span>
</span>
</li>
))}
</ul>
</div>
);
}
@@ -1,51 +1,56 @@
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, render, screen, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProxmoxServer } from '@/lib/proxmox-api';
import type { ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
import de from '@/messages/de.json';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
'card.unknownValue': 'unbekannt',
'card.lastPolledLabel': 'Letzte Abfrage',
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
'card.notPolledYetAutomatic':
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
'card.refresh': 'Jetzt aktualisieren',
'card.lastOkLabel': 'Letzte erfolgreiche Messung',
'card.pve.nodeCount': 'Knoten',
'card.pve.guests': '{running} laufend / {stopped} gestoppt',
'card.pve.cpu': 'Prozessorlast',
'card.pve.mem': 'Speicher',
'card.pbs.used': 'Belegt',
'card.pbs.lastBackup': 'Letzte Sicherung',
'card.pbs.noBackupYet': 'noch keine Sicherung',
'card.pbs.verifyState': 'Letzte Prüfung',
'card.pmg.countIn': 'Eingehend',
'card.pmg.countOut': 'Ausgehend',
'card.pmg.spamCount': 'Spam',
'card.pmg.virusCount': 'Viren',
'errors.netz': 'Der Server ist nicht erreichbar.',
'errors.zugang': 'Der Zugang wurde abgelehnt.',
'errors.rechte': 'Die Rechte reichen nicht aus.',
'errors.zertifikat': 'Das Zertifikat wurde abgelehnt.',
'errors.antwortform': 'Unerwartete Antwortform.',
'errors.server': 'Serverfehler.',
'errors.unbekannt': 'Unerwarteter Fehler.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, String(v));
}
}
return result;
},
/**
* ServerCard (quick-260924-h7x) — mit dem ECHTEN next-intl-Provider und den
* echten deutschen Texten, damit ICU-Plural und Zahlformate mitgeprueft
* werden.
*/
vi.mock('next/link', () => ({
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
afterEach(() => {
cleanup();
});
/** Fester Bezugszeitpunkt: 24.09.2026, 12:00 UTC. */
const NOW = Date.parse('2026-09-24T12:00:00.000Z');
const NOW_S = NOW / 1000;
function renderDe(ui: ReactElement) {
return render(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
function makeStatus(overrides: Partial<ProxmoxServerStatus> = {}): ProxmoxServerStatus {
return {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-24T11:56:00.000Z',
lastOkAt: '2026-09-24T11:56:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-24T11:56:00.000Z',
metrics: null,
...overrides,
};
}
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
return {
id: 'srv-1',
@@ -67,290 +72,397 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
};
}
describe('ServerCard', () => {
it('PVE-Server zeigt Knotenzahl, laufende/gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung', async () => {
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
async function card(server: ProxmoxServer, isAdmin?: boolean) {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: '2026-09-23T10:00:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />);
return screen.getByTestId('server-card');
}
describe('ServerCard — Kopf und Status', () => {
it('zeigt Name, ausgeschriebenen Produktnamen und die Adresse unveraendert (keine Grossschrift)', async () => {
const el = await card(makeServer({ baseUrl: 'https://172.21.0.1:8006', status: makeStatus() }));
expect(within(el).getByText('pve-1')).toBeInTheDocument();
expect(within(el).getByText('Virtualisierung')).toBeInTheDocument();
const address = within(el).getByText('https://172.21.0.1:8006');
expect(address).not.toHaveClass('uppercase');
expect(el.querySelector('.uppercase')).toBeNull();
});
it('PBS und PMG tragen ihre ausgeschriebenen Produktnamen', async () => {
await card(makeServer({ id: 'b', productType: 'pbs', status: makeStatus() }));
expect(screen.getByText('Datensicherung')).toBeInTheDocument();
cleanup();
await card(makeServer({ id: 'm', productType: 'pmg', status: makeStatus() }));
expect(screen.getByText('Mail-Gateway')).toBeInTheDocument();
});
it('erreichbar ohne Auffaelligkeiten: Pille „In Ordnung“, data-health ok', async () => {
const el = await card(makeServer({ status: makeStatus() }));
expect(el).toHaveAttribute('data-health', 'ok');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('In Ordnung');
});
it('Fuss: „Letzte Abfrage vor 4 Min.“ relativ, exakter Zeitpunkt im title', async () => {
const el = await card(makeServer({ status: makeStatus() }));
const footer = within(el).getByTestId('last-polled');
expect(footer).toHaveTextContent('Letzte Abfrage vor 4 Min.');
expect(footer.getAttribute('title')).toBeTruthy();
});
});
describe('ServerCard — PVE', () => {
it('laufende und gestoppte Gaeste, Knotenzahl, je Knoten Prozessor und Arbeitsspeicher mit deutschem Zahlformat', async () => {
const el = await card(
makeServer({
status: makeStatus({
metrics: {
productType: 'pve',
nodeCount: 2,
guestsRunning: 3,
guestsStopped: 1,
nodes: [
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_294_967_296, maxmem: 8_589_934_592 },
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 12 * 1024 ** 3, maxmem: 64 * 1024 ** 3 },
],
storages: [],
},
},
}),
}),
);
expect(within(el).getByTestId('guests-running')).toHaveTextContent('3');
expect(within(el).getByText('Gäste laufen')).toBeInTheDocument();
expect(within(el).getByTestId('guests-stopped')).toHaveTextContent('1');
expect(within(el).getByText('2 Knoten')).toBeInTheDocument();
const node = within(el).getByTestId('pve-node-pve1');
const [cpu, mem] = within(node).getAllByTestId('meter');
expect(cpu).toHaveTextContent('Prozessor');
expect(cpu).toHaveTextContent(/25\s%/);
expect(cpu).toHaveAttribute('data-level', 'ok');
expect(mem).toHaveTextContent('Arbeitsspeicher');
expect(mem).toHaveTextContent(/19\s%/);
expect(mem).toHaveTextContent('12,0 / 64,0 GB');
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Knoten:/)).toHaveTextContent('Knoten: 2');
expect(screen.getByText(/laufend/)).toHaveTextContent('3 laufend / 1 gestoppt');
expect(screen.getByText(/Prozessorlast/)).toHaveTextContent('Prozessorlast: 25%');
expect(screen.getByText(/Speicher:/)).toHaveTextContent('4.0 GB / 8.0 GB');
});
it('PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
productType: 'pbs',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: '2026-09-23T10:00:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
it('Auslastung ab 80 % faerbt den Balken als Warnung, ab 92 % kritisch, und die Karte wird „Warnung“', async () => {
const el = await card(
makeServer({
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{ name: 'backup-store', total: 1000, used: 400, free: 600, lastBackupAt: 1758000000, lastVerifyState: 'ok' },
],
},
productType: 'pve',
nodeCount: 1,
guestsRunning: 1,
guestsStopped: 0,
nodes: [{ node: 'pve1', cpu: 0.85, maxcpu: 8, mem: 95, maxmem: 100 }],
storages: [],
},
}),
}),
);
expect(el).toHaveAttribute('data-health', 'warn');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Warnung');
const [cpu, mem] = within(el).getAllByTestId('meter');
expect(cpu).toHaveAttribute('data-level', 'warn');
expect(mem).toHaveAttribute('data-level', 'crit');
});
render(<ServerCard server={server} />);
expect(screen.getByText('backup-store')).toBeInTheDocument();
expect(screen.getByText(/Belegt:/)).toHaveTextContent('400 B / 1000 B');
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('ok');
});
it('PBS-Datenspeicher ohne Sicherung zeigt "noch keine Sicherung"', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
productType: 'pbs',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: null,
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
it('unbekannte Knotenwerte erscheinen als „unbekannt“ — nie 0 %, nie NaN', async () => {
const el = await card(
makeServer({
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{ name: 'leer', total: 10, used: 0, free: 10, lastBackupAt: null, lastVerifyState: null },
],
productType: 'pve',
nodeCount: 1,
guestsRunning: 0,
guestsStopped: 0,
nodes: [{ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null }],
storages: [],
},
},
});
}),
}),
);
render(<ServerCard server={server} />);
expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung');
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt');
});
it('PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
productType: 'pmg',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: '2026-09-23T10:00:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: { productType: 'pmg', countIn: 120, countOut: 45, spamCount: 12, virusCount: 0 },
},
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('120');
expect(screen.getByText(/Ausgehend:/)).toHaveTextContent('45');
expect(screen.getByText(/Spam:/)).toHaveTextContent('12');
expect(screen.getByText(/Viren:/)).toHaveTextContent('0');
});
it('ein Messwert, der null ist, erscheint als "unbekannt" — nie als 0, leer oder NaN', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
productType: 'pmg',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: null,
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null },
},
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('unbekannt');
expect(screen.queryByText(/Eingehend: 0/)).not.toBeInTheDocument();
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
});
it('ein Server mit reachable:false zeigt den Klartext der Ursache und den Zeitpunkt der letzten erfolgreichen Messung', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:05:00.000Z',
lastOkAt: '2026-09-23T09:00:00.000Z',
reachable: false,
errorKind: 'zugang',
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:05:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} />);
expect(screen.getByText('Der Zugang wurde abgelehnt.')).toBeInTheDocument();
expect(screen.getByText(/Letzte erfolgreiche Messung/)).toBeInTheDocument();
});
it('der Zeitpunkt der letzten Abfrage steht bei jedem Server', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: null,
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Letzte Abfrage/)).toBeInTheDocument();
});
it('Nachbesserung Befund 2: ein frisch angelegter, noch nie abgefragter Server zeigt Admins den ruhigen Hinweis mit Knopfverweis statt "Ein unerwarteter Fehler ist aufgetreten"', async () => {
const { ServerCard } = await import('./ServerCard');
// Zustand direkt nach `createServer`: leere Zwischenlagerzeile, noch nie abgefragt.
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: null,
lastOkAt: null,
reachable: false,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} isAdmin />);
expect(
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
).toBeInTheDocument();
// Ohne die Korrektur erschiene hier faelschlich die Sammelmeldung — roter Test.
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
});
it('260923-le6: isAdmin={false}, noch nie abgefragt -> automatischer Hinweis ohne Knopfverweis', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: null,
lastOkAt: null,
reachable: false,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} isAdmin={false} />);
expect(
screen.getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
});
it('260923-le6: isAdmin weggelassen, noch nie abgefragt -> verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: null,
lastOkAt: null,
reachable: false,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} />);
expect(
screen.getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
});
it('Nachbesserung Befund 3: die Adresse bleibt unveraendert dargestellt, nur das Produktkuerzel ist grossgeschrieben', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({ baseUrl: 'https://172.21.0.1:8006' });
render(<ServerCard server={server} />);
const line = screen.getByText(/172\.21\.0\.1:8006/);
expect(line).toHaveTextContent('pve — https://172.21.0.1:8006');
const productSpan = line.querySelector('span');
expect(productSpan).not.toBeNull();
expect(productSpan).toHaveClass('uppercase');
expect(line).not.toHaveClass('uppercase');
const [cpu, mem] = within(el).getAllByTestId('meter');
expect(cpu).toHaveTextContent('unbekannt');
expect(mem).toHaveTextContent('unbekannt');
expect(cpu).toHaveAttribute('data-level', 'unknown');
expect(el).not.toHaveTextContent(/0\s%/);
expect(el).not.toHaveTextContent('NaN');
expect(el).toHaveAttribute('data-health', 'ok');
});
});
describe('ServerCard — PBS', () => {
it('je Datenspeicher Fuellstand, letzte Sicherung relativ und Pruefpille', async () => {
const el = await card(
makeServer({
productType: 'pbs',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{
name: 'backup-store',
total: 4 * 1024 ** 4,
used: 1.2 * 1024 ** 4,
free: 2.8 * 1024 ** 4,
lastBackupAt: NOW_S - 5 * 3600,
lastVerifyState: 'ok',
},
],
},
}),
}),
);
const ds = within(el).getByTestId('pbs-datastore-backup-store');
expect(within(ds).getByText('backup-store')).toBeInTheDocument();
expect(within(ds).getByTestId('meter')).toHaveTextContent('1,2 / 4,0 TB');
expect(within(ds).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 5 Std.');
expect(within(ds).getByTestId('verify-pill')).toHaveTextContent('Prüfung in Ordnung');
expect(el).toHaveAttribute('data-health', 'ok');
});
it('Sicherung aelter als 26 h und fehlgeschlagene Pruefung -> Warnung', async () => {
const el = await card(
makeServer({
productType: 'pbs',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{
name: 'alt',
total: 100,
used: 10,
free: 90,
lastBackupAt: NOW_S - 30 * 3600,
lastVerifyState: 'failed',
},
],
},
}),
}),
);
expect(el).toHaveAttribute('data-health', 'warn');
expect(within(el).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 1 Tag');
expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung fehlgeschlagen');
expect(within(el).getByTestId('verify-pill')).toHaveAttribute('title', 'failed');
});
it('ohne Sicherung „noch keine Sicherung“, Pruefung null „Prüfung unbekannt“, keine Warnung', async () => {
const el = await card(
makeServer({
productType: 'pbs',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{
name: 'leer',
total: 10,
used: 0,
free: 10,
lastBackupAt: null,
lastVerifyState: null,
},
],
},
}),
}),
);
expect(within(el).getByText('noch keine Sicherung')).toBeInTheDocument();
expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung unbekannt');
expect(el).toHaveAttribute('data-health', 'ok');
});
});
describe('ServerCard — PMG', () => {
it('vier Zahlfelder: eingehend, ausgehend, Spam, Viren', async () => {
const el = await card(
makeServer({
productType: 'pmg',
status: makeStatus({
metrics: {
productType: 'pmg',
countIn: 1200,
countOut: 45,
spamCount: 12,
virusCount: 0,
},
}),
}),
);
expect(within(el).getByTestId('pmg-in')).toHaveTextContent('Eingehend1.200');
expect(within(el).getByTestId('pmg-out')).toHaveTextContent('Ausgehend45');
expect(within(el).getByTestId('pmg-spam')).toHaveTextContent('Spam12');
expect(within(el).getByTestId('pmg-virus')).toHaveTextContent('Viren0');
expect(el).toHaveAttribute('data-health', 'ok');
});
it('Viren > 0 erscheinen in der Fehlerfarbe und machen die Karte zur Warnung', async () => {
const el = await card(
makeServer({
productType: 'pmg',
status: makeStatus({
metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 1, virusCount: 3 },
}),
}),
);
expect(el).toHaveAttribute('data-health', 'warn');
expect(within(within(el).getByTestId('pmg-virus')).getByText('3')).toHaveClass(
'text-status-down-fg',
);
});
it('null-Werte erscheinen als „unbekannt“ — nie 0', async () => {
const el = await card(
makeServer({
productType: 'pmg',
status: makeStatus({
metrics: {
productType: 'pmg',
countIn: null,
countOut: null,
spamCount: null,
virusCount: null,
},
}),
}),
);
expect(within(el).getByTestId('pmg-in')).toHaveTextContent('Eingehendunbekannt');
expect(within(el).getByTestId('pmg-virus')).toHaveTextContent('Virenunbekannt');
expect(el).not.toHaveTextContent('NaN');
});
});
describe('ServerCard — nicht erreichbar', () => {
it('Klartext der Ursache, Detail in eigener Zeile (nicht in Klammern), „Zuletzt erreichbar vor 2 Tagen“', async () => {
const el = await card(
makeServer({
status: makeStatus({
lastOkAt: '2026-09-22T12:00:00.000Z',
reachable: false,
errorKind: 'zugang',
errorDetail: 'HTTP 401',
}),
}),
);
expect(el).toHaveAttribute('data-health', 'down');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Nicht erreichbar');
const notice = within(el).getByTestId('down-notice');
expect(within(notice).getByText(/Der Zugang wurde abgelehnt\./)).toBeInTheDocument();
expect(within(notice).getByText('HTTP 401')).toBeInTheDocument();
expect(notice).not.toHaveTextContent('(HTTP 401)');
expect(within(notice).getByText('Zuletzt erreichbar vor 2 Tagen')).toBeInTheDocument();
});
it('nie erreichbar gewesen: keine „Zuletzt erreichbar“-Zeile', async () => {
const el = await card(
makeServer({ status: makeStatus({ lastOkAt: null, reachable: false, errorKind: 'netz' }) }),
);
expect(within(el).queryByText(/Zuletzt erreichbar/)).not.toBeInTheDocument();
});
});
describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
it('Admin: ruhiger Hinweis mit Knopfverweis statt Fehlermeldung', async () => {
const el = await card(makeServer({ status: UNPOLLED }), true);
expect(el).toHaveAttribute('data-health', 'idle');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Noch nicht abgefragt');
expect(
within(el).getByText(
'Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.',
),
).toBeInTheDocument();
expect(
within(el).queryByText('Ein unerwarteter Fehler ist aufgetreten.'),
).not.toBeInTheDocument();
expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument();
});
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
const el = await card(makeServer({ status: UNPOLLED }), false);
expect(
within(el).getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
});
it('isAdmin weggelassen: verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
const el = await card(makeServer({ status: UNPOLLED }));
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
expect(within(el).getByText(/automatischen Abfrage/)).toBeInTheDocument();
});
});
describe('ServerCard — offline & verwaist', () => {
const staleMetrics = makeStatus({
metrics: {
productType: 'pve',
nodeCount: 1,
guestsRunning: 7,
guestsStopped: 2,
nodes: [{ node: 'altknoten', cpu: 0.99, maxcpu: 8, mem: 99, maxmem: 100 }],
storages: [],
},
});
it('deaktiviert hat Vorrang: Pille „Offline & verwaist“, gestrichelter Rand, KEINE alten Messwerte', async () => {
const el = await card(makeServer({ isActive: false, status: staleMetrics }));
expect(el).toHaveAttribute('data-health', 'orphan');
expect(el).toHaveClass('border-dashed');
// gedaempft wird nur der Name (Vordergrundfarbe haelt 7,5:1), die Adresse bleibt lesbar
expect(within(el).getByText('pve-1')).toHaveClass('opacity-70', 'saturate-50');
expect(within(el).getByText('https://pve.intern')).not.toHaveClass('opacity-70');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Offline & verwaist');
expect(
within(el).getByText('Dieser Server ist deaktiviert und wird nicht mehr abgefragt.'),
).toBeInTheDocument();
expect(within(el).queryByTestId('guests-running')).not.toBeInTheDocument();
expect(within(el).queryByText('altknoten')).not.toBeInTheDocument();
expect(within(el).queryAllByTestId('meter')).toHaveLength(0);
});
it('deaktiviert und nicht erreichbar: keine Fehlermeldung, sondern der Verwaist-Hinweis', async () => {
const el = await card(
makeServer({ isActive: false, status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
);
expect(within(el).queryByTestId('down-notice')).not.toBeInTheDocument();
expect(within(el).getByTestId('orphan-notice')).toBeInTheDocument();
});
it('Admins bekommen den Hinweis auf die Einstellungen mit Verweis, andere nicht', async () => {
const admin = await card(makeServer({ isActive: false, status: staleMetrics }), true);
expect(
within(admin).getByText('Sie können ihn in den Einstellungen wieder aktivieren.', {
exact: false,
}),
).toBeInTheDocument();
expect(within(admin).getByRole('link', { name: 'Zu den Einstellungen' })).toHaveAttribute(
'href',
'/modules/proxmox/settings',
);
cleanup();
const user = await card(makeServer({ isActive: false, status: staleMetrics }), false);
expect(within(user).queryByRole('link')).not.toBeInTheDocument();
expect(within(user).queryByText(/wieder aktivieren/)).not.toBeInTheDocument();
});
});
@@ -1,88 +1,238 @@
'use client';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
import type {
ProxmoxErrorKind,
ProxmoxPbsMetrics,
ProxmoxPmgMetrics,
ProxmoxProductType,
ProxmoxPveMetrics,
ProxmoxServer,
} 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';
/**
* Wandelt einen rohen Byte-Wert in eine lesbare Form (B/KB/MB/GB/TB).
*/
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = -1;
do {
value /= 1024;
unitIndex++;
} while (value >= 1024 && unitIndex < units.length - 1);
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
type Translator = ReturnType<typeof useTranslations>;
/** Prozentwert einer Auslastung (0..1) gerundet, z. B. `25%`. */
function formatPercent(fraction: number): string {
return `${Math.round(fraction * 100)}%`;
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
function isKnown(value: number | null | undefined): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}
/**
* Die EINZIGE Stelle, die einen Messwert in Text verwandelt (Aufgabe 6):
* ist der Wert `null`/`undefined`, erscheint der uebersetzte Text
* "unbekannt" — sonst der Wert mit seiner Einheit. Dadurch kann kein Zweig
* versehentlich eine `0` anzeigen, wo nichts gemessen wurde. Die Feldnamen
* von PBS und PMG sind bis zur Pruefung am echten Server nur abgeleitet
* (Recherche, Annahmen A2/A3/A5), und ein still falscher Wert waere
* schlimmer als ein ehrliches "unbekannt".
* „12,0 / 64,0 GB“ — beide Werte in DERSELBEN Einheit, gewaehlt nach der
* Gesamtgroesse, Zahlformat nach Sprache (deutsch mit Komma). `null`, wenn
* einer der Werte unbekannt ist — dann steht an der Stelle nichts statt
* einer erfundenen Zahl.
*/
function formatMetric(
value: number | string | null | undefined,
unknownLabel: string,
formatter?: (v: number) => string,
): string {
if (value === null || value === undefined) return unknownLabel;
if (typeof value === 'number') {
if (Number.isNaN(value)) return unknownLabel;
return formatter ? formatter(value) : String(value);
function formatBytePair(used: number | null, total: number | null, locale: string): string | null {
if (!isKnown(used) || !isKnown(total) || total <= 0) return null;
const exponent = Math.min(
BYTE_UNITS.length - 1,
Math.max(0, Math.floor(Math.log(total) / Math.log(1024))),
);
const divisor = 1024 ** exponent;
const digits = exponent === 0 ? 0 : 1;
const nf = new Intl.NumberFormat(locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${nf.format(used / divisor)} / ${nf.format(total / divisor)} ${BYTE_UNITS[exponent]}`;
}
function 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. */
function exactTime(value: number | string | null | undefined, locale: string): string | undefined {
const ms = toEpochMs(value);
if (ms === null) return undefined;
return new Date(ms).toLocaleString(locale);
}
/** Kleines Produktsymbol je Typ: Server-Einschuebe, Archivkiste, Briefumschlag. */
function ProductIcon({ productType }: { productType: ProxmoxProductType }) {
const common = {
width: 18,
height: 18,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.75,
strokeLinecap: 'round',
strokeLinejoin: 'round',
} as const;
if (productType === 'pve') {
return (
<svg aria-hidden="true" {...common}>
<rect x="3" y="4" width="18" height="7" rx="1.5" />
<rect x="3" y="13" width="18" height="7" rx="1.5" />
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
<line x1="11" y1="7.5" x2="17" y2="7.5" />
<line x1="11" y1="16.5" x2="17" y2="16.5" />
</svg>
);
}
return value;
}
function formatTimestamp(value: string | number | null | undefined, unknownLabel: string): string {
if (value === null || value === undefined) return unknownLabel;
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
if (Number.isNaN(date.getTime())) return unknownLabel;
return date.toLocaleString();
}
interface MetricsViewProps {
metrics: ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
unknown: string;
}
function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unknown: string }) {
const t = useTranslations('proxmox');
if (productType === 'pbs') {
return (
<div className="space-y-2 text-sm">
<svg aria-hidden="true" {...common}>
<rect x="3" y="4" width="18" height="4" rx="1" />
<path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8" />
<line x1="10" y1="12" x2="14" y2="12" />
</svg>
);
}
return (
<svg aria-hidden="true" {...common}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<polyline points="3 7 12 13 21 7" />
</svg>
);
}
/** Statuspille: Punkt + Wort, Flaeche in Statusfarbe mit 12 %. Keine Daueranimation. */
function StatusPill({ health, label }: { health: ServerHealth; label: string }) {
const style = HEALTH_STYLE[health];
return (
<span
data-testid="status-pill"
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium ${style.pill}`}
>
<span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${style.fill}`} />
{label}
</span>
);
}
/**
* Schmaler Balken mit Prozentzahl. Ein unbekannter Wert zeigt eine
* schraffierte Spur und das Wort „unbekannt“ — NIE 0 %.
*/
function Meter({
label,
fraction,
detail,
unknownLabel,
locale,
}: {
label: string;
fraction: number | null;
detail?: string | null;
unknownLabel: string;
locale: string;
}) {
const level = meterLevel(fraction);
const known = level !== 'unknown' && fraction !== null;
const width = known ? `${Math.min(100, Math.max(0, fraction * 100))}%` : '100%';
return (
<div data-testid="meter" data-level={level} className="min-w-0">
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={`tabular-nums font-medium ${METER_TEXT[level]}`}>
{known ? formatPercent(fraction, locale) : unknownLabel}
</span>
</div>
<div aria-hidden="true" className="mt-1 h-1.5 overflow-hidden rounded-full bg-foreground/10">
{known ? (
<div className={`h-full rounded-full ${METER_FILL[level]}`} style={{ width }} />
) : (
<div
className="h-full w-full"
style={{
backgroundImage:
'repeating-linear-gradient(135deg, var(--border) 0 4px, transparent 4px 8px)',
}}
/>
)}
</div>
{detail && (
<div className="mt-1 text-[11px] tabular-nums text-muted-foreground">{detail}</div>
)}
</div>
);
}
function PveMetricsView({
metrics,
t,
locale,
}: {
metrics: ProxmoxPveMetrics;
t: Translator;
locale: string;
}) {
const unknown = t('card.unknownValue');
return (
<div className="space-y-2">
<div className={`${WELL} flex items-end justify-between gap-4 px-4 py-3`}>
<div className="flex items-end gap-6">
<div>
{t('card.pve.nodeCount')}: {metrics.nodeCount}
<div
className="text-2xl font-semibold leading-none tabular-nums"
data-testid="guests-running"
>
{formatCount(metrics.guestsRunning, locale)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('card.pve.running', { count: metrics.guestsRunning })}
</div>
</div>
<div>
{t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })}
<div
className="text-2xl font-semibold leading-none tabular-nums text-muted-foreground"
data-testid="guests-stopped"
>
{formatCount(metrics.guestsStopped, locale)}
</div>
<div className="mt-1 text-xs text-muted-foreground">{t('card.pve.stopped')}</div>
</div>
</div>
<div className="text-xs text-muted-foreground">
{t('card.pve.nodes', { count: metrics.nodeCount })}
</div>
</div>
{metrics.nodes.map((node) => (
<div key={node.node} className="rounded border border-border/60 px-2 py-1 text-xs">
<div className="font-medium">{node.node}</div>
<div>
{t('card.pve.cpu')}: {formatMetric(node.cpu, unknown, formatPercent)}
{' · '}
{t('card.pve.mem')}: {formatMetric(node.mem, unknown, formatBytes)}
{node.maxmem !== null && node.mem !== null
? ` / ${formatMetric(node.maxmem, unknown, formatBytes)}`
: ''}
<div
key={node.node}
data-testid={`pve-node-${node.node}`}
className={`${WELL} grid grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] items-start gap-4 px-4 py-3`}
>
<div className="truncate pt-0.5 text-sm font-medium" title={node.node}>
{node.node}
</div>
<div className="grid grid-cols-2 gap-3">
<Meter
label={t('card.pve.cpu')}
fraction={isKnown(node.cpu) ? node.cpu : null}
unknownLabel={unknown}
locale={locale}
/>
<Meter
label={t('card.pve.mem')}
fraction={ratio(node.mem, node.maxmem)}
detail={formatBytePair(node.mem, node.maxmem, locale)}
unknownLabel={unknown}
locale={locale}
/>
</div>
</div>
))}
@@ -90,124 +240,258 @@ function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unkn
);
}
function PbsMetricsView({ metrics, unknown }: { metrics: ProxmoxPbsMetrics; unknown: string }) {
const t = useTranslations('proxmox');
function PbsMetricsView({
metrics,
t,
locale,
now,
}: {
metrics: ProxmoxPbsMetrics;
t: Translator;
locale: string;
now: number;
}) {
const unknown = t('card.unknownValue');
if (metrics.datastores.length === 0) {
return <p className="text-sm text-muted-foreground">{t('card.pbs.noDatastores')}</p>;
}
return (
<div className="space-y-2 text-sm">
{metrics.datastores.map((ds) => (
<div key={ds.name} className="rounded border border-border/60 px-2 py-1 text-xs">
<div className="font-medium">{ds.name}</div>
<div>
{t('card.pbs.used')}: {formatMetric(ds.used, unknown, formatBytes)}
{ds.total !== null && ds.used !== null ? ` / ${formatMetric(ds.total, unknown, formatBytes)}` : ''}
<div className="space-y-2">
{metrics.datastores.map((ds) => {
const stale = isBackupStale(ds.lastBackupAt, now);
const backupAge = formatAge(ds.lastBackupAt, now, locale);
const verify =
ds.lastVerifyState === null
? { cls: HEALTH_STYLE.idle.pill, text: t('card.pbs.verifyUnknown') }
: ds.lastVerifyState === 'ok'
? { cls: HEALTH_STYLE.ok.pill, text: t('card.pbs.verifyOk') }
: {
cls: HEALTH_STYLE.warn.pill,
text: t('card.pbs.verifyFailed', { state: ds.lastVerifyState }),
};
return (
<div
key={ds.name}
data-testid={`pbs-datastore-${ds.name}`}
className={`${WELL} space-y-2.5 px-4 py-3`}
>
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium" title={ds.name}>
{ds.name}
</span>
<span
data-testid="verify-pill"
title={ds.lastVerifyState ?? undefined}
className={`shrink-0 whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium ${verify.cls}`}
>
{verify.text}
</span>
</div>
<div>
{t('card.pbs.lastBackup')}:{' '}
{ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)}
<Meter
label={t('card.pbs.used')}
fraction={ratio(ds.used, ds.total)}
detail={formatBytePair(ds.used, ds.total, locale)}
unknownLabel={unknown}
locale={locale}
/>
{ds.lastBackupAt === null || backupAge === null ? (
<p className="text-xs text-muted-foreground">{t('card.pbs.noBackupYet')}</p>
) : (
<p
data-testid="last-backup"
className={`text-xs ${stale ? `font-medium ${HEALTH_STYLE.warn.text}` : 'text-muted-foreground'}`}
title={exactTime(ds.lastBackupAt, locale)}
>
{t('card.pbs.lastBackupAgo', { age: backupAge })}
</p>
)}
</div>
<div>
{t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)}
);
})}
</div>
);
}
function PmgMetricsView({
metrics,
t,
locale,
}: {
metrics: ProxmoxPmgMetrics;
t: Translator;
locale: string;
}) {
const unknown = t('card.unknownValue');
const tiles: Array<{ key: string; label: string; value: number | null; alarm: boolean }> = [
{ key: 'in', label: t('card.pmg.countIn'), value: metrics.countIn, alarm: false },
{ key: 'out', label: t('card.pmg.countOut'), value: metrics.countOut, alarm: false },
{ key: 'spam', label: t('card.pmg.spamCount'), value: metrics.spamCount, alarm: false },
{
key: 'virus',
label: t('card.pmg.virusCount'),
value: metrics.virusCount,
alarm: isKnown(metrics.virusCount) && metrics.virusCount > 0,
},
];
return (
<div className="grid grid-cols-2 gap-2">
{tiles.map((tile) => (
<div key={tile.key} data-testid={`pmg-${tile.key}`} className={`${WELL} px-4 py-3`}>
<div className="text-xs text-muted-foreground">{tile.label}</div>
{isKnown(tile.value) ? (
<div
className={`mt-1 text-xl font-semibold leading-tight tabular-nums ${tile.alarm ? HEALTH_STYLE.down.text : ''}`}
>
{formatCount(tile.value, locale)}
</div>
) : (
<div className="mt-1 text-sm leading-7 text-muted-foreground">{unknown}</div>
)}
</div>
))}
</div>
);
}
function PmgMetricsView({ metrics, unknown }: { metrics: ProxmoxPmgMetrics; unknown: string }) {
const t = useTranslations('proxmox');
return (
<div className="space-y-1 text-sm">
<div>
{t('card.pmg.countIn')}: {formatMetric(metrics.countIn, unknown)}
</div>
<div>
{t('card.pmg.countOut')}: {formatMetric(metrics.countOut, unknown)}
</div>
<div>
{t('card.pmg.spamCount')}: {formatMetric(metrics.spamCount, unknown)}
</div>
<div>
{t('card.pmg.virusCount')}: {formatMetric(metrics.virusCount, unknown)}
</div>
</div>
);
}
function MetricsView({ metrics, unknown }: MetricsViewProps) {
if (metrics.productType === 'pve') return <PveMetricsView metrics={metrics} unknown={unknown} />;
if (metrics.productType === 'pbs') return <PbsMetricsView metrics={metrics} unknown={unknown} />;
return <PmgMetricsView metrics={metrics} unknown={unknown} />;
}
function errorMessage(t: ReturnType<typeof useTranslations>, kind: ProxmoxErrorKind | null): string {
function errorMessage(t: Translator, kind: ProxmoxErrorKind | null): string {
return t(`errors.${kind ?? 'unbekannt'}`);
}
interface ServerCardProps {
server: ProxmoxServer;
isAdmin?: boolean;
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
now?: number;
}
/**
* Anzeige EINES Servers (Aufgabe 6) — verzweigt ueber `productType` auf der
* unterscheidbaren Union aus Aufgabe 3.
* Anzeige EINES Servers (quick-260924-h7x). Die Tiefe entsteht ueber den
* Status: Leiste links, im Statuston getoenter Schatten, Pille oben rechts.
* Messwerte liegen in eingelassenen Feldern. Ein deaktivierter Server
* („offline & verwaist“) zeigt KEINE alten Messwerte mehr — auch wenn das
* Zwischenlager noch welche hat.
*/
export function ServerCard({ server, isAdmin = false }: ServerCardProps) {
export function ServerCard({ server, isAdmin = false, now = Date.now() }: ServerCardProps) {
const t = useTranslations('proxmox');
const unknown = t('card.unknownValue');
const locale = useLocale();
const health = serverHealth(server, now);
const style = HEALTH_STYLE[health];
const status = server.status;
const isOrphan = health === 'orphan';
const polledAge = formatAge(status?.lastPolledAt ?? null, now, locale);
return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{server.name}</div>
{/* Nachbesserung Befund 3: `uppercase` gilt nur dem Produktkuerzel, nicht der Adresse. */}
<div className="text-xs text-muted-foreground">
<span className="uppercase">{server.productType}</span> — {server.baseUrl}
</div>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{t('card.lastPolledLabel')}: {formatTimestamp(status?.lastPolledAt ?? null, unknown)}
</div>
{/*
Nachbesserung Befund 2: ein Server, der noch nie abgefragt wurde
(`lastPolledAt === null`), zeigt einen ruhigen Hinweis statt der
Fehlermeldung — die leere Zwischenlagerzeile aus `createServer` hat
`reachable: false` und `errorKind: null`, was sonst faelschlich als
"unbekannter Fehler" erschien. Nicht-Admins sehen den Knopf nicht
(der Poll-Endpunkt verlangt ADMIN/SUPER_ADMIN) und bekommen deshalb
den Text ohne Knopfverweis (260923-le6).
*/}
{status && !status.lastPolledAt && (
<p className="mt-2 text-xs text-muted-foreground">
{isAdmin ? t('card.notPolledYet', { refreshLabel: t('card.refresh') }) : t('card.notPolledYetAutomatic')}
let body: React.ReactNode = null;
if (health === 'orphan') {
body = (
<div className="space-y-1 text-sm" data-testid="orphan-notice">
<p className="text-foreground">{t('card.orphanText')}</p>
{isAdmin && (
<p className="text-muted-foreground">
{t('card.orphanAdminHint')}{' '}
<Link
href="/modules/proxmox/settings"
className="font-medium text-foreground underline-offset-2 hover:underline"
>
{t('card.settingsLink')}
</Link>
</p>
)}
{status?.lastPolledAt && !status.reachable && (
<div className="mt-2 space-y-1 text-sm">
<p className="text-destructive">
{errorMessage(t, status.errorKind)}
{status.errorDetail ? ` (${status.errorDetail})` : ''}
</p>
{status.lastOkAt && (
<p className="text-xs text-muted-foreground">
{t('card.lastOkLabel')}: {formatTimestamp(status.lastOkAt, unknown)}
</p>
)}
</div>
)}
{status?.reachable && status.metrics && (
<div className="mt-3">
<MetricsView metrics={status.metrics} unknown={unknown} />
</div>
)}
</div>
);
} else if (health === 'idle') {
// Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt
// ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis
// (260923-le6).
body = (
<p className="text-sm text-muted-foreground">
{isAdmin
? t('card.notPolledYet', { refreshLabel: t('card.refresh') })
: t('card.notPolledYetAutomatic')}
</p>
);
} else if (health === 'down' && status) {
const lastOkAge = formatAge(status.lastOkAt, now, locale);
body = (
<div
data-testid="down-notice"
className="space-y-1 rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3"
>
<p className="text-sm text-foreground">{errorMessage(t, status.errorKind)}</p>
{status.errorDetail && (
<p className="break-words text-xs text-muted-foreground">{status.errorDetail}</p>
)}
{lastOkAge && (
<p className="text-xs text-muted-foreground" title={exactTime(status.lastOkAt, locale)}>
{t('card.lastOkAgo', { age: lastOkAge })}
</p>
)}
</div>
);
} else if (status?.metrics) {
const metrics = status.metrics;
body =
metrics.productType === 'pve' ? (
<PveMetricsView metrics={metrics} t={t} locale={locale} />
) : metrics.productType === 'pbs' ? (
<PbsMetricsView metrics={metrics} t={t} locale={locale} now={now} />
) : (
<PmgMetricsView metrics={metrics} t={t} locale={locale} />
);
}
// Gedaempft (`opacity-70 saturate-50`) wird bei „verwaist“ nur Symbol und
// Name. Gedaempfte Schrift in `muted-foreground` fiele auf 2,75:1 (hell)
// bzw. 3,0:1 (dunkel) — gemessen — und damit unter 4,5:1; Produktname,
// Adresse, Fussangabe, Pille und Hinweistext bleiben deshalb voll lesbar.
// Der Name in Vordergrundfarbe haelt gedaempft noch 7,5:1 bzw. 7,1:1.
const dim = isOrphan ? 'opacity-70 saturate-50' : '';
return (
<article
data-testid="server-card"
data-health={health}
aria-label={`${server.name}: ${t(`health.${health}`)}`}
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${
isOrphan ? 'border-dashed border-border' : 'border-border'
} ${style.shadow}`}
>
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
<div className="flex flex-1 flex-col gap-4 py-4 pl-5 pr-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div
className={`${WELL} flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground ${dim}`}
>
<ProductIcon productType={server.productType} />
</div>
<div className="min-w-0">
<div className={`truncate font-semibold leading-tight ${dim}`} title={server.name}>
{server.name}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t(`card.product.${server.productType}`)}
</div>
<div className="truncate text-xs text-muted-foreground" title={server.baseUrl}>
{server.baseUrl}
</div>
</div>
</div>
<StatusPill health={health} label={t(`health.${health}`)} />
</div>
{body && <div className="flex-1">{body}</div>}
{polledAge && (
<div
data-testid="last-polled"
className="mt-auto border-t border-border/60 pt-3 text-xs text-muted-foreground"
title={exactTime(status?.lastPolledAt, locale)}
>
{t('card.lastPolledAgo', { age: polledAge })}
</div>
)}
</div>
</article>
);
}
@@ -0,0 +1,309 @@
import { describe, expect, it } from 'vitest';
import type { ProxmoxMetrics, ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
import {
formatAge,
HEALTH_ORDER,
isBackupStale,
meterLevel,
ratio,
serverHealth,
sortServersByHealth,
summarizeHealth,
THRESHOLDS,
} from './proxmox-status';
/** Fester Bezugszeitpunkt fuer alle Tests: 24.09.2026, 12:00 UTC. */
const NOW = Date.parse('2026-09-24T12:00:00.000Z');
const NOW_S = NOW / 1000;
function makeStatus(overrides: Partial<ProxmoxServerStatus> = {}): ProxmoxServerStatus {
return {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-24T11:55:00.000Z',
lastOkAt: '2026-09-24T11:55:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
updatedAt: '2026-09-24T11:55:00.000Z',
...overrides,
};
}
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
return {
id: 'srv-1',
tenantId: 't1',
name: 'pve-1',
productType: 'pve',
baseUrl: 'https://pve.intern',
authMethod: 'token',
tokenId: 'root@pam!x',
username: null,
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: 5,
position: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: makeStatus(),
...overrides,
};
}
function withMetrics(
metrics: ProxmoxMetrics,
overrides: Partial<ProxmoxServer> = {},
): ProxmoxServer {
return makeServer({
productType: metrics.productType,
status: makeStatus({ metrics }),
...overrides,
});
}
function pve(
nodes: Array<{ cpu: number | null; mem: number | null; maxmem: number | null }>,
storages: Array<{ disk: number | null; maxdisk: number | null }> = [],
): ProxmoxMetrics {
return {
productType: 'pve',
nodeCount: nodes.length,
guestsRunning: 1,
guestsStopped: 0,
nodes: nodes.map((n, i) => ({ node: `n${i}`, maxcpu: 8, ...n })),
storages: storages.map((s, i) => ({ storage: `s${i}`, node: 'n0', ...s })),
};
}
describe('THRESHOLDS', () => {
it('traegt die Schwellen aus dem Plan an EINER Stelle', () => {
expect(THRESHOLDS.usageWarn).toBe(0.8);
expect(THRESHOLDS.usageCrit).toBe(0.92);
expect(THRESHOLDS.backupMaxAgeHours).toBe(26);
});
});
describe('meterLevel', () => {
it('null/undefined/NaN -> unknown (nie ok, nie 0)', () => {
expect(meterLevel(null)).toBe('unknown');
expect(meterLevel(undefined)).toBe('unknown');
expect(meterLevel(Number.NaN)).toBe('unknown');
});
it('unter 0.80 ok, ab 0.80 warn, ab 0.92 crit', () => {
expect(meterLevel(0)).toBe('ok');
expect(meterLevel(0.79)).toBe('ok');
expect(meterLevel(0.8)).toBe('warn');
expect(meterLevel(0.91)).toBe('warn');
expect(meterLevel(0.92)).toBe('crit');
expect(meterLevel(1)).toBe('crit');
});
});
describe('ratio', () => {
it('rechnet used/total', () => {
expect(ratio(25, 100)).toBe(0.25);
});
it('null, wenn einer der Werte fehlt oder total nicht positiv ist', () => {
expect(ratio(null, 100)).toBeNull();
expect(ratio(10, null)).toBeNull();
expect(ratio(10, 0)).toBeNull();
});
});
describe('isBackupStale', () => {
it('null (noch keine Sicherung) ist KEINE Warnung', () => {
expect(isBackupStale(null, NOW)).toBe(false);
});
it('juenger als 26 h ist frisch, aelter ist veraltet', () => {
expect(isBackupStale(NOW_S - 25 * 3600, NOW)).toBe(false);
expect(isBackupStale(NOW_S - 27 * 3600, NOW)).toBe(true);
});
});
describe('serverHealth', () => {
it('deaktivierter Server ist orphan — Vorrang vor allem, auch mit alten Messwerten und Fehler', () => {
expect(serverHealth(makeServer({ isActive: false }), NOW)).toBe('orphan');
expect(
serverHealth(
makeServer({
isActive: false,
status: makeStatus({ reachable: false, errorKind: 'netz' }),
}),
NOW,
),
).toBe('orphan');
expect(
serverHealth(
withMetrics(pve([{ cpu: 0.99, mem: 99, maxmem: 100 }]), { isActive: false }),
NOW,
),
).toBe('orphan');
expect(serverHealth(makeServer({ isActive: false, status: null }), NOW)).toBe('orphan');
});
it('kein Zwischenlager oder noch nie abgefragt -> idle', () => {
expect(serverHealth(makeServer({ status: null }), NOW)).toBe('idle');
expect(
serverHealth(
makeServer({ status: makeStatus({ lastPolledAt: null, reachable: false }) }),
NOW,
),
).toBe('idle');
});
it('abgefragt, aber nicht erreichbar -> down', () => {
expect(
serverHealth(
makeServer({ status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
NOW,
),
).toBe('down');
});
it('erreichbar ohne Messwerte -> ok', () => {
expect(serverHealth(makeServer(), NOW)).toBe('ok');
});
it('PVE: Prozessor, Arbeitsspeicher oder Datenspeicher ab 0.80 -> warn', () => {
expect(serverHealth(withMetrics(pve([{ cpu: 0.2, mem: 10, maxmem: 100 }])), NOW)).toBe('ok');
expect(serverHealth(withMetrics(pve([{ cpu: 0.85, mem: 10, maxmem: 100 }])), NOW)).toBe('warn');
expect(serverHealth(withMetrics(pve([{ cpu: 0.1, mem: 80, maxmem: 100 }])), NOW)).toBe('warn');
expect(
serverHealth(
withMetrics(pve([{ cpu: 0.1, mem: 10, maxmem: 100 }], [{ disk: 95, maxdisk: 100 }])),
NOW,
),
).toBe('warn');
});
it('PVE: unbekannte Werte (null) erzeugen NIE eine Warnung', () => {
expect(
serverHealth(
withMetrics(pve([{ cpu: null, mem: null, maxmem: null }], [{ disk: null, maxdisk: null }])),
NOW,
),
).toBe('ok');
});
it('PBS: Fuellstand ab 0.80, Sicherung aelter als 26 h, Pruefung nicht ok -> warn', () => {
const base = {
name: 'ds',
total: 100,
used: 10,
free: 90,
lastBackupAt: NOW_S - 3600,
lastVerifyState: 'ok',
};
const pbs = (ds: Partial<typeof base> & Record<string, unknown>): ProxmoxMetrics => ({
productType: 'pbs',
datastores: [{ ...base, ...ds } as never],
});
expect(serverHealth(withMetrics(pbs({})), NOW)).toBe('ok');
expect(serverHealth(withMetrics(pbs({ used: 85 })), NOW)).toBe('warn');
expect(serverHealth(withMetrics(pbs({ lastBackupAt: NOW_S - 30 * 3600 })), NOW)).toBe('warn');
expect(serverHealth(withMetrics(pbs({ lastVerifyState: 'failed' })), NOW)).toBe('warn');
});
it('PBS: null bei Belegung, Sicherung oder Pruefung erzeugt KEINE Warnung', () => {
const metrics: ProxmoxMetrics = {
productType: 'pbs',
datastores: [
{
name: 'ds',
total: null,
used: null,
free: null,
lastBackupAt: null,
lastVerifyState: null,
},
],
};
expect(serverHealth(withMetrics(metrics), NOW)).toBe('ok');
});
it('PMG: Viren > 0 -> warn, 0 oder null -> ok', () => {
const pmg = (virusCount: number | null): ProxmoxMetrics => ({
productType: 'pmg',
countIn: 1,
countOut: 1,
spamCount: 1,
virusCount,
});
expect(serverHealth(withMetrics(pmg(2)), NOW)).toBe('warn');
expect(serverHealth(withMetrics(pmg(0)), NOW)).toBe('ok');
expect(serverHealth(withMetrics(pmg(null)), NOW)).toBe('ok');
});
});
describe('sortServersByHealth', () => {
it('ordnet down, warn, ok, idle, orphan — innerhalb eines Zustands nach position', () => {
const servers = [
makeServer({ id: 'orphan', isActive: false, position: 0 }),
makeServer({ id: 'ok-2', position: 5 }),
makeServer({ id: 'idle', status: null, position: 1 }),
makeServer({ id: 'ok-1', position: 2 }),
makeServer({ id: 'down', status: makeStatus({ reachable: false }), position: 9 }),
withMetrics(pve([{ cpu: 0.9, mem: 1, maxmem: 10 }]), { id: 'warn', position: 7 }),
];
expect(sortServersByHealth(servers, NOW).map((s) => s.id)).toEqual([
'down',
'warn',
'ok-1',
'ok-2',
'idle',
'orphan',
]);
});
it('veraendert die uebergebene Liste nicht', () => {
const servers = [makeServer({ id: 'a', isActive: false }), makeServer({ id: 'b' })];
sortServersByHealth(servers, NOW);
expect(servers.map((s) => s.id)).toEqual(['a', 'b']);
});
});
describe('summarizeHealth', () => {
it('zaehlt je Zustand, in der Reihenfolge von HEALTH_ORDER', () => {
const servers = [
makeServer({ id: 'a' }),
makeServer({ id: 'b' }),
makeServer({ id: 'c', isActive: false }),
makeServer({ id: 'd', status: makeStatus({ reachable: false }) }),
];
expect(HEALTH_ORDER).toEqual(['down', 'warn', 'ok', 'idle', 'orphan']);
expect(summarizeHealth(servers, NOW)).toEqual({ down: 1, warn: 0, ok: 2, idle: 0, orphan: 1 });
});
});
describe('formatAge', () => {
it('null oder ungueltig -> null', () => {
expect(formatAge(null, NOW)).toBeNull();
expect(formatAge('kein Datum', NOW)).toBeNull();
});
it('ISO-Zeichenkette: Minuten, Stunden, Tage auf Deutsch', () => {
expect(formatAge('2026-09-24T11:57:00.000Z', NOW)).toBe('vor 3 Min.');
expect(formatAge('2026-09-24T07:00:00.000Z', NOW)).toBe('vor 5 Std.');
expect(formatAge('2026-09-22T12:00:00.000Z', NOW)).toBe('vor 2 Tagen');
});
it('Zahl gilt als Unix-Sekunden (PBS lastBackupAt)', () => {
expect(formatAge(NOW_S - 5 * 3600, NOW)).toBe('vor 5 Std.');
});
it('unter einer Minute und leichte Uhrabweichung in die Zukunft -> "jetzt"', () => {
expect(formatAge('2026-09-24T11:59:40.000Z', NOW)).toBe('jetzt');
expect(formatAge('2026-09-24T12:00:30.000Z', NOW)).toBe('jetzt');
});
it('englisch ueber den Sprachparameter', () => {
expect(formatAge('2026-09-24T11:57:00.000Z', NOW, 'en')).toBe('3 min. ago');
});
});
@@ -0,0 +1,158 @@
import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
/**
* Statuslogik der Proxmox-Seite (quick-260924-h7x) — reine Funktionen ohne
* React, damit jede Verzweigung einzeln pruefbar ist.
*
* Grundregel: ein UNBEKANNTER Wert (`null`) ist nie „0“ und loest nie eine
* Warnung aus. Die Feldnamen von PBS und PMG sind nur abgeleitet (siehe
* ServerCard), ein Fehlalarm aus einem fehlenden Feld waere schlimmer als
* ein ehrliches „unbekannt“.
*/
/** Gesamtzustand eines Servers — bestimmt Farbe, Pille und Sortierung. */
export type ServerHealth = 'ok' | 'warn' | 'down' | 'idle' | 'orphan';
/** Zustand eines einzelnen Balkens. */
export type MeterLevel = 'ok' | 'warn' | 'crit' | 'unknown';
/** Alle Schwellen an EINER Stelle. Auslastungen sind Anteile (0..1). */
export const THRESHOLDS = {
/** Last, Arbeitsspeicher, Datenspeicher: ab hier Warnung. */
usageWarn: 0.8,
/** Ab hier kritisch (nur fuer die Balkenfarbe). */
usageCrit: 0.92,
/** PBS: letzte Sicherung aelter als so viele Stunden -> Warnung. */
backupMaxAgeHours: 26,
} as const;
/** Reihenfolge fuer Sortierung, Gesundheitsbalken und Legende. */
export const HEALTH_ORDER: readonly ServerHealth[] = ['down', 'warn', 'ok', 'idle', 'orphan'];
function isKnownNumber(value: number | null | undefined): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}
/** Balkenstufe aus einem Anteil (0..1); `null` bleibt `unknown`. */
export function meterLevel(fraction: number | null | undefined): MeterLevel {
if (!isKnownNumber(fraction)) return 'unknown';
if (fraction >= THRESHOLDS.usageCrit) return 'crit';
if (fraction >= THRESHOLDS.usageWarn) return 'warn';
return 'ok';
}
/** `used / total` — `null`, sobald ein Wert fehlt oder `total` nicht positiv ist. */
export function ratio(
used: number | null | undefined,
total: number | null | undefined,
): number | null {
if (!isKnownNumber(used) || !isKnownNumber(total) || total <= 0) return null;
return used / total;
}
/**
* Zeitpunkt in Millisekunden. Zahlen sind Unix-SEKUNDEN (so liefert PBS
* `lastBackupAt`), Zeichenketten ISO-Zeitpunkte (Zwischenlager).
*/
export function toEpochMs(value: number | string | null | undefined): number | null {
if (value === null || value === undefined) return null;
const ms = typeof value === 'number' ? value * 1000 : Date.parse(value);
return Number.isNaN(ms) ? null : ms;
}
/** Letzte PBS-Sicherung aelter als {@link THRESHOLDS.backupMaxAgeHours}? `null` (noch nie) ist KEINE Warnung. */
export function isBackupStale(
lastBackupAt: number | null | undefined,
now: number = Date.now(),
): boolean {
const ms = toEpochMs(lastBackupAt);
if (ms === null) return false;
return now - ms > THRESHOLDS.backupMaxAgeHours * 3600 * 1000;
}
function isHigh(fraction: number | null): boolean {
return fraction !== null && fraction >= THRESHOLDS.usageWarn;
}
/** Liegt irgendein Messwert ueber seiner Warnschwelle? */
function metricsNeedAttention(metrics: ProxmoxMetrics, now: number): boolean {
if (metrics.productType === 'pve') {
const nodeWarn = metrics.nodes.some(
(n) => isHigh(isKnownNumber(n.cpu) ? n.cpu : null) || isHigh(ratio(n.mem, n.maxmem)),
);
const storageWarn = metrics.storages.some((s) => isHigh(ratio(s.disk, s.maxdisk)));
return nodeWarn || storageWarn;
}
if (metrics.productType === 'pbs') {
return metrics.datastores.some(
(ds) =>
isHigh(ratio(ds.used, ds.total)) ||
isBackupStale(ds.lastBackupAt, now) ||
(ds.lastVerifyState !== null && ds.lastVerifyState !== 'ok'),
);
}
return isKnownNumber(metrics.virusCount) && metrics.virusCount > 0;
}
/**
* Gesamtzustand eines Servers. Reihenfolge der Pruefung ist Absicht:
* ein deaktivierter Server ist „offline & verwaist“, egal was im
* Zwischenlager noch steht.
*/
export function serverHealth(server: ProxmoxServer, now: number = Date.now()): ServerHealth {
if (!server.isActive) return 'orphan';
const status = server.status;
if (!status || status.lastPolledAt === null) return 'idle';
if (!status.reachable) return 'down';
if (status.metrics && metricsNeedAttention(status.metrics, now)) return 'warn';
return 'ok';
}
/** Neue Liste: down, warn, ok, idle, orphan — innerhalb eines Zustands nach `position`. */
export function sortServersByHealth(
servers: readonly ProxmoxServer[],
now: number = Date.now(),
): ProxmoxServer[] {
const rank = new Map(servers.map((s) => [s.id, HEALTH_ORDER.indexOf(serverHealth(s, now))]));
return [...servers].sort(
(a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0) || a.position - b.position,
);
}
/** Anzahl Server je Zustand (alle Zustaende vorhanden, auch mit 0). */
export function summarizeHealth(
servers: readonly ProxmoxServer[],
now: number = Date.now(),
): Record<ServerHealth, number> {
const counts: Record<ServerHealth, number> = { down: 0, warn: 0, ok: 0, idle: 0, orphan: 0 };
for (const server of servers) counts[serverHealth(server, now)]++;
return counts;
}
/**
* Relative Zeitangabe („vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“) ueber
* `Intl.RelativeTimeFormat` — keine zusaetzliche Abhaengigkeit. Unter einer
* Minute (und bei leichter Uhrabweichung in die Zukunft) „jetzt“.
* `null`, wenn der Zeitpunkt fehlt oder ungueltig ist.
*/
export function formatAge(
value: number | string | null | undefined,
now: number = Date.now(),
locale = 'de',
): string | null {
const ms = toEpochMs(value);
if (ms === null) return null;
const seconds = Math.max(0, Math.round((now - ms) / 1000));
if (seconds < 60) {
return new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'auto' }).format(
0,
'second',
);
}
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'always' });
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return rtf.format(-minutes, 'minute');
const hours = Math.floor(minutes / 60);
if (hours < 24) return rtf.format(-hours, 'hour');
return rtf.format(-Math.floor(hours / 24), 'day');
}
@@ -0,0 +1,65 @@
import type { MeterLevel, ServerHealth } from './proxmox-status';
/**
* Tailwind-Klassen je Zustand (quick-260924-h7x). Bewusst als vollstaendige,
* statische Zeichenketten — Tailwind findet Klassen nur, wenn sie woertlich im
* Quelltext stehen, zusammengesetzte Namen wie `bg-status-${x}` gingen verloren.
*
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
* zweischichtiger Schatten, der untere im Statuston getoent.
*/
export const HEALTH_STYLE: Record<
ServerHealth,
{ fill: string; pill: string; text: string; shadow: string }
> = {
ok: {
fill: 'bg-status-ok',
pill: 'bg-status-ok/12 text-status-ok-fg',
text: 'text-status-ok-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-ok)]',
},
warn: {
fill: 'bg-status-warn',
pill: 'bg-status-warn/12 text-status-warn-fg',
text: 'text-status-warn-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-warn)]',
},
down: {
fill: 'bg-status-down',
pill: 'bg-status-down/12 text-status-down-fg',
text: 'text-status-down-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-down)]',
},
idle: {
fill: 'bg-status-idle',
pill: 'bg-status-idle/12 text-status-idle-fg',
text: 'text-status-idle-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-idle)]',
},
// Kein farbiger Schatten: ein verwaister Server soll nicht „leuchten“.
orphan: {
fill: 'bg-status-orphan',
pill: 'bg-status-orphan/12 text-status-orphan-fg',
text: 'text-status-orphan-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.04)]',
},
};
/** Balkenfarbe je Stufe; `unknown` wird schraffiert gezeichnet (siehe Meter). */
export const METER_FILL: Record<Exclude<MeterLevel, 'unknown'>, string> = {
ok: 'bg-status-ok',
warn: 'bg-status-warn',
crit: 'bg-status-down',
};
/** Schriftfarbe der Prozentzahl je Stufe. */
export const METER_TEXT: Record<MeterLevel, string> = {
ok: 'text-foreground',
warn: 'text-status-warn-fg',
crit: 'text-status-down-fg',
unknown: 'text-muted-foreground',
};
/** Eingelassenes Messfeld („Well“). */
export const WELL = 'rounded-lg bg-well shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]';
@@ -1,19 +1,66 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useAuthStore } from '@/lib/stores/auth-store';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from './components/HealthBar';
import { sortServersByHealth, summarizeHealth } from './components/proxmox-status';
import { ServerCard } from './components/ServerCard';
import { WELL } from './components/status-styles';
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
const CLOCK_TICK_MS = 60_000;
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={spinning ? 'animate-spin motion-reduce:animate-none' : ''}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<polyline points="21 3 21 8 16 8" />
</svg>
);
}
function SkeletonCard() {
return (
<div className="rounded-xl border border-border bg-card p-4 pl-5 shadow-[0_1px_2px_oklch(0_0_0/0.06)]">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-3.5 w-1/3 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
</div>
<div className="h-6 w-20 rounded-full bg-muted" />
</div>
<div className="mt-4 h-16 rounded-lg bg-muted" />
<div className="mt-2 h-12 rounded-lg bg-muted" />
</div>
);
}
/**
* Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt. Der Knopf
* erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN,
* SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6).
* Modulseite (quick-260924-h7x, vorher 260923-dhh Aufgabe 6) — liest
* ausschliesslich aus dem Zwischenlager, das `GET servers` liefert; kein
* Live-Zugriff bei Proxmox von hier aus (D-05).
*
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
* (down, warn, ok, idle, orphan, darin `position`). „Jetzt aktualisieren“
* loest je Server eine Abfrage aus und laedt danach neu; der Knopf erscheint
* nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)`
* verlangt (260923-le6).
*/
export default function ProxmoxPage() {
const t = useTranslations('proxmox');
@@ -23,10 +70,14 @@ export default function ProxmoxPage() {
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [now, setNow] = useState(() => Date.now());
const reload = useCallback(() => {
listServers()
.then(setServers)
.then((list) => {
setServers(list);
setNow(Date.now());
})
.catch(() => setError(t('loadError')));
}, [t]);
@@ -34,6 +85,11 @@ export default function ProxmoxPage() {
reload();
}, [reload]);
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
return () => window.clearInterval(id);
}, []);
const handleRefresh = async () => {
if (!servers || servers.length === 0) return;
setIsRefreshing(true);
@@ -45,63 +101,102 @@ export default function ProxmoxPage() {
}
};
const sorted = useMemo(() => (servers ? sortServersByHealth(servers, now) : []), [servers, now]);
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<div className="flex items-center gap-3">
{isAdmin && servers !== null && servers.length > 0 && (
{isAdmin && (
<div className="flex items-center gap-4">
<Link
href="/modules/proxmox/settings"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{t('card.settingsNav')}
</Link>
{servers !== null && servers.length > 0 && (
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-70"
>
<RefreshIcon spinning={isRefreshing} />
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button>
)}
{isAdmin && (
<Link
href="/modules/proxmox/settings"
className="text-sm text-primary hover:underline"
>
{t('card.settingsLink')}
</Link>
</div>
)}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
{error}
</p>
)}
{!error && servers === null && (
<p className="text-sm text-muted-foreground">{t('loading')}</p>
<div
aria-busy="true"
className="grid animate-pulse gap-4 motion-reduce:animate-none lg:grid-cols-2"
>
<span className="sr-only">{t('loading')}</span>
<SkeletonCard />
<SkeletonCard />
</div>
)}
{!error && servers !== null && servers.length === 0 && (
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
{t('emptyState')}
<div className={`${WELL} flex flex-col items-center gap-3 px-6 py-10 text-center`}>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<rect x="3" y="4" width="18" height="7" rx="1.5" />
<rect x="3" y="13" width="18" height="7" rx="1.5" />
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
</svg>
<p className="max-w-md text-sm text-muted-foreground">{t('emptyState')}</p>
{isAdmin && (
<>
{' '}
<Link href="/modules/proxmox/settings" className="text-primary hover:underline">
<Link
href="/modules/proxmox/settings"
className="text-sm font-medium text-foreground hover:underline"
>
{t('card.settingsLink')}
</Link>
</>
)}
</div>
)}
{!error && servers !== null && servers.length > 0 && (
<ul className="space-y-3">
{servers.map((server) => (
<li key={server.id}>
<ServerCard server={server} isAdmin={isAdmin} />
<>
<HealthBar counts={counts} />
{/* Spaltenfluss statt Zeilenraster: eine kurze Karte (z. B. "nicht
erreichbar") liesse im Raster eine Luecke bis zur hoeheren
Nachbarkarte; so rueckt die naechste Karte direkt nach. */}
<ul className="gap-4 lg:columns-2">
{sorted.map((server) => (
<li key={server.id} className="mb-4 min-w-0 break-inside-avoid">
<ServerCard server={server} isAdmin={isAdmin} now={now} />
</li>
))}
</ul>
</>
)}
</div>
);
@@ -1,6 +1,19 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProxmoxServer } from '@/lib/proxmox-api';
import de from '@/messages/de.json';
// Echter next-intl-Provider mit den deutschen Texten (quick-260924-h7x) —
// ServerCard braucht `useLocale` und ICU-Plural.
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
// "Jetzt aktualisieren" ruft `pollServer` je Server.
@@ -26,34 +39,6 @@ vi.mock('next/link', () => ({
),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
title: 'Proxmox',
description: 'Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick.',
loading: 'Lade Serverliste...',
loadError: 'Die Serverliste konnte nicht geladen werden.',
emptyState:
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
'card.refresh': 'Jetzt aktualisieren',
'card.refreshing': 'Wird aktualisiert...',
'card.settingsLink': 'Zu den Einstellungen',
'card.unknownValue': 'unbekannt',
'card.lastPolledLabel': 'Letzte Abfrage',
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
'card.notPolledYetAutomatic':
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, String(v));
}
}
return result;
},
}));
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
selector({ user }),
@@ -176,3 +161,119 @@ describe('ProxmoxPage role gating (260923-le6)', () => {
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
});
});
// Ein erreichbarer, abgefragter Server; Zustand ueber die Ueberschreibungen.
function makePolledServer(
overrides: Partial<ProxmoxServer> = {},
statusOverrides: Record<string, unknown> = {},
) {
const base = makeUnpolledServer(overrides);
return {
...base,
status: {
...base.status,
lastPolledAt: new Date(Date.now() - 3 * 60_000).toISOString(),
lastOkAt: new Date(Date.now() - 3 * 60_000).toISOString(),
reachable: true,
...statusOverrides,
},
} as ProxmoxServer;
}
describe('ProxmoxPage — Gesundheitsbalken und Sortierung (quick-260924-h7x)', () => {
const servers = [
makePolledServer({ id: 'a', name: 'ok-server', position: 0 }),
makePolledServer(
{ id: 'b', name: 'down-server', position: 1 },
{ reachable: false, errorKind: 'netz' },
),
makePolledServer({ id: 'c', name: 'orphan-server', position: 2, isActive: false }),
makePolledServer(
{ id: 'd', name: 'warn-server', position: 3, productType: 'pmg' },
{ metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 0, virusCount: 2 } },
),
makeUnpolledServer({ id: 'e', name: 'idle-server', position: 4 }),
];
it('Karten stehen in der Reihenfolge down, warn, ok, idle, orphan', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue(servers);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('ok-server');
const order = screen.getAllByTestId('server-card').map((c) => c.getAttribute('data-health'));
expect(order).toEqual(['down', 'warn', 'ok', 'idle', 'orphan']);
});
it('der Balken liest die Zusammenfassung vor und hat je belegtem Zustand ein Segment', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue(servers);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('ok-server');
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
expect(bar).toHaveAccessibleName(
'Zustand der Server: 1 nicht erreichbar, 1 mit Warnung, 1 in Ordnung, 1 noch nicht abgefragt, 1 offline & verwaist',
);
expect(Array.from(bar.children).map((s) => s.getAttribute('data-segment'))).toEqual([
'down',
'warn',
'ok',
'idle',
'orphan',
]);
});
it('leere Zustaende entfallen in Balken und Legende', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue([
makePolledServer({ id: 'a', name: 'eins', position: 0 }),
makePolledServer({ id: 'b', name: 'zwei', position: 1 }),
]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('eins');
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
expect(bar).toHaveAccessibleName('Zustand der Server: 2 in Ordnung');
expect(bar.children).toHaveLength(1);
const legend = within(screen.getByTestId('health-bar')).getByRole('list', { hidden: true });
expect(legend.children).toHaveLength(1);
});
it('waehrend des Ladens erscheinen Skelett-Karten ohne Daueranimation bei reduzierter Bewegung', async () => {
mockUser({ role: 'USER' });
mockListServers.mockReturnValue(new Promise(() => undefined));
const { default: ProxmoxPage } = await import('./page');
const { container } = render(<ProxmoxPage />);
const busy = container.querySelector('[aria-busy="true"]');
expect(busy).not.toBeNull();
expect(busy).toHaveClass('animate-pulse', 'motion-reduce:animate-none');
expect(screen.getByText('Lade Serverliste...')).toBeInTheDocument();
});
it('Einstellungen-Verweis nur fuer Admins', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue([makePolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.queryByRole('link', { name: 'Einstellungen' })).not.toBeInTheDocument();
cleanup();
mockUser({ role: 'ADMIN' });
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.getByRole('link', { name: 'Einstellungen' })).toHaveAttribute(
'href',
'/modules/proxmox/settings',
);
});
});
+4 -3
View File
@@ -114,9 +114,10 @@ export default function DashboardPage() {
return (
<div className="relative p-2">
{/* Reiterleiste (quick-260923-ad9) — bleibt waehrend eines
Reiterwechsels stehen, nur das Raster darunter zeigt eine kurze
Ladezeile. */}
{/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
kurze Ladezeile. */}
<DashboardTabs
dashboards={dashboards}
activeDashboardId={activeDashboardId}
+45
View File
@@ -44,6 +44,19 @@
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
/* Statusfarben (quick-260924-h7x): Flaechen (Balken, Punkte, Leisten) und
die abgedunkelte bzw. aufgehellte Textvariante `-fg` fuer Schrift. */
--color-status-ok: var(--status-ok);
--color-status-warn: var(--status-warn);
--color-status-down: var(--status-down);
--color-status-idle: var(--status-idle);
--color-status-orphan: var(--status-orphan);
--color-status-ok-fg: var(--status-ok-fg);
--color-status-warn-fg: var(--status-warn-fg);
--color-status-down-fg: var(--status-down-fg);
--color-status-idle-fg: var(--status-idle-fg);
--color-status-orphan-fg: var(--status-orphan-fg);
--color-well: var(--well);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
@@ -83,6 +96,24 @@
--sidebar-accent: oklch(0.93 0 0);
--sidebar-accent-foreground: oklch(0.25 0 0);
--sidebar-border: oklch(0.90 0 0);
/* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem
Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis
4,9:1 (gemessen, WCAG-Formel). Fuer Text gibt es deshalb die `-fg`-
Variante, gemessen auf --card: ok 5,6:1, warn 5,7:1, down 6,1:1,
idle/orphan 6,0:1 (auf der Pille mit 12 % Statusflaeche >= 4,9:1). */
--status-ok: oklch(0.62 0.15 152);
--status-warn: oklch(0.72 0.15 70);
--status-down: oklch(0.58 0.21 27);
--status-idle: oklch(0.62 0.02 260);
--status-orphan: oklch(0.55 0.01 260);
--status-ok-fg: oklch(0.50 0.13 152);
--status-warn-fg: oklch(0.52 0.12 60);
--status-down-fg: oklch(0.52 0.20 27);
--status-idle-fg: oklch(0.50 0.02 260);
--status-orphan-fg: oklch(0.50 0.01 260);
/* Eingelassene Messfelder. */
--well: oklch(0.975 0.003 260);
}
.dark {
@@ -110,6 +141,20 @@
--sidebar-accent: oklch(0.91 0.19 102 / 0.15);
--sidebar-accent-foreground: oklch(0.91 0.19 102);
--sidebar-border: oklch(0.30 0.01 260);
/* Statusfarben (dark). Textvariante auf --card gemessen: ok 7,4:1,
warn 8,5:1, down 6,1:1, idle 6,1:1, orphan 5,6:1. */
--status-ok: oklch(0.72 0.15 152);
--status-warn: oklch(0.80 0.14 75);
--status-down: oklch(0.68 0.19 27);
--status-idle: oklch(0.60 0.02 260);
--status-orphan: oklch(0.52 0.01 260);
--status-ok-fg: oklch(0.76 0.15 152);
--status-warn-fg: oklch(0.82 0.14 75);
--status-down-fg: oklch(0.74 0.16 27);
--status-idle-fg: oklch(0.72 0.02 260);
--status-orphan-fg: oklch(0.70 0.01 260);
--well: oklch(0.235 0.01 260);
}
body {
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das
@@ -90,8 +90,17 @@ function defaultProps(overrides: Partial<Parameters<typeof DashboardTabs>[0]> =
};
}
// Seit quick-260924-h7x rendert die Leiste per Portal in den Einhaengepunkt
// der Kopfzeile — der Test legt ihn so an, wie `header.tsx` ihn rendert.
beforeEach(() => {
const slot = document.createElement('div');
slot.id = 'header-center-slot';
document.body.appendChild(slot);
});
afterEach(() => {
cleanup();
document.getElementById('header-center-slot')?.remove();
vi.restoreAllMocks();
});
@@ -99,24 +108,27 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
render(<DashboardTabs {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toBeInTheDocument();
});
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
const onSelect = vi.fn();
render(<DashboardTabs {...defaultProps({ onSelect })} />);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => {
it('Test 3: der aktive Reiter traegt aria-selected=true, der andere false', () => {
render(<DashboardTabs {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current');
expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toHaveAttribute(
'aria-selected',
'false',
);
});
it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => {
@@ -171,7 +183,7 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
fireEvent.keyDown(input, { key: 'Escape' });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
});
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
@@ -219,7 +231,7 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
expect(onReorder).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
@@ -261,14 +273,16 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
// Vorschau: d2 steht jetzt vor d1 im DOM.
const buttons = screen.getAllByRole('button').filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
const buttons = screen
.getAllByRole('tab')
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']);
fireEvent.pointerCancel(tab, { pointerId: 1 });
expect(onReorder).not.toHaveBeenCalled();
const buttonsAfterCancel = screen
.getAllByRole('button')
.getAllByRole('tab')
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']);
});
@@ -289,13 +303,17 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => {
stubTabRects();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })} />);
render(
<DashboardTabs
{...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })}
/>,
);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true');
});
it('Test 19: nach einem echten Ziehen unterdrueckt der naechste Klick die Auswahl EINMAL, danach funktioniert Klicken wieder normal', () => {
@@ -311,16 +329,18 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
expect(onReorder).toHaveBeenCalledTimes(1);
// Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen.
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' }));
expect(onSelect).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' }));
expect(onSelect).toHaveBeenCalledWith('d1');
});
it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => {
const { rerender } = render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS })} />);
expect(screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.')).toBeInTheDocument();
expect(
screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'),
).toBeInTheDocument();
rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
expect(
@@ -331,7 +351,9 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />);
render(
<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />,
);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
@@ -341,3 +363,72 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
});
});
describe('DashboardTabs — in der Kopfzeile (quick-260924-h7x)', () => {
it('Test 22: rendert in den Einhaengepunkt der Kopfzeile, nicht an Ort und Stelle', () => {
const { container } = render(<DashboardTabs {...defaultProps()} />);
const slot = document.getElementById('header-center-slot') as HTMLElement;
expect(within(slot).getByRole('navigation', { name: 'Dashboard-Reiter' })).toBeInTheDocument();
expect(within(slot).getByRole('tablist')).toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});
it('Test 23: ohne Einhaengepunkt rendert die Leiste gar nichts', () => {
document.getElementById('header-center-slot')?.remove();
render(<DashboardTabs {...defaultProps()} />);
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
it('Test 24: Pfeiltasten wandern zwischen den Reitern (mit Umlauf), ohne zu waehlen; nur der aktive ist per Tab erreichbar', () => {
const onSelect = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onSelect })} />);
const [t1, t2, t3] = screen.getAllByRole('tab');
expect(t1).toHaveAttribute('tabindex', '0');
expect(t2).toHaveAttribute('tabindex', '-1');
expect(t3).toHaveAttribute('tabindex', '-1');
t1.focus();
fireEvent.keyDown(t1, { key: 'ArrowRight' });
expect(t2).toHaveFocus();
fireEvent.keyDown(t2, { key: 'ArrowRight' });
expect(t3).toHaveFocus();
fireEvent.keyDown(t3, { key: 'ArrowRight' });
expect(t1).toHaveFocus();
fireEvent.keyDown(t1, { key: 'ArrowLeft' });
expect(t3).toHaveFocus();
fireEvent.keyDown(t3, { key: 'Home' });
expect(t1).toHaveFocus();
fireEvent.keyDown(t1, { key: 'End' });
expect(t3).toHaveFocus();
expect(onSelect).not.toHaveBeenCalled();
});
it('Test 25: genau ein Reiter — der Umschalter steht trotzdem, im Bearbeitungsmodus nur mit „+“ daneben', () => {
render(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
});
it('Test 26: „+“ sitzt ausserhalb der scrollenden Spur und bleibt so immer sichtbar', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
const tablist = screen.getByRole('tablist');
expect(tablist).not.toContainElement(screen.getByLabelText('Dashboard hinzufügen'));
});
it('Test 27: der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
const dialog = screen.getByRole('alertdialog');
const slot = document.getElementById('header-center-slot') as HTMLElement;
expect(slot).not.toContainElement(dialog);
});
});
@@ -1,7 +1,18 @@
'use client';
import { useTranslations } from 'next-intl';
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import {
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import type { DashboardTab } from '@/lib/dashboard-api';
interface DashboardTabsProps {
@@ -15,6 +26,9 @@ interface DashboardTabsProps {
onReorder: (ids: string[]) => void;
}
/** Breite der weichen Ausblendung an einem ueberlaufenden Rand. */
const FADE_PX = 16;
/** Ab dieser waagerechten Auslenkung (Bildschirmpixel) wird aus einem Klick ein Ziehen (Task 4). */
const DRAG_THRESHOLD_PX = 4;
@@ -63,7 +77,17 @@ function computeReorderedIds(
}
/**
* Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3/4).
* Dashboard-Reiter (quick-260923-ad9, Task 3/4; seit quick-260924-h7x in der
* Kopfzeile).
*
* Die Leiste belegt keine eigene Zeile mehr: sie rendert per `createPortal`
* in den Einhaengepunkt {@link HEADER_CENTER_SLOT_ID} der Kopfzeile, sobald
* er im DOM ist — vorher rendert sie nichts. Gestaltung als kompakter
* Umschalter: eingelassene Spur, der aktive Reiter liegt erhaben darauf.
* Laufen die Reiter ueber, scrollt die Spur waagrecht ohne sichtbare Leiste
* und blendet die Raender weich aus; der aktive Reiter wird ins Bild
* gescrollt. Pfeiltasten links/rechts wandern zwischen den Reitern
* (`role="tablist"`/`tab`), Eingabe oder Leertaste waehlt.
*
* Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus.
* Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft)
@@ -103,6 +127,61 @@ export function DashboardTabs({
const dragRef = useRef<DragState | null>(null);
const hasDraggedRef = useRef(false);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const tabButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const scrollRef = useRef<HTMLDivElement>(null);
const hintId = useId();
const [slot, setSlot] = useState<HTMLElement | null>(null);
const [overflow, setOverflow] = useState({ left: false, right: false });
// Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben
// Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM.
useEffect(() => {
setSlot(document.getElementById(HEADER_CENTER_SLOT_ID));
}, []);
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
const measureOverflow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const left = el.scrollLeft > 1;
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
setOverflow((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
}, []);
// `slot` ist der Ausloeser: die Spur (scrollRef) existiert erst, wenn das
// Portal in die Kopfzeile gezeichnet ist.
// biome-ignore lint/correctness/useExhaustiveDependencies: `slot` ist der Ausloeser, nicht Eingabe
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
measureOverflow();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measureOverflow);
observer.observe(el);
return () => observer.disconnect();
}, [measureOverflow, slot]);
// Nach jeder Aenderung der Reiter neu messen (neuer Reiter, umbenannt).
// biome-ignore lint/correctness/useExhaustiveDependencies: `dashboards` ist der Ausloeser, nicht Eingabe
useEffect(() => {
measureOverflow();
}, [dashboards, measureOverflow]);
// Aktiven Reiter ins Bild scrollen — nur die Spur, nie die Seite.
// biome-ignore lint/correctness/useExhaustiveDependencies: `slot` ist der Ausloeser, nicht Eingabe
useEffect(() => {
const container = scrollRef.current;
const tab = activeDashboardId ? tabRefs.current.get(activeDashboardId) : undefined;
if (!container || !tab) return;
const c = container.getBoundingClientRect();
const r = tab.getBoundingClientRect();
if (r.left < c.left) {
container.scrollLeft -= c.left - r.left + FADE_PX;
} else if (r.right > c.right) {
container.scrollLeft += r.right - c.right + FADE_PX;
}
measureOverflow();
}, [activeDashboardId, measureOverflow, slot]);
// Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
@@ -204,14 +283,52 @@ export function DashboardTabs({
setRenamingId(null);
}
/** Pfeiltasten wandern zwischen den Reitern; gewaehlt wird mit Eingabe/Leertaste. */
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
const count = orderedTabs.length;
let next: number;
if (e.key === 'ArrowRight') next = (index + 1) % count;
else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = count - 1;
else return;
e.preventDefault();
tabButtonRefs.current.get(orderedTabs[next].id)?.focus();
}
const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null;
return (
<div>
<nav aria-label={t('tabs.navLabel')} className="mb-1 flex items-center gap-1 overflow-x-auto">
if (!slot) return null;
const hasActive = orderedTabs.some((tab) => tab.id === activeDashboardId);
const fadeMask =
overflow.left || overflow.right
? `linear-gradient(to right, ${overflow.left ? 'transparent 0' : 'black 0'}, black ${FADE_PX}px, black calc(100% - ${FADE_PX}px), ${overflow.right ? 'transparent 100%' : 'black 100%'})`
: undefined;
const maskStyle: CSSProperties | undefined = fadeMask
? { maskImage: fadeMask, WebkitMaskImage: fadeMask }
: undefined;
const bar = (
<nav
aria-label={t('tabs.navLabel')}
className="flex min-w-0 max-w-full items-center gap-1.5"
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
>
{/* Eingelassene Spur (32 px): der aktive Reiter liegt erhaben darauf. */}
<div className="h-8 min-w-0 max-w-full rounded-lg bg-muted p-0.5 shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)] md:max-w-[min(56vw,720px)]">
<div
ref={scrollRef}
role="tablist"
aria-describedby={dashboards.length > 1 ? hintId : undefined}
onScroll={measureOverflow}
style={maskStyle}
className="flex h-7 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId;
const isRenaming = renamingId === tab.id;
const isFocusable = isActive || (!hasActive && index === 0);
return (
<div
@@ -222,7 +339,11 @@ export function DashboardTabs({
}}
data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`}
className="flex touch-none items-center"
className={`flex h-7 shrink-0 touch-none items-center rounded-md transition-colors ${
isActive
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-card/60 hover:text-foreground'
}`}
onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -245,17 +366,22 @@ export function DashboardTabs({
}}
maxLength={40}
aria-label={t('tabs.renameInputLabel')}
className="h-8 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
className="h-6 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
/>
) : (
<button
ref={(el) => {
if (el) tabButtonRefs.current.set(tab.id, el);
else tabButtonRefs.current.delete(tab.id);
}}
type="button"
role="tab"
aria-selected={isActive}
tabIndex={isFocusable ? 0 : -1}
onClick={() => handleTabClick(tab.id)}
aria-current={isActive ? 'true' : undefined}
className={`h-8 cursor-grab rounded-t px-3 text-sm font-medium transition-colors ${
isActive
? 'border border-b-transparent border-border bg-card text-foreground'
: 'text-muted-foreground hover:bg-muted'
onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`h-7 cursor-grab whitespace-nowrap rounded-md px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isActive ? 'font-medium' : ''
}`}
>
{tab.name}
@@ -268,13 +394,13 @@ export function DashboardTabs({
onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')}
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
className="-ml-1.5 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -293,13 +419,13 @@ export function DashboardTabs({
onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')}
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -315,20 +441,25 @@ export function DashboardTabs({
</div>
);
})}
</div>
</div>
{/* „+“ sitzt AUSSERHALB der scrollenden Spur und bleibt deshalb auch
bei vielen Reitern immer sichtbar — wie bisher nur im
Bearbeitungsmodus. */}
{isEditMode && (
<button
type="button"
onClick={onCreate}
aria-label={t('tabs.add')}
title={t('tabs.add')}
className="ml-1 flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -342,8 +473,21 @@ export function DashboardTabs({
</button>
)}
{pendingDeleteTab && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Hinweis zum Ziehen: frueher eine eigene Zeile unter der Leiste, jetzt
fuer Vorleseprogramme an der Spur und als Tooltip der Leiste. */}
{dashboards.length > 1 && (
<span id={hintId} className="sr-only">
{t('tabs.dragHint')}
</span>
)}
</nav>
);
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
// sonst saesse er in deren Stapelkontext.
const dialog = pendingDeleteTab
? createPortal(
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<button
type="button"
onClick={() => setPendingDeleteId(null)}
@@ -354,7 +498,7 @@ export function DashboardTabs({
role="alertdialog"
aria-modal="true"
aria-label={t('tabs.deleteDialogTitle')}
className="relative z-50 mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
className="relative z-[60] mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
>
<h3 className="mb-2 text-lg font-semibold text-foreground">
{t('tabs.deleteDialogTitle')}
@@ -382,12 +526,15 @@ export function DashboardTabs({
</button>
</div>
</div>
</div>
)}
</nav>
{dashboards.length > 1 && (
<p className="mb-2 text-xs text-muted-foreground">{t('tabs.dragHint')}</p>
)}
</div>
</div>,
document.body,
)
: null;
return (
<>
{createPortal(bar, slot)}
{dialog}
</>
);
}
@@ -0,0 +1,6 @@
/**
* Kennung des Einhaengepunkts in der Mitte der Kopfzeile (quick-260924-h7x).
* Die Kopfzeile rendert ein leeres Element mit dieser Kennung; Seiten
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
*/
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
@@ -150,3 +150,31 @@ describe('Header — Sitzungswaechter (quick-260917-gyd)', () => {
).toHaveTextContent('?');
});
});
describe('Header — Mitte der Kopfzeile (quick-260924-h7x)', () => {
it('auf der Startseite: kein Text „Startseite“, aber der leere Einhaengepunkt fuer die Reiter', async () => {
mockPathname = '/';
vi.stubGlobal('location', { href: '', pathname: '/', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader();
expect(screen.queryByText('Startseite')).not.toBeInTheDocument();
const slot = document.getElementById('header-center-slot');
expect(slot).not.toBeNull();
expect(slot).toBeEmptyDOMElement();
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
});
it('auf anderen Seiten: „Startseite“ bleibt wie bisher', async () => {
mockPathname = '/modules/proxmox';
vi.stubGlobal('location', { href: '', pathname: '/modules/proxmox', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader();
expect(screen.getByText('Startseite')).toBeInTheDocument();
expect(document.getElementById('header-center-slot')).not.toBeNull();
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
});
});
+16 -5
View File
@@ -10,6 +10,7 @@ import { fetchSessionState, logout } from '@/lib/auth-actions';
import { buildNextParam } from '@/lib/safe-next';
import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import { usePathname } from 'next/navigation';
export function Header() {
@@ -97,7 +98,7 @@ export function Header() {
return (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm">
{/* Left: Logo + Mobile hamburger */}
<div className="flex items-center gap-3">
<div className="flex shrink-0 items-center gap-3">
<button
type="button"
onClick={() => setMobileOpen(true)}
@@ -128,19 +129,29 @@ export function Header() {
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
wordmarkClassName="dark:text-primary"
// Unter 640 px nur die Bildmarke: der Platz gehoert dort den
// Dashboard-Reitern in der Kopfzeilenmitte (260924-h7x).
wordmarkClassName="hidden sm:inline dark:text-primary"
/>
</div>
{/* Center: Breadcrumb / page title */}
<div className="flex-1 px-4 text-center">
{/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x).
`DashboardTabs` rendert per Portal hierher; der Punkt muss deshalb
im React-Baum der Kopfzeile LEER bleiben. Auf der Startseite
entfaellt der Text „Startseite“, auf allen anderen Seiten bleibt
er wie bisher. `min-w-0` laesst die Mitte schrumpfen, damit Logo
und Aktionen rechts auch bei schmalem Fenster Platz behalten. */}
<div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4">
<div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
{pathname !== '/' && (
<span className="text-sm text-muted-foreground truncate">
{tHeader('breadcrumb.home')}
</span>
)}
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<BugReportButton />
<ThemeToggle />
+35 -8
View File
@@ -720,24 +720,36 @@
"emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.",
"card": {
"unknownValue": "unbekannt",
"lastPolledLabel": "Letzte Abfrage",
"lastPolledAgo": "Letzte Abfrage {age}",
"notPolledYet": "Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.",
"notPolledYetAutomatic": "Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.",
"lastOkLabel": "Letzte erfolgreiche Messung",
"lastOkAgo": "Zuletzt erreichbar {age}",
"orphanText": "Dieser Server ist deaktiviert und wird nicht mehr abgefragt.",
"orphanAdminHint": "Sie können ihn in den Einstellungen wieder aktivieren.",
"refresh": "Jetzt aktualisieren",
"refreshing": "Wird aktualisiert...",
"settingsLink": "Zu den Einstellungen",
"settingsNav": "Einstellungen",
"product": {
"pve": "Virtualisierung",
"pbs": "Datensicherung",
"pmg": "Mail-Gateway"
},
"pve": {
"nodeCount": "Knoten",
"guests": "{running} laufend / {stopped} gestoppt",
"cpu": "Prozessorlast",
"mem": "Speicher"
"running": "{count, plural, one {Gast läuft} other {Gäste laufen}}",
"stopped": "gestoppt",
"nodes": "{count, plural, one {# Knoten} other {# Knoten}}",
"cpu": "Prozessor",
"mem": "Arbeitsspeicher"
},
"pbs": {
"used": "Belegt",
"lastBackup": "Letzte Sicherung",
"lastBackupAgo": "Letzte Sicherung {age}",
"noBackupYet": "noch keine Sicherung",
"verifyState": "Letzte Prüfung"
"verifyOk": "Prüfung in Ordnung",
"verifyFailed": "Prüfung fehlgeschlagen",
"verifyUnknown": "Prüfung unbekannt",
"noDatastores": "Keine Datenspeicher gemeldet."
},
"pmg": {
"countIn": "Eingehend",
@@ -746,6 +758,21 @@
"virusCount": "Viren"
}
},
"health": {
"ok": "In Ordnung",
"warn": "Warnung",
"down": "Nicht erreichbar",
"idle": "Noch nicht abgefragt",
"orphan": "Offline & verwaist"
},
"legend": {
"ok": "{count, plural, one {in Ordnung} other {in Ordnung}}",
"warn": "{count, plural, one {mit Warnung} other {mit Warnung}}",
"down": "{count, plural, one {nicht erreichbar} other {nicht erreichbar}}",
"idle": "{count, plural, one {noch nicht abgefragt} other {noch nicht abgefragt}}",
"orphan": "{count, plural, one {offline & verwaist} other {offline & verwaist}}",
"barLabel": "Zustand der Server: {summary}"
},
"errors": {
"netz": "Der Server ist nicht erreichbar. Bitte prüfen Sie die Adresse und die Netzwerkverbindung.",
"zugang": "Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.",
+34 -7
View File
@@ -720,24 +720,36 @@
"emptyState": "No server configured yet. Add one in the module settings.",
"card": {
"unknownValue": "unknown",
"lastPolledLabel": "Last poll",
"lastPolledAgo": "Last poll {age}",
"notPolledYet": "No poll has run yet. Click \"{refreshLabel}\" above.",
"notPolledYetAutomatic": "No poll has run yet. The values will appear after the next automatic poll.",
"lastOkLabel": "Last successful measurement",
"lastOkAgo": "Last reachable {age}",
"orphanText": "This server is deactivated and is no longer polled.",
"orphanAdminHint": "You can reactivate it in the settings.",
"refresh": "Refresh now",
"refreshing": "Refreshing...",
"settingsLink": "Go to settings",
"settingsNav": "Settings",
"product": {
"pve": "Virtualization",
"pbs": "Backup",
"pmg": "Mail gateway"
},
"pve": {
"nodeCount": "Nodes",
"guests": "{running} running / {stopped} stopped",
"cpu": "CPU load",
"running": "{count, plural, one {guest running} other {guests running}}",
"stopped": "stopped",
"nodes": "{count, plural, one {# node} other {# nodes}}",
"cpu": "CPU",
"mem": "Memory"
},
"pbs": {
"used": "Used",
"lastBackup": "Last backup",
"lastBackupAgo": "Last backup {age}",
"noBackupYet": "no backup yet",
"verifyState": "Last verification"
"verifyOk": "Verification OK",
"verifyFailed": "Verification failed",
"verifyUnknown": "Verification unknown",
"noDatastores": "No datastores reported."
},
"pmg": {
"countIn": "Inbound",
@@ -746,6 +758,21 @@
"virusCount": "Viruses"
}
},
"health": {
"ok": "Healthy",
"warn": "Warning",
"down": "Unreachable",
"idle": "Not polled yet",
"orphan": "Offline & orphaned"
},
"legend": {
"ok": "{count, plural, one {healthy} other {healthy}}",
"warn": "{count, plural, one {with warning} other {with warnings}}",
"down": "{count, plural, one {unreachable} other {unreachable}}",
"idle": "{count, plural, one {not polled yet} other {not polled yet}}",
"orphan": "{count, plural, one {offline & orphaned} other {offline & orphaned}}",
"barLabel": "Server health: {summary}"
},
"errors": {
"netz": "The server is unreachable. Please check the address and the network connection.",
"zugang": "Access was denied. Please check the username and password, or the token details.",
@@ -189,4 +189,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'gemessene',
'Messung',
'Prozessorlast',
// quick-260924-h7x: Proxmox-Seite neu gestaltet — korrektes Deutsch mit „ss“
'Prozessor',
'Arbeitsspeicher',
];