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
@@ -1,12 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
listPbsDatastoreNames,
listStoppedPveGuests,
normalizePbs,
normalizePmg,
normalizePve,
readBool,
readList,
readNumber,
readPveOnboot,
readText,
} from './proxmox-normalize';
@@ -285,3 +287,32 @@ describe('normalizePmg (Aufgabe 3, <behavior>)', () => {
expect(metrics.countOut).toBe(50);
});
});
describe('quick-261005: Autostart-Hilfen', () => {
it('listStoppedPveGuests nimmt nur nicht laufende qemu/lxc ohne Vorlage, sortiert nach vmid', () => {
const list = listStoppedPveGuests({
data: [
{ type: 'lxc', node: 'b', vmid: '205', status: 'stopped' },
{ type: 'qemu', node: 'a', vmid: 101, status: 'paused', name: 'p' },
{ type: 'qemu', node: 'a', vmid: 100, status: 'running' },
{ type: 'qemu', node: 'a', vmid: 9000, status: 'stopped', template: 1 },
{ type: 'storage', node: 'a', status: 'available' },
{ type: 'qemu', vmid: 300, status: 'stopped' },
],
});
expect(list).toEqual([
{ vmid: 101, name: 'p', type: 'qemu', node: 'a', status: 'paused' },
{ vmid: 205, name: null, type: 'lxc', node: 'b', status: 'stopped' },
]);
expect(listStoppedPveGuests('kaputt')).toEqual([]);
});
it('readPveOnboot: fehlendes Feld heisst aus, kein data-Objekt heisst unbekannt', () => {
expect(readPveOnboot({ data: { onboot: 1 } })).toBe(true);
expect(readPveOnboot({ data: { onboot: '1' } })).toBe(true);
expect(readPveOnboot({ data: { onboot: 0 } })).toBe(false);
expect(readPveOnboot({ data: {} })).toBe(false);
expect(readPveOnboot({ data: null })).toBeNull();
expect(readPveOnboot(null)).toBeNull();
});
});
+41
View File
@@ -3,6 +3,7 @@ import type {
ProxmoxPbsDatastoreMetric,
ProxmoxPbsMetrics,
ProxmoxPmgMetrics,
ProxmoxPveAutostartGuest,
ProxmoxPveMetrics,
ProxmoxPveNodeMetric,
} from './proxmox.types';
@@ -121,6 +122,46 @@ export function normalizePve(body: unknown): NormalizeResult<ProxmoxPveMetrics>
};
}
/**
* Gaeste aus `/cluster/resources`, die NICHT laufen und keine Vorlage sind
* (quick-261005) — Kandidaten fuer die Autostart-Pruefung. Fuer jeden
* fragt der Dienst danach `/nodes/{node}/{type}/{vmid}/config` ab, denn
* `cluster/resources` liefert `onboot` nicht mit. Eintraege ohne gueltige
* vmid oder Node werden still uebergangen.
*/
export function listStoppedPveGuests(body: unknown): ProxmoxPveAutostartGuest[] {
if (!isRecord(body)) return [];
const result: ProxmoxPveAutostartGuest[] = [];
for (const entry of readList(body.data)) {
if (!isRecord(entry)) continue;
if (entry.type !== 'qemu' && entry.type !== 'lxc') continue;
if (entry.status === 'running') continue;
if (readBool(entry.template) === true) continue;
const vmid = readNumber(entry.vmid);
const node = readText(entry.node);
if (vmid === null || node === null) continue;
result.push({
vmid,
name: readText(entry.name),
type: entry.type,
node,
status: readText(entry.status) ?? 'unknown',
});
}
return result.sort((a, b) => a.vmid - b.vmid);
}
/**
* `onboot` aus der Antwort von `/nodes/{node}/{type}/{vmid}/config`.
* Proxmox laesst das Feld weg, wenn es nie gesetzt wurde — das heisst
* „aus“ (Vorgabe 0). `null` nur bei einer Antwort ohne `data`-Objekt.
*/
export function readPveOnboot(body: unknown): boolean | null {
if (!isRecord(body) || !isRecord(body.data)) return null;
if (!('onboot' in body.data)) return false;
return readBool(body.data.onboot) ?? false;
}
// ---------------------------------------------------------------------------
// PBS — /api2/json/status/datastore-usage + je Datenspeicher .../snapshots
// ---------------------------------------------------------------------------
+54 -8
View File
@@ -188,8 +188,9 @@ describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () =
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
const urls: string[] = [];
const fetchSpy = vi.fn(async (url: string) => {
expect(url).toBe('https://pve.intern:8006/api2/json/cluster/resources');
urls.push(url);
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
@@ -208,6 +209,51 @@ describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () =
expect(status.reachable).toBe(true);
expect(status.metrics).toMatchObject({ nodeCount: 1 });
expect(status.rawSample).toContain('"node":"pve1"');
expect(urls[0]).toBe('https://pve.intern:8006/api2/json/cluster/resources');
});
it('quick-261005: meldet gestoppte Gaeste mit aktivem Autostart, Vorlagen nie, unlesbare als nicht geprueft', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
const resources = {
data: [
{ type: 'node', node: 'pve1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 2 },
{ type: 'qemu', node: 'pve1', vmid: 100, status: 'running', name: 'laeuft' },
{ type: 'qemu', node: 'pve1', vmid: 101, status: 'stopped', name: 'dc01' },
{ type: 'lxc', node: 'pve2', vmid: 201, status: 'stopped', name: 'ohne-autostart' },
{ type: 'qemu', node: 'pve1', vmid: 102, status: 'stopped', name: 'kein-recht' },
{ type: 'qemu', node: 'pve1', vmid: 9000, status: 'stopped', template: 1 },
],
};
const urls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
urls.push(url);
if (url.endsWith('/cluster/resources')) {
return new Response(JSON.stringify(resources), { status: 200 });
}
if (url.endsWith('/nodes/pve1/qemu/101/config')) {
return new Response(JSON.stringify({ data: { onboot: 1, name: 'dc01' } }), { status: 200 });
}
if (url.endsWith('/nodes/pve2/lxc/201/config')) {
return new Response(JSON.stringify({ data: { hostname: 'x' } }), { status: 200 });
}
return new Response('{"data":null}', { status: 403 });
}),
);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
expect(result?.metrics).toMatchObject({
autostartStopped: [{ vmid: 101, name: 'dc01', type: 'qemu', node: 'pve1', status: 'stopped' }],
autostartUnchecked: 1,
});
expect(urls.some((u) => u.includes('/100/config'))).toBe(false);
expect(urls.some((u) => u.includes('/9000/config'))).toBe(false);
});
it('sendet die Token-Kopfzeile im PVE-Schema (Gleichheitszeichen vor dem Geheimnis)', async () => {
@@ -241,8 +287,8 @@ describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () =
});
const dispatchers: unknown[] = [];
const fetchSpy = vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
dispatchers.push(options.dispatcher);
const fetchSpy = vi.fn(async (url: string, options: RequestInit & { dispatcher?: unknown }) => {
if (url.endsWith('/cluster/resources')) dispatchers.push(options.dispatcher);
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
@@ -479,7 +525,7 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
capturedUrl = url;
capturedUrl ??= url;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
@@ -519,8 +565,8 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
let fetchCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCalls++;
vi.fn(async (url: string) => {
if (url.endsWith('/cluster/resources')) fetchCalls++;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
@@ -540,8 +586,8 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
let fetchCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCalls++;
vi.fn(async (url: string) => {
if (url.endsWith('/cluster/resources')) fetchCalls++;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
+51 -3
View File
@@ -5,13 +5,25 @@ import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
import {
listPbsDatastoreNames,
listStoppedPveGuests,
normalizePbs,
normalizePmg,
normalizePve,
readPveOnboot,
} from './proxmox-normalize';
import type {
CreateProxmoxServerDto,
TestProxmoxServerDto,
UpdateProxmoxServerDto,
} from './dto/proxmox-server.dto';
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
import type {
ProxmoxErrorKind,
ProxmoxPollResult,
ProxmoxProductType,
ProxmoxPveAutostartGuest,
} from './proxmox.types';
/**
* Nur die Felder, die eine Abfrage tatsaechlich braucht (Nachbesserung
@@ -77,6 +89,13 @@ const SAFE_SERVER_SELECT = {
*/
const PBS_SNAPSHOT_QUERY_CAP = 10;
/**
* Deckel der Autostart-Pruefung je PVE-Durchlauf (quick-261005): hoechstens
* so viele `/config`-Abfragen fuer gestoppte Gaeste. Gaeste darueber hinaus
* zaehlen als „nicht geprueft“ (`autostartUnchecked`), nie als „in Ordnung“.
*/
const PVE_AUTOSTART_QUERY_CAP = 40;
/**
* Zehn-Sekunden-Sperre fuer `pollServer` (Aufgabe 4, T-DHH-06): ein Klick
* auf "Jetzt aktualisieren" darf nicht zu ungebremsten Anfragen gegen die
@@ -325,6 +344,34 @@ export class ProxmoxService {
return result;
}
/**
* Autostart-Pruefung (quick-261005): fuer jeden gestoppten Gast (keine
* Vorlage) die Einstellung `onboot` aus seiner Konfiguration lesen.
* `cluster/resources` liefert sie nicht mit. Ein fehlgeschlagener Abruf
* (z. B. 403, weil dem Token VM.Audit fehlt) macht den Server NICHT
* unerreichbar, sondern zaehlt als „nicht geprueft“.
*/
private async checkPveAutostart(
server: ProxmoxCredentialSource,
session: { headers: Record<string, string>; retried: boolean },
resourcesBody: unknown,
): Promise<{ autostartStopped: ProxmoxPveAutostartGuest[]; autostartUnchecked: number }> {
const stopped = listStoppedPveGuests(resourcesBody);
const autostartStopped: ProxmoxPveAutostartGuest[] = [];
let autostartUnchecked = Math.max(0, stopped.length - PVE_AUTOSTART_QUERY_CAP);
for (const guest of stopped.slice(0, PVE_AUTOSTART_QUERY_CAP)) {
const configResult = await this.getWithRetry(
server,
session,
`/api2/json/nodes/${encodeURIComponent(guest.node)}/${guest.type}/${guest.vmid}/config`,
);
const onboot = configResult.ok ? readPveOnboot(configResult.body) : null;
if (onboot === null) autostartUnchecked++;
else if (onboot) autostartStopped.push(guest);
}
return { autostartStopped, autostartUnchecked };
}
/**
* EIN Abfragedurchlauf gegen genau diesen Server — `pve` (Aufgabe 1),
* `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort
@@ -366,11 +413,12 @@ export class ProxmoxService {
rawSample: truncateRaw(result.body),
};
}
const autostart = await this.checkPveAutostart(server, session, result.body);
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalized.metrics,
metrics: { ...normalized.metrics, ...autostart },
rawSample: truncateRaw(result.body),
};
}
+20
View File
@@ -59,6 +59,15 @@ export interface ProxmoxPveStorageMetric {
maxdisk: number | null;
}
/** Eine VM oder ein LXC, der nicht laeuft, obwohl Autostart (`onboot`) 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;
@@ -66,6 +75,17 @@ export interface ProxmoxPveMetrics {
guestsStopped: number;
nodes: ProxmoxPveNodeMetric[];
storages: ProxmoxPveStorageMetric[];
/**
* Gestoppte Gaeste mit aktivem Autostart (quick-261005). Fehlt in
* Zwischenlagerstaenden von vor dieser Erweiterung.
*/
autostartStopped?: ProxmoxPveAutostartGuest[];
/**
* Gestoppte Gaeste, deren Autostart-Einstellung NICHT gelesen werden
* konnte (fehlendes Recht VM.Audit, Deckel erreicht, Abruf fehlgeschlagen)
* — ehrlich „unbekannt“ statt still „kein Problem“.
*/
autostartUnchecked?: number;
}
export interface ProxmoxPbsDatastoreMetric {
@@ -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(
+13
View File
@@ -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 {
+16 -2
View File
@@ -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",
+16 -2
View File
@@ -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",