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>
This commit is contained in:
2026-09-24 12:27:05 +02:00
parent 3d266418fc
commit 0fa7ce0f44
2 changed files with 421 additions and 0 deletions
@@ -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> = {}): 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,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<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');
}