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:
@@ -0,0 +1,269 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user