Files
tessera-ctl/apps/api/src/proxmox/proxmox-normalize.ts
T
schalli 998aba9ef3 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>
2026-09-23 10:28:25 +02:00

270 lines
9.4 KiB
TypeScript

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<string, unknown> {
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<string, unknown>, keys: readonly string[]): unknown {
for (const key of keys) {
if (key in record && record[key] !== undefined) return record[key];
}
return undefined;
}
export interface NormalizeResult<TMetrics> {
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<ProxmoxPveMetrics> {
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<string, unknown> => isRecord(e);
const nodeEntries = list.filter((e) => isEntry(e) && e.type === 'node') as Record<string, unknown>[];
const guestEntries = list.filter(
(e) => isEntry(e) && (e.type === 'qemu' || e.type === 'lxc'),
) as Record<string, unknown>[];
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<string, unknown>,
): NormalizeResult<ProxmoxPbsMetrics> {
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<ProxmoxPmgMetrics> {
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,
};
}