998aba9ef3
- 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>
270 lines
9.4 KiB
TypeScript
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,
|
|
};
|
|
}
|