import type { ProxmoxErrorKind, ProxmoxPbsDatastoreMetric, ProxmoxPbsMetrics, ProxmoxPmgMetrics, ProxmoxPveMetrics, ProxmoxPveNodeMetric, } from './proxmox.types'; /** * Nachsichtige Leser als reine Funktionen ohne Datenbankbezug — der * gesamte Umgang mit einer unerwarteten Form ist ein Rueckgabewert * (`null`/leere Liste), NIE eine Ausnahme. Der Nutzer prueft dieses Modul * ausschliesslich an seinen eigenen, echten Servern; ein Wurf wuerde ihm * eine leere Seite zeigen statt eines ehrlichen "unbekannt". */ export function readNumber(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } return null; } export function readText(value: unknown): string | null { if (typeof value === 'string' && value.trim() !== '') return value; if (typeof value === 'number' && Number.isFinite(value)) return String(value); return null; } export function readBool(value: unknown): boolean | null { if (typeof value === 'boolean') return value; if (value === 'true' || value === 1 || value === '1') return true; if (value === 'false' || value === 0 || value === '0') return false; return null; } /** Liefert bei allem, was kein Array ist, eine LEERE Liste — nie einen Wurf. */ export function readList(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } /** Nimmt den ersten VORHANDENEN Schluessel einer Namensliste (mehrere plausible Namen, in Reihenfolge). */ function readFirstPresent(record: Record, keys: readonly string[]): unknown { for (const key of keys) { if (key in record && record[key] !== undefined) return record[key]; } return undefined; } export interface NormalizeResult { metrics: TMetrics; errorKind: ProxmoxErrorKind | null; } // --------------------------------------------------------------------------- // PVE — /api2/json/cluster/resources // --------------------------------------------------------------------------- function emptyPveMetrics(): ProxmoxPveMetrics { return { productType: 'pve', nodeCount: 0, guestsRunning: 0, guestsStopped: 0, nodes: [], storages: [], }; } export function normalizePve(body: unknown): NormalizeResult { if (!isRecord(body)) { // Ganze Antwort ist Zeichenkette/Array/null/leer — leeres Messwertobjekt, kein Wurf. return { metrics: emptyPveMetrics(), errorKind: 'antwortform' }; } const list = readList(body.data); const isEntry = (e: unknown): e is Record => isRecord(e); const nodeEntries = list.filter((e) => isEntry(e) && e.type === 'node') as Record[]; const guestEntries = list.filter( (e) => isEntry(e) && (e.type === 'qemu' || e.type === 'lxc'), ) as Record[]; const storageEntries = list.filter((e) => isEntry(e) && e.type === 'storage') as Record< string, unknown >[]; const running = guestEntries.filter((g) => g.status === 'running').length; const nodes: ProxmoxPveNodeMetric[] = nodeEntries.map((n) => ({ node: readText(n.node) ?? 'unbekannt', cpu: readNumber(n.cpu), maxcpu: readNumber(n.maxcpu), mem: readNumber(n.mem), maxmem: readNumber(n.maxmem), })); const storages = storageEntries.map((s) => ({ storage: readText(s.storage) ?? 'unbekannt', node: readText(s.node) ?? 'unbekannt', disk: readNumber(s.disk), maxdisk: readNumber(s.maxdisk), })); return { metrics: { productType: 'pve', nodeCount: nodeEntries.length, guestsRunning: running, guestsStopped: guestEntries.length - running, nodes, storages, }, errorKind: null, }; } // --------------------------------------------------------------------------- // PBS — /api2/json/status/datastore-usage + je Datenspeicher .../snapshots // --------------------------------------------------------------------------- /** * Feldnamen der PBS-Belegungsabfrage sind aus der Recherche nur ABGELEITET * (Annahme A3, Forenbeleg, kein Primaerbeleg) — GENAU DIESE Konstante ist * anzupassen, wenn ein echter PBS-Server andere Namen liefert. */ const PBS_USAGE_FIELDS = { store: ['store', 'name'], total: ['total'], used: ['used'], free: ['avail', 'free'], } as const; /** Dieselbe Annahme A3 fuer die Sicherungsliste eines Datenspeichers. */ const PBS_SNAPSHOT_FIELDS = { backupTime: ['backup-time', 'backupTime'], verifyState: ['verification', 'verify-state', 'verifyState'], } as const; function emptyPbsMetrics(): ProxmoxPbsMetrics { return { productType: 'pbs', datastores: [] }; } /** * Nur die Datenspeichernamen aus der Belegungsantwort — fuer den Deckel * der Folgeabfragen in `proxmox.service.ts` (Aufgabe 3, hoechstens 10 je * Durchlauf). Dieselbe Feldnamen-Konstante wie `normalizePbs`, damit es * EINE Stelle zum Nachziehen gibt, nicht zwei. */ export function listPbsDatastoreNames(usage: unknown): string[] { if (!isRecord(usage)) return []; return readList(usage.data) .filter(isRecord) .map((entry) => readText(readFirstPresent(entry, PBS_USAGE_FIELDS.store))) .filter((name): name is string => name !== null); } function readVerifyState(value: unknown): string | null { // `verification` kann selbst ein Objekt sein ({ state: 'ok', ... }) oder // direkt eine Zeichenkette — beide Formen kommen in Forenbeispielen vor. if (isRecord(value)) { const state = readFirstPresent(value, ['state', 'result']); return readText(state); } return readText(value); } /** * `usage` ist die Antwort von `/status/datastore-usage`; `snapshotsByStore` * bildet je Datenspeichernamen die (bereits abgefragte) Rohantwort seiner * `/admin/datastore/{store}/snapshots`-Abfrage ab — `undefined`, wenn der * Deckel von hoechstens 10 Folgeabfragen je Durchlauf (`proxmox.service.ts`) * diesen Speicher nicht mehr erreicht hat. */ export function normalizePbs( usage: unknown, snapshotsByStore: Record, ): NormalizeResult { if (!isRecord(usage)) { return { metrics: emptyPbsMetrics(), errorKind: 'antwortform' }; } const entries = readList(usage.data).filter(isRecord); const datastores: ProxmoxPbsDatastoreMetric[] = entries.map((entry) => { const name = readText(readFirstPresent(entry, PBS_USAGE_FIELDS.store)) ?? 'unbekannt'; const snapshotsBody = snapshotsByStore[name]; const snapshotList = isRecord(snapshotsBody) ? readList(snapshotsBody.data).filter(isRecord) : []; let lastBackupAt: number | null = null; let lastVerifyState: string | null = null; for (const snapshot of snapshotList) { const backupTime = readNumber(readFirstPresent(snapshot, PBS_SNAPSHOT_FIELDS.backupTime)); if (backupTime !== null && (lastBackupAt === null || backupTime > lastBackupAt)) { lastBackupAt = backupTime; lastVerifyState = readVerifyState(readFirstPresent(snapshot, PBS_SNAPSHOT_FIELDS.verifyState)); } } // Kein Eintrag in der Liste (leer, aber kein Fehler): "noch keine // Sicherung" — Frontend (Aufgabe 6) unterscheidet das ueber // `snapshotList.length === 0`, hier bleibt der Wert ehrlich `null`. return { name, total: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.total)), used: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.used)), free: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.free)), lastBackupAt, lastVerifyState, }; }); return { metrics: { productType: 'pbs', datastores }, errorKind: null }; } // --------------------------------------------------------------------------- // PMG — /api2/json/statistics/mail // --------------------------------------------------------------------------- /** Annahme A5 der Recherche — abgeleitet aus `pmgsh`-Community-Belegen, nicht aus Primaerdoku. */ const PMG_STATS_FIELDS = { countIn: ['count_in'], countOut: ['count_out'], spamIn: ['spamcount_in'], spamOut: ['spamcount_out'], virusIn: ['viruscount_in'], virusOut: ['viruscount_out'], } as const; function emptyPmgMetrics(): ProxmoxPmgMetrics { return { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null }; } function sumOrNull(a: number | null, b: number | null): number | null { if (a === null && b === null) return null; return (a ?? 0) + (b ?? 0); } export function normalizePmg(body: unknown): NormalizeResult { if (!isRecord(body)) { return { metrics: emptyPmgMetrics(), errorKind: 'antwortform' }; } const stats = isRecord(body.data) ? body.data : {}; const countIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.countIn)); const countOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.countOut)); const spamIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.spamIn)); const spamOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.spamOut)); const virusIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.virusIn)); const virusOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.virusOut)); return { metrics: { productType: 'pmg', countIn, countOut, spamCount: sumOrNull(spamIn, spamOut), virusCount: sumOrNull(virusIn, virusOut), }, errorKind: null, }; }