feat(quick-261005-bt1): Proxmox warnt bei gestopptem Gast mit aktivem Autostart

Fuer jeden nicht laufenden qemu/lxc (ohne Vorlage) liest der PVE-Durchlauf
onboot aus /nodes/{node}/{type}/{vmid}/config (nur GET, Deckel 40).
Befund in den Metriken (autostartStopped, autostartUnchecked); Karte wird
Warnung mit einer Zeile je Gast, Kachel zeigt 'gestoppt trotz Autostart'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-05 08:30:20 +02:00
parent 781bc9f9ee
commit 873d15d25e
21 changed files with 455 additions and 21 deletions
@@ -188,6 +188,15 @@ describe('widgetKeyFigure', () => {
});
});
it('quick-261005: gestoppte Gaeste mit Autostart -> autostart mit Anzahl, Vorrang vor Auslastung', () => {
const guest = { vmid: 101, name: null, type: 'lxc' as const, node: 'n1', status: 'stopped' };
const metrics = pve({
nodes: [{ node: 'n1', cpu: 0.95, maxcpu: 8, mem: 1, maxmem: 10 }],
autostartStopped: [guest, { ...guest, vmid: 102 }],
});
expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'autostart', count: 2 });
});
it('PVE ok ohne Gaeste -> noGuests', () => {
expect(
widgetKeyFigure(server({ metrics: pve({ guestsRunning: 0, guestsStopped: 0 }) }), NOW),
@@ -1,4 +1,5 @@
import {
autostartAlerts,
HEALTH_ORDER,
isBackupStale,
type MeterLevel,
@@ -84,6 +85,7 @@ export type KeyFigure =
| { kind: 'status'; health: 'down' | 'idle' | 'orphan' }
| { kind: 'guests'; running: number; total: number }
| { kind: 'noGuests' }
| { kind: 'autostart'; count: number }
| { kind: 'load'; fraction: number; level: MeterLevel }
| { kind: 'backup'; at: number; stale: boolean }
| { kind: 'noBackup' }
@@ -124,6 +126,10 @@ export function widgetKeyFigure(server: ProxmoxServer, now: number = Date.now())
if (metrics === null) return { kind: 'unknown' };
if (metrics.productType === 'pve') {
// quick-261005: ein gestoppter Gast mit Autostart wiegt schwerer als
// hohe Auslastung — er ist vermutlich ungewollt aus.
const stoppedWithAutostart = autostartAlerts(metrics).length;
if (stoppedWithAutostart > 0) return { kind: 'autostart', count: stoppedWithAutostart };
if (health === 'warn') {
const fraction = maxKnown([
...metrics.nodes.map((n) => (isKnownNumber(n.cpu) ? n.cpu : null)),
@@ -167,6 +167,24 @@ describe('ProxmoxWidget: Balken und Zusammenfassung (quick-260924-i8v)', () => {
expect(summary).toHaveClass('text-status-ok-fg');
});
it('quick-261005: gestoppter Gast mit Autostart -> Warnung und Kennzahl in Warnfarbe', async () => {
mockListServers.mockResolvedValue([
makeServer('ok', {
metrics: pveMetrics({
autostartStopped: [
{ vmid: 101, name: 'dc01', type: 'qemu', node: 'n1', status: 'stopped' },
],
}),
}),
]);
await renderWidget();
const figure = await screen.findByTestId('proxmox-key-figure');
expect(figure).toHaveTextContent('1 gestoppt trotz Autostart');
expect(figure).toHaveClass('text-status-warn-fg');
expect(screen.getByTestId('proxmox-summary')).not.toHaveTextContent('Alles in Ordnung');
});
it('je ein down, warn, ok: „1 nicht erreichbar, 1 mit Warnung“ in der Farbe von down', async () => {
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('warn'), makeServer('down')]);
await renderWidget();
@@ -282,6 +282,8 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
return t('proxmox.guests', { running: figure.running, total: figure.total });
case 'noGuests':
return t('proxmox.noGuests');
case 'autostart':
return t('proxmox.autostartStopped', { count: figure.count });
case 'load':
return t('proxmox.load', { percent: formatPercent(figure.fraction, locale) });
case 'backup': {
@@ -300,6 +302,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
function keyFigureColor(figure: KeyFigure): string {
if (figure.kind === 'status') return HEALTH_STYLE[figure.health].text;
if (figure.kind === 'load') return METER_TEXT[figure.level];
if (figure.kind === 'autostart') return HEALTH_STYLE.warn.text;
if (figure.kind === 'backup' && figure.stale) return HEALTH_STYLE.warn.text;
return 'text-muted-foreground';
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ProxmoxMetrics, ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
import {
autostartAlerts,
formatAge,
formatCount,
formatPercent,
@@ -320,3 +321,25 @@ describe('formatPercent / formatCount', () => {
expect(formatCount(12904, 'de')).toBe('12.904');
});
});
describe('Autostart-Warnung (quick-261005)', () => {
const guest = { vmid: 101, name: 'dc01', type: 'qemu' as const, node: 'n0', status: 'stopped' };
it('gestoppter Gast mit Autostart macht einen sonst gesunden PVE-Server zu warn', () => {
const metrics = { ...pve([{ cpu: 0.1, mem: 1, maxmem: 10 }]), autostartStopped: [guest] };
expect(serverHealth(withMetrics(metrics), NOW)).toBe('warn');
});
it('leere Liste, fehlendes Feld (alter Zwischenlagerstand) und nur ungepruefte Gaeste bleiben ok', () => {
const base = pve([{ cpu: 0.1, mem: 1, maxmem: 10 }]);
expect(serverHealth(withMetrics({ ...base, autostartStopped: [] }), NOW)).toBe('ok');
expect(serverHealth(withMetrics(base), NOW)).toBe('ok');
expect(serverHealth(withMetrics({ ...base, autostartUnchecked: 3 }), NOW)).toBe('ok');
});
it('autostartAlerts liefert ohne Feld eine leere Liste', () => {
const base = pve([]) as Extract<ProxmoxMetrics, { productType: 'pve' }>;
expect(autostartAlerts(base)).toEqual([]);
expect(autostartAlerts({ ...base, autostartStopped: [guest] })).toEqual([guest]);
});
});
@@ -1,4 +1,9 @@
import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
import type {
ProxmoxMetrics,
ProxmoxPveAutostartGuest,
ProxmoxPveMetrics,
ProxmoxServer,
} from '@/lib/proxmox-api';
/**
* Statuslogik der Proxmox-Seite (quick-260924-h7x) — reine Funktionen ohne
@@ -77,6 +82,14 @@ function isHigh(fraction: number | null): boolean {
return fraction !== null && fraction >= THRESHOLDS.usageWarn;
}
/**
* Gaeste, die nicht laufen, obwohl Autostart aktiv ist (quick-261005).
* Aeltere Zwischenlagerstaende ohne das Feld liefern eine leere Liste.
*/
export function autostartAlerts(metrics: ProxmoxPveMetrics): ProxmoxPveAutostartGuest[] {
return Array.isArray(metrics.autostartStopped) ? metrics.autostartStopped : [];
}
/** Liegt irgendein Messwert ueber seiner Warnschwelle? */
function metricsNeedAttention(metrics: ProxmoxMetrics, now: number): boolean {
if (metrics.productType === 'pve') {
@@ -84,7 +97,7 @@ function metricsNeedAttention(metrics: ProxmoxMetrics, now: number): boolean {
(n) => isHigh(isKnownNumber(n.cpu) ? n.cpu : null) || isHigh(ratio(n.mem, n.maxmem)),
);
const storageWarn = metrics.storages.some((s) => isHigh(ratio(s.disk, s.maxdisk)));
return nodeWarn || storageWarn;
return nodeWarn || storageWarn || autostartAlerts(metrics).length > 0;
}
if (metrics.productType === 'pbs') {
return metrics.datastores.some(