diff --git a/CHANGELOG.md b/CHANGELOG.md index 5722e45..cffcf32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ 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 ### Behoben diff --git a/apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx b/apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx new file mode 100644 index 0000000..088a1c7 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx @@ -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; +} + +/** + * 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 ( +
+
+ {entries.map((e) => ( +
+ ))} +
+ +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx index 1517371..61e28dc 100644 --- a/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx +++ b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx @@ -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) => { - const translations: Record = { - '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 }) => ( + + {children} + + ), })); 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( + + {ui} + , + ); +} + +function makeStatus(overrides: Partial = {}): 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 { return { id: 'srv-1', @@ -67,290 +72,393 @@ function makeServer(overrides: Partial = {}): ProxmoxServer { }; } -describe('ServerCard', () => { - it('PVE-Server zeigt Knotenzahl, laufende/gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung', async () => { - 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', - 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 }, - ], - storages: [], - }, - }, - }); +const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false }); - render(); +async function card(server: ProxmoxServer, isAdmin?: boolean) { + const { ServerCard } = await import('./ServerCard'); + renderDe(); + return screen.getByTestId('server-card'); +} - 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'); +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-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', - metrics: { - productType: 'pbs', - datastores: [ - { name: 'backup-store', total: 1000, used: 400, free: 600, lastBackupAt: 1758000000, lastVerifyState: 'ok' }, - ], - }, - }, - }); - - render(); - - 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 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('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', - metrics: { - productType: 'pbs', - datastores: [ - { name: 'leer', total: 10, used: 0, free: 10, lastBackupAt: null, lastVerifyState: null }, - ], - }, - }, - }); - - render(); - - expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung'); - expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt'); + 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('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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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'); + it('Fuss: „Letzte Abfrage vor 4 Min.“ relativ, exakter Zeitpunkt im title', async () => { + const el = await card(makeServer({ status: makeStatus() })); + const footer = within(el).getByTestId('last-polled'); + expect(footer).toHaveTextContent('Letzte Abfrage vor 4 Min.'); + expect(footer.getAttribute('title')).toBeTruthy(); + }); +}); + +describe('ServerCard — PVE', () => { + it('laufende und gestoppte Gaeste, Knotenzahl, je Knoten Prozessor und Arbeitsspeicher mit deutschem Zahlformat', async () => { + const el = await card( + makeServer({ + status: makeStatus({ + metrics: { + productType: 'pve', + nodeCount: 2, + guestsRunning: 3, + guestsStopped: 1, + nodes: [ + { node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 12 * 1024 ** 3, maxmem: 64 * 1024 ** 3 }, + ], + storages: [], + }, + }), + }), + ); + + expect(within(el).getByTestId('guests-running')).toHaveTextContent('3'); + expect(within(el).getByText('Gäste laufen')).toBeInTheDocument(); + expect(within(el).getByTestId('guests-stopped')).toHaveTextContent('1'); + expect(within(el).getByText('2 Knoten')).toBeInTheDocument(); + + const node = within(el).getByTestId('pve-node-pve1'); + const [cpu, mem] = within(node).getAllByTestId('meter'); + expect(cpu).toHaveTextContent('Prozessor'); + expect(cpu).toHaveTextContent(/25\s%/); + expect(cpu).toHaveAttribute('data-level', 'ok'); + expect(mem).toHaveTextContent('Arbeitsspeicher'); + expect(mem).toHaveTextContent(/19\s%/); + expect(mem).toHaveTextContent('12,0 / 64,0 GB'); + }); + + it('Auslastung ab 80 % faerbt den Balken als Warnung, ab 92 % kritisch, und die Karte wird „Warnung“', async () => { + const el = await card( + makeServer({ + status: makeStatus({ + metrics: { + productType: 'pve', + nodeCount: 1, + guestsRunning: 1, + guestsStopped: 0, + nodes: [{ node: 'pve1', cpu: 0.85, maxcpu: 8, mem: 95, maxmem: 100 }], + storages: [], + }, + }), + }), + ); + + expect(el).toHaveAttribute('data-health', 'warn'); + expect(within(el).getByTestId('status-pill')).toHaveTextContent('Warnung'); + const [cpu, mem] = within(el).getAllByTestId('meter'); + expect(cpu).toHaveAttribute('data-level', 'warn'); + expect(mem).toHaveAttribute('data-level', 'crit'); + }); + + it('unbekannte Knotenwerte erscheinen als „unbekannt“ — nie 0 %, nie NaN', async () => { + const el = await card( + makeServer({ + status: makeStatus({ + metrics: { + productType: 'pve', + nodeCount: 1, + guestsRunning: 0, + guestsStopped: 0, + nodes: [{ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null }], + storages: [], + }, + }), + }), + ); + + const [cpu, mem] = within(el).getAllByTestId('meter'); + expect(cpu).toHaveTextContent('unbekannt'); + expect(mem).toHaveTextContent('unbekannt'); + expect(cpu).toHaveAttribute('data-level', 'unknown'); + expect(el).not.toHaveTextContent(/0\s%/); + expect(el).not.toHaveTextContent('NaN'); + expect(el).toHaveAttribute('data-health', 'ok'); + }); +}); + +describe('ServerCard — PBS', () => { + it('je Datenspeicher Fuellstand, letzte Sicherung relativ und Pruefpille', async () => { + const el = await card( + makeServer({ + productType: 'pbs', + status: makeStatus({ + metrics: { + productType: 'pbs', + datastores: [ + { + name: 'backup-store', + total: 4 * 1024 ** 4, + used: 1.2 * 1024 ** 4, + free: 2.8 * 1024 ** 4, + lastBackupAt: NOW_S - 5 * 3600, + lastVerifyState: 'ok', + }, + ], + }, + }), + }), + ); + + const ds = within(el).getByTestId('pbs-datastore-backup-store'); + expect(within(ds).getByText('backup-store')).toBeInTheDocument(); + expect(within(ds).getByTestId('meter')).toHaveTextContent('1,2 / 4,0 TB'); + expect(within(ds).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 5 Std.'); + expect(within(ds).getByTestId('verify-pill')).toHaveTextContent('Prüfung in Ordnung'); + expect(el).toHaveAttribute('data-health', 'ok'); + }); + + it('Sicherung aelter als 26 h und fehlgeschlagene Pruefung -> Warnung', async () => { + const el = await card( + makeServer({ + productType: 'pbs', + status: makeStatus({ + metrics: { + productType: 'pbs', + datastores: [ + { + name: 'alt', + total: 100, + used: 10, + free: 90, + lastBackupAt: NOW_S - 30 * 3600, + lastVerifyState: 'failed', + }, + ], + }, + }), + }), + ); + + expect(el).toHaveAttribute('data-health', 'warn'); + expect(within(el).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 1 Tag'); + expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung: 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'); + 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(); }); }); diff --git a/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx index 97a849b..b19057f 100644 --- a/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx +++ b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx @@ -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; -/** 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 ( + + ); + } + if (productType === 'pbs') { + return ( + + ); } - 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'); return ( -
-
- {t('card.pve.nodeCount')}: {metrics.nodeCount} + + ); +} + +/** Statuspille: Punkt + Wort, Flaeche in Statusfarbe mit 12 %. Keine Daueranimation. */ +function StatusPill({ health, label }: { health: ServerHealth; label: string }) { + const style = HEALTH_STYLE[health]; + return ( + + + ); +} + +/** + * 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 ( +
+
+ {label} + + {known ? formatPercent(fraction, locale) : unknownLabel} +
-
- {t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })} +