From 0fa7ce0f44db56663a151e41ec464b0eb4e871c9 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 24 Sep 2026 12:27:05 +0200 Subject: [PATCH] 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) --- .../proxmox/components/proxmox-status.test.ts | 275 ++++++++++++++++++ .../proxmox/components/proxmox-status.ts | 146 ++++++++++ 2 files changed, 421 insertions(+) create mode 100644 apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.test.ts create mode 100644 apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts diff --git a/apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.test.ts b/apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.test.ts new file mode 100644 index 0000000..f856f79 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.test.ts @@ -0,0 +1,275 @@ +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 { + 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 { + 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 { + 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 & Record): 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'); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts b/apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts new file mode 100644 index 0000000..20d996c --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts @@ -0,0 +1,146 @@ +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 { + const counts: Record = { 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'); +}