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 new file mode 100644 index 0000000..317de8d --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx @@ -0,0 +1,255 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ProxmoxServer } from '@/lib/proxmox-api'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + const translations: Record = { + 'card.unknownValue': 'unbekannt', + 'card.lastPolledLabel': 'Letzte Abfrage', + '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; + }, +})); + +afterEach(() => { + cleanup(); +}); + +function makeServer(overrides: Partial = {}): ProxmoxServer { + return { + id: 'srv-1', + tenantId: 't1', + name: 'pve-1', + productType: 'pve', + baseUrl: 'https://pve.intern', + authMethod: 'token', + tokenId: 'root@pam!x', + username: null, + tlsRejectUnauthorized: true, + isActive: true, + pollIntervalMin: 5, + position: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: null, + ...overrides, + }; +} + +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: [], + }, + }, + }); + + render(); + + 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', + 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-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('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(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx new file mode 100644 index 0000000..ba6e403 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import type { + ProxmoxErrorKind, + ProxmoxPbsMetrics, + ProxmoxPmgMetrics, + ProxmoxPveMetrics, + ProxmoxServer, +} from '@/lib/proxmox-api'; + +/** + * Wandelt einen rohen Byte-Wert in eine lesbare Form (B/KB/MB/GB/TB). + */ +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = -1; + do { + value /= 1024; + unitIndex++; + } while (value >= 1024 && unitIndex < units.length - 1); + return `${value.toFixed(1)} ${units[unitIndex]}`; +} + +/** Prozentwert einer Auslastung (0..1) gerundet, z. B. `25%`. */ +function formatPercent(fraction: number): string { + return `${Math.round(fraction * 100)}%`; +} + +/** + * 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". + */ +function formatMetric( + value: number | string | null | undefined, + unknownLabel: string, + formatter?: (v: number) => string, +): string { + if (value === null || value === undefined) return unknownLabel; + if (typeof value === 'number') { + if (Number.isNaN(value)) return unknownLabel; + return formatter ? formatter(value) : String(value); + } + return value; +} + +function 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} +
+
+ {t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })} +
+ {metrics.nodes.map((node) => ( +
+
{node.node}
+
+ {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)}` + : ''} +
+
+ ))} +
+ ); +} + +function PbsMetricsView({ metrics, unknown }: { metrics: ProxmoxPbsMetrics; unknown: string }) { + const t = useTranslations('proxmox'); + return ( +
+ {metrics.datastores.map((ds) => ( +
+
{ds.name}
+
+ {t('card.pbs.used')}: {formatMetric(ds.used, unknown, formatBytes)} + {ds.total !== null && ds.used !== null ? ` / ${formatMetric(ds.total, unknown, formatBytes)}` : ''} +
+
+ {t('card.pbs.lastBackup')}:{' '} + {ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)} +
+
+ {t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)} +
+
+ ))} +
+ ); +} + +function PmgMetricsView({ metrics, unknown }: { metrics: ProxmoxPmgMetrics; unknown: string }) { + const t = useTranslations('proxmox'); + return ( +
+
+ {t('card.pmg.countIn')}: {formatMetric(metrics.countIn, unknown)} +
+
+ {t('card.pmg.countOut')}: {formatMetric(metrics.countOut, unknown)} +
+
+ {t('card.pmg.spamCount')}: {formatMetric(metrics.spamCount, unknown)} +
+
+ {t('card.pmg.virusCount')}: {formatMetric(metrics.virusCount, unknown)} +
+
+ ); +} + +function MetricsView({ metrics, unknown }: MetricsViewProps) { + if (metrics.productType === 'pve') return ; + if (metrics.productType === 'pbs') return ; + return ; +} + +function errorMessage(t: ReturnType, kind: ProxmoxErrorKind | null): string { + return t(`errors.${kind ?? 'unbekannt'}`); +} + +interface ServerCardProps { + server: ProxmoxServer; +} + +/** + * Anzeige EINES Servers (Aufgabe 6) — verzweigt ueber `productType` auf der + * unterscheidbaren Union aus Aufgabe 3. + */ +export function ServerCard({ server }: ServerCardProps) { + const t = useTranslations('proxmox'); + const unknown = t('card.unknownValue'); + const status = server.status; + + return ( +
+
+
+
{server.name}
+
+ {server.productType} — {server.baseUrl} +
+
+
+ +
+ {t('card.lastPolledLabel')}: {formatTimestamp(status?.lastPolledAt ?? null, unknown)} +
+ + {status && !status.reachable && ( +
+

+ {errorMessage(t, status.errorKind)} + {status.errorDetail ? ` (${status.errorDetail})` : ''} +

+ {status.lastOkAt && ( +

+ {t('card.lastOkLabel')}: {formatTimestamp(status.lastOkAt, unknown)} +

+ )} +
+ )} + + {status?.reachable && status.metrics && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/proxmox/page.tsx b/apps/web/src/app/(portal)/modules/proxmox/page.tsx index cdb79e2..3a341e8 100644 --- a/apps/web/src/app/(portal)/modules/proxmox/page.tsx +++ b/apps/web/src/app/(portal)/modules/proxmox/page.tsx @@ -1,39 +1,75 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { listServers, type ProxmoxServer } from '@/lib/proxmox-api'; +import Link from 'next/link'; +import { useAuthStore } from '@/lib/stores/auth-store'; +import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api'; +import { ServerCard } from './components/ServerCard'; /** - * Modulseite (Aufgabe 1: duenne Liste — Name, Typ, Adresse; Aufgabe 6 - * ergaenzt die produktabhaengige Auslastungsanzeige ueber `ServerCard`). - * Liest ausschliesslich aus dem Zwischenlager, das `GET servers` liefert — - * kein Live-Zugriff bei Proxmox von hier aus (D-05). + * 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. */ export default function ProxmoxPage() { const t = useTranslations('proxmox'); + const user = useAuthStore((s) => s.user); + const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; + const [servers, setServers] = useState(null); const [error, setError] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + const reload = useCallback(() => { + listServers() + .then(setServers) + .catch(() => setError(t('loadError'))); + }, [t]); useEffect(() => { - let cancelled = false; - listServers() - .then((data) => { - if (!cancelled) setServers(data); - }) - .catch(() => { - if (!cancelled) setError(t('loadError')); - }); - return () => { - cancelled = true; - }; - }, [t]); + reload(); + }, [reload]); + + const handleRefresh = async () => { + if (!servers || servers.length === 0) return; + setIsRefreshing(true); + try { + await Promise.all(servers.map((server) => pollServer(server.id).catch(() => undefined))); + reload(); + } finally { + setIsRefreshing(false); + } + }; return (
-
-

{t('title')}

-

{t('description')}

+
+
+

{t('title')}

+

{t('description')}

+
+
+ {servers !== null && servers.length > 0 && ( + + )} + {isAdmin && ( + + {t('card.settingsLink')} + + )} +
{error &&

{error}

} @@ -45,23 +81,22 @@ export default function ProxmoxPage() { {!error && servers !== null && servers.length === 0 && (
{t('emptyState')} + {isAdmin && ( + <> + {' '} + + {t('card.settingsLink')} + + + )}
)} {!error && servers !== null && servers.length > 0 && (
    {servers.map((server) => ( -
  • -
    - {server.name} - - {server.productType} - -
    -

    {server.baseUrl}

    +
  • +
  • ))}
diff --git a/apps/web/src/lib/proxmox-api.ts b/apps/web/src/lib/proxmox-api.ts index fa43527..dd487d9 100644 --- a/apps/web/src/lib/proxmox-api.ts +++ b/apps/web/src/lib/proxmox-api.ts @@ -21,6 +21,54 @@ export type ProxmoxErrorKind = | 'server' | 'unbekannt'; +export interface ProxmoxPveNodeMetric { + node: string; + cpu: number | null; + maxcpu: number | null; + mem: number | null; + maxmem: number | null; +} + +export interface ProxmoxPveStorageMetric { + storage: string; + node: string; + disk: number | null; + maxdisk: number | null; +} + +export interface ProxmoxPveMetrics { + productType: 'pve'; + nodeCount: number; + guestsRunning: number; + guestsStopped: number; + nodes: ProxmoxPveNodeMetric[]; + storages: ProxmoxPveStorageMetric[]; +} + +export interface ProxmoxPbsDatastoreMetric { + name: string; + total: number | null; + used: number | null; + free: number | null; + lastBackupAt: number | null; + lastVerifyState: string | null; +} + +export interface ProxmoxPbsMetrics { + productType: 'pbs'; + datastores: ProxmoxPbsDatastoreMetric[]; +} + +export interface ProxmoxPmgMetrics { + productType: 'pmg'; + countIn: number | null; + countOut: number | null; + spamCount: number | null; + virusCount: number | null; +} + +export type ProxmoxMetrics = ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics; + export interface ProxmoxServerStatus { id: string; serverId: string; @@ -29,7 +77,7 @@ export interface ProxmoxServerStatus { reachable: boolean; errorKind: ProxmoxErrorKind | null; errorDetail: string | null; - metrics: unknown; + metrics: ProxmoxMetrics | null; rawSample: unknown; updatedAt: string; } @@ -72,7 +120,7 @@ export interface ProxmoxTestResult { reachable: boolean; errorKind: ProxmoxErrorKind | null; errorDetail: string | null; - metrics: unknown; + metrics: ProxmoxMetrics | null; rawSample: unknown; } diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 5c2c91f..86bb4d0 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -709,6 +709,32 @@ "loading": "Lade Serverliste...", "loadError": "Die Serverliste konnte nicht geladen werden.", "emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.", + "card": { + "unknownValue": "unbekannt", + "lastPolledLabel": "Letzte Abfrage", + "lastOkLabel": "Letzte erfolgreiche Messung", + "refresh": "Jetzt aktualisieren", + "refreshing": "Wird aktualisiert...", + "settingsLink": "Zu den Einstellungen", + "pve": { + "nodeCount": "Knoten", + "guests": "{running} laufend / {stopped} gestoppt", + "cpu": "Prozessorlast", + "mem": "Speicher" + }, + "pbs": { + "used": "Belegt", + "lastBackup": "Letzte Sicherung", + "noBackupYet": "noch keine Sicherung", + "verifyState": "Letzte Prüfung" + }, + "pmg": { + "countIn": "Eingehend", + "countOut": "Ausgehend", + "spamCount": "Spam", + "virusCount": "Viren" + } + }, "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.", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 7c62da6..c6b936b 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -709,6 +709,32 @@ "loading": "Loading server list...", "loadError": "Could not load the server list.", "emptyState": "No server configured yet. Add one in the module settings.", + "card": { + "unknownValue": "unknown", + "lastPolledLabel": "Last poll", + "lastOkLabel": "Last successful measurement", + "refresh": "Refresh now", + "refreshing": "Refreshing...", + "settingsLink": "Go to settings", + "pve": { + "nodeCount": "Nodes", + "guests": "{running} running / {stopped} stopped", + "cpu": "CPU load", + "mem": "Memory" + }, + "pbs": { + "used": "Used", + "lastBackup": "Last backup", + "noBackupYet": "no backup yet", + "verifyState": "Last verification" + }, + "pmg": { + "countIn": "Inbound", + "countOut": "Outbound", + "spamCount": "Spam", + "virusCount": "Viruses" + } + }, "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.", diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index fe0614f..73aadf1 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -187,4 +187,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ // quick-260923-dhh: Proxmox-Modul — korrektes Deutsch mit „ss“ 'bewusst', 'gemessene', + 'Messung', + 'Prozessorlast', ];