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:
@@ -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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user