feat(260923-dhh): Proxmox-Modul Aufgabe 3 - PBS und PMG auswerten

- proxmox-normalize.ts: nachsichtige Leser (readNumber/readText/readBool/
  readList) und normalizePve/normalizePbs/normalizePmg als reine
  Funktionen, nie ein Wurf bei unerwarteter Form
- PVE ergaenzt um je Speicherort Belegung (storages)
- PBS: Belegung je Datenspeicher plus letzte Sicherung/Pruefergebnis aus
  bis zu 10 Folgeabfragen je Durchlauf (Deckel in proxmox.service.ts)
- PMG: Tageszahlen eingehend/ausgehend/Spam/Viren
- Feldnamen je Produkt als benannte Konstante (Annahmen A3/A5 der
  Recherche), mehrere plausible Namen je Feld moeglich
- proxmox.service.ts: produktabhaengige Abfragefolge, Ticket-Erneuerung
  jetzt je Durchlauf statt je Aufruf (PBS-Mehrfachabfragen loggen nicht
  mehrfach neu ein)
- proxmox-nur-lesen.spec.ts: Riegel erkennt jetzt auch den Umschlag
  getWithRetry als zulaessige Aufrufform

Tore: api 1293/1293 (>=1240), type-check 4/4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:28:25 +02:00
parent 4f8a368c9e
commit 998aba9ef3
6 changed files with 750 additions and 97 deletions
@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest';
import {
listPbsDatastoreNames,
normalizePbs,
normalizePmg,
normalizePve,
readBool,
readList,
readNumber,
readText,
} from './proxmox-normalize';
describe('nachsichtige Leser (Aufgabe 3, <behavior>)', () => {
it('readNumber: Zahl, umwandelbare Zeichenkette, sonst null', () => {
expect(readNumber(42)).toBe(42);
expect(readNumber('42')).toBe(42);
expect(readNumber('0.37')).toBe(0.37);
expect(readNumber('nicht-umwandelbar')).toBeNull();
expect(readNumber(undefined)).toBeNull();
expect(readNumber(null)).toBeNull();
expect(readNumber(Number.NaN)).toBeNull();
});
it('readText: nichtleere Zeichenkette oder Zahl, sonst null', () => {
expect(readText('hallo')).toBe('hallo');
expect(readText(42)).toBe('42');
expect(readText('')).toBeNull();
expect(readText(null)).toBeNull();
expect(readText(undefined)).toBeNull();
});
it('readBool: boolesch oder gaengige Wahr/Falsch-Formen, sonst null', () => {
expect(readBool(true)).toBe(true);
expect(readBool('true')).toBe(true);
expect(readBool(1)).toBe(true);
expect(readBool(false)).toBe(false);
expect(readBool('false')).toBe(false);
expect(readBool('irgendwas')).toBeNull();
});
it('readList: alles, was kein Array ist, wird eine leere Liste', () => {
expect(readList([1, 2])).toEqual([1, 2]);
expect(readList('kein-array')).toEqual([]);
expect(readList(null)).toEqual([]);
expect(readList(undefined)).toEqual([]);
expect(readList({})).toEqual([]);
});
});
describe('normalizePve (Aufgabe 3, <behavior>)', () => {
it('Knotenzahl, laufende/gestoppte Gaeste, je Knoten Prozessorlast/Speicher, je Speicherort Belegung', () => {
const body = {
data: [
{ type: 'node', node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
{ type: 'node', node: 'pve2', cpu: 0.1, maxcpu: 4, mem: 1_000_000_000, maxmem: 8_000_000_000 },
{ type: 'qemu', node: 'pve1', status: 'running' },
{ type: 'qemu', node: 'pve1', status: 'stopped' },
{ type: 'lxc', node: 'pve2', status: 'running' },
{ type: 'storage', node: 'pve1', storage: 'local-lvm', disk: 100, maxdisk: 500 },
],
};
const { metrics, errorKind } = normalizePve(body);
expect(errorKind).toBeNull();
expect(metrics.nodeCount).toBe(2);
expect(metrics.guestsRunning).toBe(2);
expect(metrics.guestsStopped).toBe(1);
expect(metrics.nodes).toEqual([
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
{ node: 'pve2', cpu: 0.1, maxcpu: 4, mem: 1_000_000_000, maxmem: 8_000_000_000 },
]);
expect(metrics.storages).toEqual([
{ storage: 'local-lvm', node: 'pve1', disk: 100, maxdisk: 500 },
]);
});
it('Feld fehlt -> null, nie 0/Wurf', () => {
const body = { data: [{ type: 'node', node: 'pve1' }] };
expect(() => normalizePve(body)).not.toThrow();
const { metrics } = normalizePve(body);
expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null });
});
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
const body = { data: [{ type: 'node', node: 'pve1', cpu: '0.5', maxcpu: '4', mem: '100', maxmem: '200' }] };
const { metrics } = normalizePve(body);
expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: 0.5, maxcpu: 4, mem: 100, maxmem: 200 });
});
it('Antwort ist HTML statt JSON-Objekt (hier: eine Zeichenkette) -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
expect(() => normalizePve('<html>Anmeldeseite</html>')).not.toThrow();
const { metrics, errorKind } = normalizePve('<html>Anmeldeseite</html>');
expect(errorKind).toBe('antwortform');
expect(metrics).toEqual({
productType: 'pve',
nodeCount: 0,
guestsRunning: 0,
guestsStopped: 0,
nodes: [],
storages: [],
});
});
it('Antwort ist ein Array statt eines Objekts -> antwortform, kein Wurf', () => {
const { errorKind } = normalizePve([1, 2, 3]);
expect(errorKind).toBe('antwortform');
});
it('Antwort ist null -> antwortform, kein Wurf', () => {
const { errorKind } = normalizePve(null);
expect(errorKind).toBe('antwortform');
});
});
describe('normalizePbs (Aufgabe 3, <behavior>)', () => {
it('je Datenspeicher Gesamt/Belegt/Frei, letzter Sicherungszeitpunkt und letztes Pruefergebnis', () => {
const usage = {
data: [{ store: 'backup-store', total: 1000, used: 400, avail: 600 }],
};
const snapshotsByStore = {
'backup-store': {
data: [
{ 'backup-time': 1000, verification: { state: 'ok' } },
{ 'backup-time': 2000, verification: { state: 'failed' } },
],
},
};
const { metrics, errorKind } = normalizePbs(usage, snapshotsByStore);
expect(errorKind).toBeNull();
expect(metrics.datastores).toEqual([
{
name: 'backup-store',
total: 1000,
used: 400,
free: 600,
lastBackupAt: 2000,
lastVerifyState: 'failed',
},
]);
});
it('ein Datenspeicher ohne Sicherungen ergibt null (Frontend zeigt "noch keine Sicherung") und keinen Fehler', () => {
const usage = { data: [{ store: 'leer', total: 10, used: 0, avail: 10 }] };
const { metrics, errorKind } = normalizePbs(usage, { leer: { data: [] } });
expect(errorKind).toBeNull();
expect(metrics.datastores[0]).toMatchObject({ lastBackupAt: null, lastVerifyState: null });
});
it('Feld fehlt -> null, nie 0/Wurf', () => {
const usage = { data: [{ store: 'x' }] };
expect(() => normalizePbs(usage, {})).not.toThrow();
const { metrics } = normalizePbs(usage, {});
expect(metrics.datastores[0]).toEqual({
name: 'x',
total: null,
used: null,
free: null,
lastBackupAt: null,
lastVerifyState: null,
});
});
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
const usage = { data: [{ store: 'x', total: '1000', used: '400', avail: '600' }] };
const { metrics } = normalizePbs(usage, {});
expect(metrics.datastores[0]).toMatchObject({ total: 1000, used: 400, free: 600 });
});
it('Antwort ist HTML statt JSON -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
expect(() => normalizePbs('<html></html>', {})).not.toThrow();
const { metrics, errorKind } = normalizePbs('<html></html>', {});
expect(errorKind).toBe('antwortform');
expect(metrics.datastores).toEqual([]);
});
it('ein PBS-Server mit vielen Datenspeichern: listPbsDatastoreNames liefert alle Namen (Deckel lebt in proxmox.service.ts)', () => {
const usage = { data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}` })) };
expect(listPbsDatastoreNames(usage)).toHaveLength(15);
});
});
describe('normalizePmg (Aufgabe 3, <behavior>)', () => {
it('Tageszahlen eingehend, ausgehend, Spam, Viren', () => {
const body = {
data: {
count_in: 100,
count_out: 50,
spamcount_in: 10,
spamcount_out: 2,
viruscount_in: 1,
viruscount_out: 0,
},
};
const { metrics, errorKind } = normalizePmg(body);
expect(errorKind).toBeNull();
expect(metrics).toEqual({
productType: 'pmg',
countIn: 100,
countOut: 50,
spamCount: 12,
virusCount: 1,
});
});
it('Feld fehlt -> null, nie 0/Wurf', () => {
const body = { data: {} };
expect(() => normalizePmg(body)).not.toThrow();
const { metrics } = normalizePmg(body);
expect(metrics).toEqual({
productType: 'pmg',
countIn: null,
countOut: null,
spamCount: null,
virusCount: null,
});
});
it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => {
const body = { data: { count_in: '100', count_out: '50' } };
const { metrics } = normalizePmg(body);
expect(metrics.countIn).toBe(100);
expect(metrics.countOut).toBe(50);
});
it('Antwort ist HTML statt JSON -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => {
expect(() => normalizePmg('<html></html>')).not.toThrow();
const { metrics, errorKind } = normalizePmg('<html></html>');
expect(errorKind).toBe('antwortform');
expect(metrics).toEqual({
productType: 'pmg',
countIn: null,
countOut: null,
spamCount: null,
virusCount: null,
});
});
});