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:
@@ -170,6 +170,47 @@ describe('ServerCard — PVE', () => {
|
||||
expect(mem).toHaveAttribute('data-level', 'crit');
|
||||
});
|
||||
|
||||
it('quick-261005: je gestopptem Gast mit Autostart eine Zeile, Karte wird „Warnung“; ungepruefte als Hinweis', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
status: makeStatus({
|
||||
metrics: {
|
||||
productType: 'pve',
|
||||
nodeCount: 2,
|
||||
guestsRunning: 1,
|
||||
guestsStopped: 3,
|
||||
nodes: [{ node: 'pve1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 100 }],
|
||||
storages: [],
|
||||
autostartStopped: [
|
||||
{ vmid: 101, name: 'dc01', type: 'qemu', node: 'pve1', status: 'stopped' },
|
||||
{ vmid: 201, name: null, type: 'lxc', node: 'pve2', status: 'paused' },
|
||||
],
|
||||
autostartUnchecked: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(el).toHaveAttribute('data-health', 'warn');
|
||||
const notice = within(el).getByTestId('autostart-notice');
|
||||
expect(notice).toHaveTextContent('2 Gäste laufen nicht, obwohl Autostart aktiv ist');
|
||||
expect(within(notice).getByTestId('autostart-guest-101')).toHaveTextContent(
|
||||
'VM 101 „dc01“auf pve1 · gestoppt',
|
||||
);
|
||||
expect(within(notice).getByTestId('autostart-guest-201')).toHaveTextContent(
|
||||
'LXC 201auf pve2 · angehalten',
|
||||
);
|
||||
expect(within(el).getByTestId('autostart-unchecked')).toHaveTextContent(
|
||||
'Bei 1 gestopptem Gast konnte der Autostart nicht geprüft werden.',
|
||||
);
|
||||
});
|
||||
|
||||
it('quick-261005: ohne Autostart-Befund keine Warnzeile (auch bei altem Zwischenlagerstand)', async () => {
|
||||
const el = await card(makeServer());
|
||||
expect(within(el).queryByTestId('autostart-notice')).toBeNull();
|
||||
expect(within(el).queryByTestId('autostart-unchecked')).toBeNull();
|
||||
});
|
||||
|
||||
it('unbekannte Knotenwerte erscheinen als „unbekannt“ — nie 0 %, nie NaN', async () => {
|
||||
const el = await card(
|
||||
makeServer({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import {
|
||||
autostartAlerts,
|
||||
formatAge,
|
||||
formatCount,
|
||||
formatPercent,
|
||||
@@ -162,6 +163,62 @@ function Meter({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* quick-261005: Warnung fuer jede VM bzw. jeden LXC, der nicht laeuft,
|
||||
* obwohl Autostart aktiv ist. Darunter, falls vorhanden, wie viele
|
||||
* gestoppte Gaeste nicht geprueft werden konnten — ehrlich statt still.
|
||||
*/
|
||||
function AutostartNotice({ metrics, t }: { metrics: ProxmoxPveMetrics; t: Translator }) {
|
||||
const alerts = autostartAlerts(metrics);
|
||||
const unchecked = metrics.autostartUnchecked ?? 0;
|
||||
if (alerts.length === 0 && unchecked === 0) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{alerts.length > 0 && (
|
||||
<div
|
||||
data-testid="autostart-notice"
|
||||
className="space-y-1.5 rounded-lg border-l-4 border-status-warn bg-status-warn/8 px-4 py-3"
|
||||
>
|
||||
<p className={`text-sm font-medium ${HEALTH_STYLE.warn.text}`}>
|
||||
{t('card.pve.autostartTitle', { count: alerts.length })}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{alerts.map((guest) => (
|
||||
<li
|
||||
key={`${guest.node}-${guest.vmid}`}
|
||||
data-testid={`autostart-guest-${guest.vmid}`}
|
||||
className="flex flex-wrap items-baseline gap-x-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t(`card.pve.guestType.${guest.type}`)} {guest.vmid}
|
||||
{guest.name ? ` „${guest.name}“` : ''}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('card.pve.autostartGuestDetail', {
|
||||
node: guest.node,
|
||||
status: t(`card.pve.guestStatus.${guestStatusKey(guest.status)}`),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{unchecked > 0 && (
|
||||
<p data-testid="autostart-unchecked" className="text-xs text-muted-foreground">
|
||||
{t('card.pve.autostartUnchecked', { count: unchecked })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const KNOWN_GUEST_STATUS = new Set(['stopped', 'paused', 'suspended']);
|
||||
|
||||
function guestStatusKey(status: string): string {
|
||||
return KNOWN_GUEST_STATUS.has(status) ? status : 'other';
|
||||
}
|
||||
|
||||
function PveMetricsView({
|
||||
metrics,
|
||||
t,
|
||||
@@ -202,6 +259,8 @@ function PveMetricsView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutostartNotice metrics={metrics} t={t} />
|
||||
|
||||
{metrics.nodes.map((node) => (
|
||||
<div
|
||||
key={node.node}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -36,6 +36,15 @@ export interface ProxmoxPveStorageMetric {
|
||||
maxdisk: number | null;
|
||||
}
|
||||
|
||||
/** Gast, der nicht laeuft, obwohl Autostart aktiv ist (quick-261005). */
|
||||
export interface ProxmoxPveAutostartGuest {
|
||||
vmid: number;
|
||||
name: string | null;
|
||||
type: 'qemu' | 'lxc';
|
||||
node: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProxmoxPveMetrics {
|
||||
productType: 'pve';
|
||||
nodeCount: number;
|
||||
@@ -43,6 +52,10 @@ export interface ProxmoxPveMetrics {
|
||||
guestsStopped: number;
|
||||
nodes: ProxmoxPveNodeMetric[];
|
||||
storages: ProxmoxPveStorageMetric[];
|
||||
/** Fehlt in Zwischenlagerstaenden von vor quick-261005. */
|
||||
autostartStopped?: ProxmoxPveAutostartGuest[];
|
||||
/** Gestoppte Gaeste, deren Autostart nicht gelesen werden konnte. */
|
||||
autostartUnchecked?: number;
|
||||
}
|
||||
|
||||
export interface ProxmoxPbsDatastoreMetric {
|
||||
|
||||
@@ -473,7 +473,8 @@
|
||||
"titlePlaceholder": "Titel (optional)",
|
||||
"serversLabel": "Angezeigte Server",
|
||||
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
|
||||
"chooseServers": "Server auswählen"
|
||||
"chooseServers": "Server auswählen",
|
||||
"autostartStopped": "{count} gestoppt trotz Autostart"
|
||||
},
|
||||
"reminder": {
|
||||
"name": "Erinnerungen",
|
||||
@@ -1013,7 +1014,20 @@
|
||||
"stopped": "gestoppt",
|
||||
"nodes": "{count, plural, one {# Knoten} other {# Knoten}}",
|
||||
"cpu": "Prozessor",
|
||||
"mem": "Arbeitsspeicher"
|
||||
"mem": "Arbeitsspeicher",
|
||||
"autostartTitle": "{count, plural, one {# Gast läuft nicht, obwohl Autostart aktiv ist} other {# Gäste laufen nicht, obwohl Autostart aktiv ist}}",
|
||||
"autostartGuestDetail": "auf {node} · {status}",
|
||||
"autostartUnchecked": "Bei {count, plural, one {# gestopptem Gast} other {# gestoppten Gästen}} konnte der Autostart nicht geprüft werden. Dem Zugang fehlt vermutlich das Recht VM.Audit.",
|
||||
"guestType": {
|
||||
"qemu": "VM",
|
||||
"lxc": "LXC"
|
||||
},
|
||||
"guestStatus": {
|
||||
"stopped": "gestoppt",
|
||||
"paused": "angehalten",
|
||||
"suspended": "im Ruhezustand",
|
||||
"other": "läuft nicht"
|
||||
}
|
||||
},
|
||||
"pbs": {
|
||||
"used": "Belegt",
|
||||
|
||||
@@ -473,7 +473,8 @@
|
||||
"titlePlaceholder": "Title (optional)",
|
||||
"serversLabel": "Servers shown",
|
||||
"serversHint": "With nothing selected, the tile shows all servers.",
|
||||
"chooseServers": "Choose servers"
|
||||
"chooseServers": "Choose servers",
|
||||
"autostartStopped": "{count} stopped despite autostart"
|
||||
},
|
||||
"reminder": {
|
||||
"name": "Reminders",
|
||||
@@ -1013,7 +1014,20 @@
|
||||
"stopped": "stopped",
|
||||
"nodes": "{count, plural, one {# node} other {# nodes}}",
|
||||
"cpu": "CPU",
|
||||
"mem": "Memory"
|
||||
"mem": "Memory",
|
||||
"autostartTitle": "{count, plural, one {# guest is not running although autostart is enabled} other {# guests are not running although autostart is enabled}}",
|
||||
"autostartGuestDetail": "on {node} · {status}",
|
||||
"autostartUnchecked": "Autostart could not be checked for {count, plural, one {# stopped guest} other {# stopped guests}}. The access probably lacks the VM.Audit permission.",
|
||||
"guestType": {
|
||||
"qemu": "VM",
|
||||
"lxc": "LXC"
|
||||
},
|
||||
"guestStatus": {
|
||||
"stopped": "stopped",
|
||||
"paused": "paused",
|
||||
"suspended": "suspended",
|
||||
"other": "not running"
|
||||
}
|
||||
},
|
||||
"pbs": {
|
||||
"used": "Used",
|
||||
|
||||
Reference in New Issue
Block a user