feat(260924-i8v): Proxmox-Kachel als Durchstich mit Balken und Zusammenfassung

- Typ proxmox am Ende von WIDGET_TYPES, WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }
- gemeinsame Statusteile (proxmox-status, HealthBar, status-styles) nach components/proxmox/
- HealthBar variant compact: 6 px, ohne Legende, aria-hidden
- Registry 3/4/8/8, Katalog-/Registry-/API-Tests auf die erste Modul-Kachel umgestellt
- ProxmoxWidget liest nur listServers, zeigt Lade-, Leer-, Fehlerzustand

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 13:24:51 +02:00
parent 8bfa4fc467
commit a906c6705d
19 changed files with 556 additions and 69 deletions
@@ -0,0 +1,32 @@
import { HEALTH_ORDER, type ServerHealth } from '@/components/proxmox/proxmox-status';
/**
* Reine Funktionen der Proxmox-Kachel (quick-260924-i8v) — ohne React,
* damit jede Verzweigung einzeln pruefbar ist (Muster `picture-frame-config.ts`).
* Die Statuslogik selbst (Zustand je Server, Schwellen, Sortierung) kommt
* aus `components/proxmox/proxmox-status.ts` und wird hier nicht kopiert.
*/
export interface HealthSummary {
/** Es gibt Server, und keiner weicht von „in Ordnung“ ab. */
allOk: boolean;
/** Alle Zustaende ausser `ok` mit Anzahl > 0, in `HEALTH_ORDER`. */
entries: Array<{ health: ServerHealth; count: number }>;
/** Schlimmster vorhandene Zustand (erster in `HEALTH_ORDER` mit Anzahl > 0). */
worst: ServerHealth | null;
}
/**
* Zusammenfassung fuer die Zeile unter dem Balken: „Alles in Ordnung“ oder
* die Abweichungen („1 nicht erreichbar, 1 mit Warnung“). Die Anzahl `ok`
* wird bewusst nicht genannt — die Zeile soll sagen, was zu tun ist.
*/
export function healthSummary(counts: Record<ServerHealth, number>): HealthSummary {
const entries = HEALTH_ORDER.filter((h) => h !== 'ok' && counts[h] > 0).map((health) => ({
health,
count: counts[health],
}));
const total = HEALTH_ORDER.reduce((sum, h) => sum + counts[h], 0);
const worst = HEALTH_ORDER.find((h) => counts[h] > 0) ?? null;
return { allOk: total > 0 && entries.length === 0, entries, worst };
}
@@ -0,0 +1,233 @@
import { cleanup, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
import de from '@/messages/de.json';
// Echter next-intl-Provider mit den deutschen Texten (Muster
// proxmox-page-roles.test.tsx) — die Kachel braucht ICU-Plural und useLocale.
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
// T-I8V-02: `pollServer` ist nur als Spion da — die Kachel darf ihn nie rufen.
const mockListServers = vi.fn();
const mockPollServer = vi.fn();
vi.mock('@/lib/proxmox-api', () => ({
listServers: (...args: unknown[]) => mockListServers(...args),
pollServer: (...args: unknown[]) => mockPollServer(...args),
}));
const mockAuthStore = vi.fn();
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
}));
vi.mock('next/link', () => ({
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
selector({ user }),
);
}
type Health = 'ok' | 'warn' | 'down' | 'idle' | 'orphan';
const POLLED_AT = new Date(Date.now() - 2 * 60 * 1000).toISOString();
function pveMetrics(overrides: Partial<Extract<ProxmoxMetrics, { productType: 'pve' }>> = {}) {
return {
productType: 'pve' as const,
nodeCount: 1,
guestsRunning: 3,
guestsStopped: 1,
nodes: [{ node: 'n1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 10 }],
storages: [],
...overrides,
};
}
/**
* Baukasten: ein Server mit Zwischenlager passend zum gewuenschten Zustand.
* `warn` = PVE-Knoten bei 87 % Last.
*/
function makeServer(
health: Health,
overrides: Partial<ProxmoxServer> & { metrics?: ProxmoxMetrics | null } = {},
): ProxmoxServer {
const { metrics: metricsOverride, ...rest } = overrides;
const id = rest.id ?? `srv-${health}`;
const metrics: ProxmoxMetrics | null =
metricsOverride !== undefined
? metricsOverride
: health === 'warn'
? pveMetrics({ nodes: [{ node: 'n1', cpu: 0.87, maxcpu: 8, mem: 1, maxmem: 10 }] })
: pveMetrics();
return {
id,
tenantId: 't1',
name: `server-${health}`,
productType: 'pve',
baseUrl: 'https://pve.intern',
authMethod: 'token',
tokenId: 'root@pam!x',
username: null,
tlsRejectUnauthorized: true,
isActive: health !== 'orphan',
pollIntervalMin: 5,
position: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: {
id: `st-${id}`,
serverId: id,
lastPolledAt: health === 'idle' ? null : POLLED_AT,
lastOkAt: health === 'down' ? null : POLLED_AT,
reachable: health !== 'down' && health !== 'idle',
errorKind: health === 'down' ? 'netz' : null,
errorDetail: health === 'down' ? 'connect ECONNREFUSED 10.0.0.1:8006' : null,
rawSample: null,
updatedAt: POLLED_AT,
metrics: health === 'down' || health === 'idle' ? null : metrics,
},
...rest,
};
}
async function renderWidget(
props: { config?: Record<string, unknown>; isEditMode?: boolean } = {},
) {
const { ProxmoxWidget } = await import('./proxmox-widget');
return render(
<ProxmoxWidget
instanceId="w-1"
config={props.config ?? {}}
isEditMode={props.isEditMode ?? false}
/>,
);
}
beforeEach(() => {
mockUser({ role: 'USER' });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ProxmoxWidget: Balken und Zusammenfassung (quick-260924-i8v)', () => {
it('alle Server in Ordnung: „Alles in Ordnung“ in der Ok-Farbe', async () => {
mockListServers.mockResolvedValue([
makeServer('ok', { id: 'a', name: 'pve-a' }),
makeServer('ok', { id: 'b', name: 'pve-b' }),
]);
await renderWidget();
const summary = await screen.findByTestId('proxmox-summary');
expect(summary).toHaveTextContent('Alles in Ordnung');
expect(summary).toHaveClass('text-status-ok-fg');
});
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();
const summary = await screen.findByTestId('proxmox-summary');
expect(summary).toHaveTextContent('1 nicht erreichbar, 1 mit Warnung');
expect(summary).toHaveClass('text-status-down-fg');
expect(summary).toHaveAttribute('title', '1 nicht erreichbar, 1 mit Warnung');
});
it('zeigt den kompakten Balken (6 px, aria-hidden) ohne Legende', async () => {
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]);
await renderWidget();
const bar = await screen.findByTestId('health-bar');
expect(bar).toHaveAttribute('data-variant', 'compact');
expect(bar).toHaveClass('h-1.5');
expect(bar).toHaveAttribute('aria-hidden', 'true');
expect(bar.querySelectorAll('[data-segment]')).toHaveLength(2);
// Keine Legende: kein Listeneintrag im Balken, kein role="img"
expect(bar.querySelector('li')).toBeNull();
expect(screen.queryByRole('img')).toBeNull();
});
it('zeigt die Fehlerdetails eines nicht erreichbaren Servers nicht (T-I8V-05)', async () => {
mockListServers.mockResolvedValue([makeServer('down')]);
await renderWidget();
await screen.findByTestId('proxmox-summary');
expect(screen.queryByText(/ECONNREFUSED/)).toBeNull();
expect(screen.queryByText(/pve\.intern/)).toBeNull();
});
});
describe('ProxmoxWidget: Lade-, Leer- und Fehlerzustand (quick-260924-i8v)', () => {
it('waehrend des Ladens: pulsierende Leiste und Vorlesetext', async () => {
mockListServers.mockReturnValue(new Promise(() => {}));
await renderWidget();
expect(screen.getByText('Lade Serverliste...')).toHaveClass('sr-only');
});
it('leere Liste: Satz; ein Admin sieht den Link zu den Einstellungen', async () => {
mockUser({ role: 'ADMIN' });
mockListServers.mockResolvedValue([]);
await renderWidget();
expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Zu den Einstellungen' })).toHaveAttribute(
'href',
'/modules/proxmox/settings',
);
});
it('leere Liste: ein Benutzer mit Rolle USER sieht keinen Link', async () => {
mockListServers.mockResolvedValue([]);
await renderWidget();
expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument();
expect(screen.queryByRole('link')).toBeNull();
expect(screen.queryByText('Zu den Einstellungen')).toBeNull();
});
it('leere Liste im Bearbeitungsmodus: der Hinweis fuer Admins steht ohne Link da', async () => {
mockUser({ role: 'SUPER_ADMIN' });
mockListServers.mockResolvedValue([]);
await renderWidget({ isEditMode: true });
expect(await screen.findByText('Zu den Einstellungen')).toBeInTheDocument();
expect(screen.queryByRole('link')).toBeNull();
});
it('Laden schlaegt fehl: „Die Serverliste konnte nicht geladen werden.“', async () => {
mockListServers.mockRejectedValue(new Error('500'));
await renderWidget();
expect(
await screen.findByText('Die Serverliste konnte nicht geladen werden.'),
).toBeInTheDocument();
});
it('ruft nie die manuelle Abfrage auf (T-I8V-02)', async () => {
mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]);
await renderWidget();
await screen.findByTestId('proxmox-summary');
await waitFor(() => expect(mockListServers).toHaveBeenCalledTimes(1));
expect(mockPollServer).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,116 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { HealthBar } from '@/components/proxmox/HealthBar';
import { summarizeHealth } from '@/components/proxmox/proxmox-status';
import { HEALTH_STYLE } from '@/components/proxmox/status-styles';
// T-I8V-02: aus dem Proxmox-Klienten NUR die Leseabfrage des Zwischenlagers.
// Die manuelle Abfrage (POST .../poll) gehoert der Modulseite und darf hier
// nie auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen aus.
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { healthSummary } from './proxmox-widget-model';
/**
* ProxmoxWidget (quick-260924-i8v) — die erste Modul-Kachel. Zeigt den
* Zustand der Proxmox-Server in der Statussprache der Modulseite: kompakter
* Gesundheitsbalken und darunter die Zusammenfassung in Worten.
*
* Liest ausschliesslich das Zwischenlager (`GET /modules/proxmox/servers`),
* das `@UseModule('proxmox')` serverseitig schuetzt.
*/
export function ProxmoxWidget({ isEditMode }: WidgetProps) {
const t = useTranslations('widgets');
const tp = useTranslations('proxmox');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
// Fehler als Flag, nicht als Text: `t` gehoert nicht in die Effekt-
// Abhaengigkeiten (Befund 14 aus favorites-widget).
const [loadFailed, setLoadFailed] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
let cancelled = false;
listServers()
.then((list) => {
if (cancelled) return;
setServers(list);
setLoadFailed(false);
setNow(Date.now());
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
return () => {
cancelled = true;
};
}, []);
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
const summary = useMemo(() => healthSummary(counts), [counts]);
if (servers === null && loadFailed) {
return (
<div className="flex h-full items-center justify-center p-2.5">
<p className="text-center text-sm text-muted-foreground">{tp('loadError')}</p>
</div>
);
}
if (servers === null) {
return (
<div className="flex h-full flex-col overflow-hidden p-2.5" aria-busy="true">
<div className="h-1.5 w-full shrink-0 animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
<span className="sr-only">{tp('loading')}</span>
</div>
);
}
if (servers.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-1.5 p-2.5 text-center">
<p className="text-sm text-muted-foreground">{t('proxmox.empty')}</p>
{isAdmin &&
(isEditMode ? (
<span className="text-sm font-medium text-foreground">{tp('card.settingsLink')}</span>
) : (
<Link
href="/modules/proxmox/settings"
className="rounded-sm text-sm font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{tp('card.settingsLink')}
</Link>
))}
</div>
);
}
const summaryText = summary.allOk
? t('proxmox.allOk')
: summary.entries
.map((e) => `${e.count} ${tp(`legend.${e.health}`, { count: e.count })}`)
.join(', ');
const summaryColor = summary.allOk
? HEALTH_STYLE.ok.text
: summary.worst
? HEALTH_STYLE[summary.worst].text
: 'text-muted-foreground';
return (
<div className="flex h-full flex-col gap-2 overflow-hidden p-2.5">
<HealthBar variant="compact" counts={counts} />
<p
className={`truncate text-sm font-medium ${summaryColor}`}
title={summaryText}
data-testid="proxmox-summary"
>
{summaryText}
</p>
</div>
);
}