feat(260924-h7x): Proxmox-Seite mit Status-Design und Tiefe

- Statusfarben als OKLCH-Tokens (Flaeche + kontrastgepruefte Textvariante) und Well-Token
- Gesundheitsbalken mit Legende und vorgelesener Zusammenfassung
- Karten nach Zustand sortiert, Statusleiste, getoenter Schatten, Statuspille
- Messwerte in eingelassenen Feldern: PVE-Knoten mit Balken, PBS-Fuellstand/Sicherung/Pruefung, PMG-Zahlfelder
- offline & verwaist: gestrichelt, gedaempft, keine alten Messwerte
- Skelett-Karten beim Laden, Leerzustand als Well, relative Zeitangaben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 12:34:16 +02:00
parent 0fa7ce0f44
commit 57c338fe53
13 changed files with 1435 additions and 582 deletions
@@ -0,0 +1,58 @@
'use client';
import { useTranslations } from 'next-intl';
import { HEALTH_ORDER, type ServerHealth } from './proxmox-status';
import { HEALTH_STYLE } from './status-styles';
interface HealthBarProps {
counts: Record<ServerHealth, number>;
}
/**
* Gesundheitsbalken (quick-260924-h7x) — das eine praegnante Element der
* Seite: ein 8 px hoher, voll gerundeter Balken, anteilig je Zustand geteilt
* (down, warn, ok, idle, orphan; leere Zustaende entfallen), darunter die
* Legende „Punkt + Zahl + Wort“. Der Balken liest seine Zusammenfassung als
* `role="img"` vor.
*/
export function HealthBar({ counts }: HealthBarProps) {
const t = useTranslations('proxmox');
const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({
health,
count: counts[health],
word: t(`legend.${health}`, { count: counts[health] }),
}));
if (entries.length === 0) return null;
const summary = entries.map((e) => `${e.count} ${e.word}`).join(', ');
return (
<div className="space-y-2.5" data-testid="health-bar">
<div
role="img"
aria-label={t('legend.barLabel', { summary })}
className="flex h-2 w-full gap-[3px] overflow-hidden rounded-full"
>
{entries.map((e) => (
<div
key={e.health}
data-segment={e.health}
className={`h-full min-w-2 first:rounded-l-full last:rounded-r-full ${HEALTH_STYLE[e.health].fill}`}
style={{ flexGrow: e.count, flexBasis: 0 }}
/>
))}
</div>
<ul className="flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-hidden="true">
{entries.map((e) => (
<li key={e.health} className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${HEALTH_STYLE[e.health].fill}`} />
<span>
<span className="font-semibold tabular-nums">{e.count}</span>{' '}
<span className="text-muted-foreground">{e.word}</span>
</span>
</li>
))}
</ul>
</div>
);
}
@@ -1,51 +1,56 @@
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, render, screen, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProxmoxServer } from '@/lib/proxmox-api';
import type { ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
import de from '@/messages/de.json';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
'card.unknownValue': 'unbekannt',
'card.lastPolledLabel': 'Letzte Abfrage',
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
'card.notPolledYetAutomatic':
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
'card.refresh': 'Jetzt aktualisieren',
'card.lastOkLabel': 'Letzte erfolgreiche Messung',
'card.pve.nodeCount': 'Knoten',
'card.pve.guests': '{running} laufend / {stopped} gestoppt',
'card.pve.cpu': 'Prozessorlast',
'card.pve.mem': 'Speicher',
'card.pbs.used': 'Belegt',
'card.pbs.lastBackup': 'Letzte Sicherung',
'card.pbs.noBackupYet': 'noch keine Sicherung',
'card.pbs.verifyState': 'Letzte Prüfung',
'card.pmg.countIn': 'Eingehend',
'card.pmg.countOut': 'Ausgehend',
'card.pmg.spamCount': 'Spam',
'card.pmg.virusCount': 'Viren',
'errors.netz': 'Der Server ist nicht erreichbar.',
'errors.zugang': 'Der Zugang wurde abgelehnt.',
'errors.rechte': 'Die Rechte reichen nicht aus.',
'errors.zertifikat': 'Das Zertifikat wurde abgelehnt.',
'errors.antwortform': 'Unerwartete Antwortform.',
'errors.server': 'Serverfehler.',
'errors.unbekannt': 'Unerwarteter Fehler.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, String(v));
}
}
return result;
},
/**
* ServerCard (quick-260924-h7x) — mit dem ECHTEN next-intl-Provider und den
* echten deutschen Texten, damit ICU-Plural und Zahlformate mitgeprueft
* werden.
*/
vi.mock('next/link', () => ({
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
afterEach(() => {
cleanup();
});
/** Fester Bezugszeitpunkt: 24.09.2026, 12:00 UTC. */
const NOW = Date.parse('2026-09-24T12:00:00.000Z');
const NOW_S = NOW / 1000;
function renderDe(ui: ReactElement) {
return render(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
function makeStatus(overrides: Partial<ProxmoxServerStatus> = {}): ProxmoxServerStatus {
return {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-24T11:56:00.000Z',
lastOkAt: '2026-09-24T11:56:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-24T11:56:00.000Z',
metrics: null,
...overrides,
};
}
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
return {
id: 'srv-1',
@@ -67,290 +72,393 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
};
}
describe('ServerCard', () => {
it('PVE-Server zeigt Knotenzahl, laufende/gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: '2026-09-23T10:00:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: {
productType: 'pve',
nodeCount: 2,
guestsRunning: 3,
guestsStopped: 1,
nodes: [
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_294_967_296, maxmem: 8_589_934_592 },
],
storages: [],
},
},
});
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
render(<ServerCard server={server} />);
async function card(server: ProxmoxServer, isAdmin?: boolean) {
const { ServerCard } = await import('./ServerCard');
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />);
return screen.getByTestId('server-card');
}
expect(screen.getByText(/Knoten:/)).toHaveTextContent('Knoten: 2');
expect(screen.getByText(/laufend/)).toHaveTextContent('3 laufend / 1 gestoppt');
expect(screen.getByText(/Prozessorlast/)).toHaveTextContent('Prozessorlast: 25%');
expect(screen.getByText(/Speicher:/)).toHaveTextContent('4.0 GB / 8.0 GB');
describe('ServerCard — Kopf und Status', () => {
it('zeigt Name, ausgeschriebenen Produktnamen und die Adresse unveraendert (keine Grossschrift)', async () => {
const el = await card(makeServer({ baseUrl: 'https://172.21.0.1:8006', status: makeStatus() }));
expect(within(el).getByText('pve-1')).toBeInTheDocument();
expect(within(el).getByText('Virtualisierung')).toBeInTheDocument();
const address = within(el).getByText('https://172.21.0.1:8006');
expect(address).not.toHaveClass('uppercase');
expect(el.querySelector('.uppercase')).toBeNull();
});
it('PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
productType: 'pbs',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: '2026-09-23T10:00:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: {
productType: 'pbs',
datastores: [
{ name: 'backup-store', total: 1000, used: 400, free: 600, lastBackupAt: 1758000000, lastVerifyState: 'ok' },
],
},
},
});
render(<ServerCard server={server} />);
expect(screen.getByText('backup-store')).toBeInTheDocument();
expect(screen.getByText(/Belegt:/)).toHaveTextContent('400 B / 1000 B');
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('ok');
it('PBS und PMG tragen ihre ausgeschriebenen Produktnamen', async () => {
await card(makeServer({ id: 'b', productType: 'pbs', status: makeStatus() }));
expect(screen.getByText('Datensicherung')).toBeInTheDocument();
cleanup();
await card(makeServer({ id: 'm', productType: 'pmg', status: makeStatus() }));
expect(screen.getByText('Mail-Gateway')).toBeInTheDocument();
});
it('PBS-Datenspeicher ohne Sicherung zeigt "noch keine Sicherung"', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
productType: 'pbs',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: null,
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: {
productType: 'pbs',
datastores: [
{ name: 'leer', total: 10, used: 0, free: 10, lastBackupAt: null, lastVerifyState: null },
],
},
},
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung');
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt');
it('erreichbar ohne Auffaelligkeiten: Pille „In Ordnung“, data-health ok', async () => {
const el = await card(makeServer({ status: makeStatus() }));
expect(el).toHaveAttribute('data-health', 'ok');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('In Ordnung');
});
it('PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
productType: 'pmg',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: '2026-09-23T10:00:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: { productType: 'pmg', countIn: 120, countOut: 45, spamCount: 12, virusCount: 0 },
},
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('120');
expect(screen.getByText(/Ausgehend:/)).toHaveTextContent('45');
expect(screen.getByText(/Spam:/)).toHaveTextContent('12');
expect(screen.getByText(/Viren:/)).toHaveTextContent('0');
});
it('ein Messwert, der null ist, erscheint als "unbekannt" — nie als 0, leer oder NaN', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
productType: 'pmg',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: null,
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null },
},
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('unbekannt');
expect(screen.queryByText(/Eingehend: 0/)).not.toBeInTheDocument();
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
});
it('ein Server mit reachable:false zeigt den Klartext der Ursache und den Zeitpunkt der letzten erfolgreichen Messung', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:05:00.000Z',
lastOkAt: '2026-09-23T09:00:00.000Z',
reachable: false,
errorKind: 'zugang',
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:05:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} />);
expect(screen.getByText('Der Zugang wurde abgelehnt.')).toBeInTheDocument();
expect(screen.getByText(/Letzte erfolgreiche Messung/)).toBeInTheDocument();
});
it('der Zeitpunkt der letzten Abfrage steht bei jedem Server', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-23T10:00:00.000Z',
lastOkAt: null,
reachable: true,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Letzte Abfrage/)).toBeInTheDocument();
});
it('Nachbesserung Befund 2: ein frisch angelegter, noch nie abgefragter Server zeigt Admins den ruhigen Hinweis mit Knopfverweis statt "Ein unerwarteter Fehler ist aufgetreten"', async () => {
const { ServerCard } = await import('./ServerCard');
// Zustand direkt nach `createServer`: leere Zwischenlagerzeile, noch nie abgefragt.
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: null,
lastOkAt: null,
reachable: false,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} isAdmin />);
expect(
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
).toBeInTheDocument();
// Ohne die Korrektur erschiene hier faelschlich die Sammelmeldung — roter Test.
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
});
it('260923-le6: isAdmin={false}, noch nie abgefragt -> automatischer Hinweis ohne Knopfverweis', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: null,
lastOkAt: null,
reachable: false,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} isAdmin={false} />);
expect(
screen.getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
});
it('260923-le6: isAdmin weggelassen, noch nie abgefragt -> verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: null,
lastOkAt: null,
reachable: false,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-09-23T10:00:00.000Z',
metrics: null,
},
});
render(<ServerCard server={server} />);
expect(
screen.getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
});
it('Nachbesserung Befund 3: die Adresse bleibt unveraendert dargestellt, nur das Produktkuerzel ist grossgeschrieben', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({ baseUrl: 'https://172.21.0.1:8006' });
render(<ServerCard server={server} />);
const line = screen.getByText(/172\.21\.0\.1:8006/);
expect(line).toHaveTextContent('pve — https://172.21.0.1:8006');
const productSpan = line.querySelector('span');
expect(productSpan).not.toBeNull();
expect(productSpan).toHaveClass('uppercase');
expect(line).not.toHaveClass('uppercase');
it('Fuss: „Letzte Abfrage vor 4 Min.“ relativ, exakter Zeitpunkt im title', async () => {
const el = await card(makeServer({ status: makeStatus() }));
const footer = within(el).getByTestId('last-polled');
expect(footer).toHaveTextContent('Letzte Abfrage vor 4 Min.');
expect(footer.getAttribute('title')).toBeTruthy();
});
});
describe('ServerCard — PVE', () => {
it('laufende und gestoppte Gaeste, Knotenzahl, je Knoten Prozessor und Arbeitsspeicher mit deutschem Zahlformat', async () => {
const el = await card(
makeServer({
status: makeStatus({
metrics: {
productType: 'pve',
nodeCount: 2,
guestsRunning: 3,
guestsStopped: 1,
nodes: [
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 12 * 1024 ** 3, maxmem: 64 * 1024 ** 3 },
],
storages: [],
},
}),
}),
);
expect(within(el).getByTestId('guests-running')).toHaveTextContent('3');
expect(within(el).getByText('Gäste laufen')).toBeInTheDocument();
expect(within(el).getByTestId('guests-stopped')).toHaveTextContent('1');
expect(within(el).getByText('2 Knoten')).toBeInTheDocument();
const node = within(el).getByTestId('pve-node-pve1');
const [cpu, mem] = within(node).getAllByTestId('meter');
expect(cpu).toHaveTextContent('Prozessor');
expect(cpu).toHaveTextContent(/25\s%/);
expect(cpu).toHaveAttribute('data-level', 'ok');
expect(mem).toHaveTextContent('Arbeitsspeicher');
expect(mem).toHaveTextContent(/19\s%/);
expect(mem).toHaveTextContent('12,0 / 64,0 GB');
});
it('Auslastung ab 80 % faerbt den Balken als Warnung, ab 92 % kritisch, und die Karte wird „Warnung“', async () => {
const el = await card(
makeServer({
status: makeStatus({
metrics: {
productType: 'pve',
nodeCount: 1,
guestsRunning: 1,
guestsStopped: 0,
nodes: [{ node: 'pve1', cpu: 0.85, maxcpu: 8, mem: 95, maxmem: 100 }],
storages: [],
},
}),
}),
);
expect(el).toHaveAttribute('data-health', 'warn');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Warnung');
const [cpu, mem] = within(el).getAllByTestId('meter');
expect(cpu).toHaveAttribute('data-level', 'warn');
expect(mem).toHaveAttribute('data-level', 'crit');
});
it('unbekannte Knotenwerte erscheinen als „unbekannt“ — nie 0 %, nie NaN', async () => {
const el = await card(
makeServer({
status: makeStatus({
metrics: {
productType: 'pve',
nodeCount: 1,
guestsRunning: 0,
guestsStopped: 0,
nodes: [{ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null }],
storages: [],
},
}),
}),
);
const [cpu, mem] = within(el).getAllByTestId('meter');
expect(cpu).toHaveTextContent('unbekannt');
expect(mem).toHaveTextContent('unbekannt');
expect(cpu).toHaveAttribute('data-level', 'unknown');
expect(el).not.toHaveTextContent(/0\s%/);
expect(el).not.toHaveTextContent('NaN');
expect(el).toHaveAttribute('data-health', 'ok');
});
});
describe('ServerCard — PBS', () => {
it('je Datenspeicher Fuellstand, letzte Sicherung relativ und Pruefpille', async () => {
const el = await card(
makeServer({
productType: 'pbs',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{
name: 'backup-store',
total: 4 * 1024 ** 4,
used: 1.2 * 1024 ** 4,
free: 2.8 * 1024 ** 4,
lastBackupAt: NOW_S - 5 * 3600,
lastVerifyState: 'ok',
},
],
},
}),
}),
);
const ds = within(el).getByTestId('pbs-datastore-backup-store');
expect(within(ds).getByText('backup-store')).toBeInTheDocument();
expect(within(ds).getByTestId('meter')).toHaveTextContent('1,2 / 4,0 TB');
expect(within(ds).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 5 Std.');
expect(within(ds).getByTestId('verify-pill')).toHaveTextContent('Prüfung in Ordnung');
expect(el).toHaveAttribute('data-health', 'ok');
});
it('Sicherung aelter als 26 h und fehlgeschlagene Pruefung -> Warnung', async () => {
const el = await card(
makeServer({
productType: 'pbs',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{
name: 'alt',
total: 100,
used: 10,
free: 90,
lastBackupAt: NOW_S - 30 * 3600,
lastVerifyState: 'failed',
},
],
},
}),
}),
);
expect(el).toHaveAttribute('data-health', 'warn');
expect(within(el).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 1 Tag');
expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung: failed');
});
it('ohne Sicherung „noch keine Sicherung“, Pruefung null „Prüfung unbekannt“, keine Warnung', async () => {
const el = await card(
makeServer({
productType: 'pbs',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{
name: 'leer',
total: 10,
used: 0,
free: 10,
lastBackupAt: null,
lastVerifyState: null,
},
],
},
}),
}),
);
expect(within(el).getByText('noch keine Sicherung')).toBeInTheDocument();
expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung unbekannt');
expect(el).toHaveAttribute('data-health', 'ok');
});
});
describe('ServerCard — PMG', () => {
it('vier Zahlfelder: eingehend, ausgehend, Spam, Viren', async () => {
const el = await card(
makeServer({
productType: 'pmg',
status: makeStatus({
metrics: {
productType: 'pmg',
countIn: 1200,
countOut: 45,
spamCount: 12,
virusCount: 0,
},
}),
}),
);
expect(within(el).getByTestId('pmg-in')).toHaveTextContent('Eingehend1.200');
expect(within(el).getByTestId('pmg-out')).toHaveTextContent('Ausgehend45');
expect(within(el).getByTestId('pmg-spam')).toHaveTextContent('Spam12');
expect(within(el).getByTestId('pmg-virus')).toHaveTextContent('Viren0');
expect(el).toHaveAttribute('data-health', 'ok');
});
it('Viren > 0 erscheinen in der Fehlerfarbe und machen die Karte zur Warnung', async () => {
const el = await card(
makeServer({
productType: 'pmg',
status: makeStatus({
metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 1, virusCount: 3 },
}),
}),
);
expect(el).toHaveAttribute('data-health', 'warn');
expect(within(within(el).getByTestId('pmg-virus')).getByText('3')).toHaveClass(
'text-status-down-fg',
);
});
it('null-Werte erscheinen als „unbekannt“ — nie 0', async () => {
const el = await card(
makeServer({
productType: 'pmg',
status: makeStatus({
metrics: {
productType: 'pmg',
countIn: null,
countOut: null,
spamCount: null,
virusCount: null,
},
}),
}),
);
expect(within(el).getByTestId('pmg-in')).toHaveTextContent('Eingehendunbekannt');
expect(within(el).getByTestId('pmg-virus')).toHaveTextContent('Virenunbekannt');
expect(el).not.toHaveTextContent('NaN');
});
});
describe('ServerCard — nicht erreichbar', () => {
it('Klartext der Ursache, Detail in eigener Zeile (nicht in Klammern), „Zuletzt erreichbar vor 2 Tagen“', async () => {
const el = await card(
makeServer({
status: makeStatus({
lastOkAt: '2026-09-22T12:00:00.000Z',
reachable: false,
errorKind: 'zugang',
errorDetail: 'HTTP 401',
}),
}),
);
expect(el).toHaveAttribute('data-health', 'down');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Nicht erreichbar');
const notice = within(el).getByTestId('down-notice');
expect(within(notice).getByText(/Der Zugang wurde abgelehnt\./)).toBeInTheDocument();
expect(within(notice).getByText('HTTP 401')).toBeInTheDocument();
expect(notice).not.toHaveTextContent('(HTTP 401)');
expect(within(notice).getByText('Zuletzt erreichbar vor 2 Tagen')).toBeInTheDocument();
});
it('nie erreichbar gewesen: keine „Zuletzt erreichbar“-Zeile', async () => {
const el = await card(
makeServer({ status: makeStatus({ lastOkAt: null, reachable: false, errorKind: 'netz' }) }),
);
expect(within(el).queryByText(/Zuletzt erreichbar/)).not.toBeInTheDocument();
});
});
describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
it('Admin: ruhiger Hinweis mit Knopfverweis statt Fehlermeldung', async () => {
const el = await card(makeServer({ status: UNPOLLED }), true);
expect(el).toHaveAttribute('data-health', 'idle');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Noch nicht abgefragt');
expect(
within(el).getByText(
'Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.',
),
).toBeInTheDocument();
expect(
within(el).queryByText('Ein unerwarteter Fehler ist aufgetreten.'),
).not.toBeInTheDocument();
expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument();
});
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
const el = await card(makeServer({ status: UNPOLLED }), false);
expect(
within(el).getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
});
it('isAdmin weggelassen: verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
const el = await card(makeServer({ status: UNPOLLED }));
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
expect(within(el).getByText(/automatischen Abfrage/)).toBeInTheDocument();
});
});
describe('ServerCard — offline & verwaist', () => {
const staleMetrics = makeStatus({
metrics: {
productType: 'pve',
nodeCount: 1,
guestsRunning: 7,
guestsStopped: 2,
nodes: [{ node: 'altknoten', cpu: 0.99, maxcpu: 8, mem: 99, maxmem: 100 }],
storages: [],
},
});
it('deaktiviert hat Vorrang: Pille „Offline & verwaist“, gestrichelter Rand, KEINE alten Messwerte', async () => {
const el = await card(makeServer({ isActive: false, status: staleMetrics }));
expect(el).toHaveAttribute('data-health', 'orphan');
expect(el).toHaveClass('border-dashed');
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Offline & verwaist');
expect(
within(el).getByText('Dieser Server ist deaktiviert und wird nicht mehr abgefragt.'),
).toBeInTheDocument();
expect(within(el).queryByTestId('guests-running')).not.toBeInTheDocument();
expect(within(el).queryByText('altknoten')).not.toBeInTheDocument();
expect(within(el).queryAllByTestId('meter')).toHaveLength(0);
});
it('deaktiviert und nicht erreichbar: keine Fehlermeldung, sondern der Verwaist-Hinweis', async () => {
const el = await card(
makeServer({ isActive: false, status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
);
expect(within(el).queryByTestId('down-notice')).not.toBeInTheDocument();
expect(within(el).getByTestId('orphan-notice')).toBeInTheDocument();
});
it('Admins bekommen den Hinweis auf die Einstellungen mit Verweis, andere nicht', async () => {
const admin = await card(makeServer({ isActive: false, status: staleMetrics }), true);
expect(
within(admin).getByText('Sie können ihn in den Einstellungen wieder aktivieren.', {
exact: false,
}),
).toBeInTheDocument();
expect(within(admin).getByRole('link', { name: 'Zu den Einstellungen' })).toHaveAttribute(
'href',
'/modules/proxmox/settings',
);
cleanup();
const user = await card(makeServer({ isActive: false, status: staleMetrics }), false);
expect(within(user).queryByRole('link')).not.toBeInTheDocument();
expect(within(user).queryByText(/wieder aktivieren/)).not.toBeInTheDocument();
});
});
@@ -1,88 +1,238 @@
'use client';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
import type {
ProxmoxErrorKind,
ProxmoxPbsMetrics,
ProxmoxPmgMetrics,
ProxmoxProductType,
ProxmoxPveMetrics,
ProxmoxServer,
} from '@/lib/proxmox-api';
import {
formatAge,
isBackupStale,
meterLevel,
ratio,
type ServerHealth,
serverHealth,
toEpochMs,
} from './proxmox-status';
import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from './status-styles';
/**
* Wandelt einen rohen Byte-Wert in eine lesbare Form (B/KB/MB/GB/TB).
*/
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = -1;
do {
value /= 1024;
unitIndex++;
} while (value >= 1024 && unitIndex < units.length - 1);
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
type Translator = ReturnType<typeof useTranslations>;
/** Prozentwert einer Auslastung (0..1) gerundet, z. B. `25%`. */
function formatPercent(fraction: number): string {
return `${Math.round(fraction * 100)}%`;
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
function isKnown(value: number | null | undefined): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}
/**
* Die EINZIGE Stelle, die einen Messwert in Text verwandelt (Aufgabe 6):
* ist der Wert `null`/`undefined`, erscheint der uebersetzte Text
* "unbekannt" — sonst der Wert mit seiner Einheit. Dadurch kann kein Zweig
* versehentlich eine `0` anzeigen, wo nichts gemessen wurde. Die Feldnamen
* von PBS und PMG sind bis zur Pruefung am echten Server nur abgeleitet
* (Recherche, Annahmen A2/A3/A5), und ein still falscher Wert waere
* schlimmer als ein ehrliches "unbekannt".
* „12,0 / 64,0 GB“ — beide Werte in DERSELBEN Einheit, gewaehlt nach der
* Gesamtgroesse, Zahlformat nach Sprache (deutsch mit Komma). `null`, wenn
* einer der Werte unbekannt ist — dann steht an der Stelle nichts statt
* einer erfundenen Zahl.
*/
function formatMetric(
value: number | string | null | undefined,
unknownLabel: string,
formatter?: (v: number) => string,
): string {
if (value === null || value === undefined) return unknownLabel;
if (typeof value === 'number') {
if (Number.isNaN(value)) return unknownLabel;
return formatter ? formatter(value) : String(value);
function formatBytePair(used: number | null, total: number | null, locale: string): string | null {
if (!isKnown(used) || !isKnown(total) || total <= 0) return null;
const exponent = Math.min(
BYTE_UNITS.length - 1,
Math.max(0, Math.floor(Math.log(total) / Math.log(1024))),
);
const divisor = 1024 ** exponent;
const digits = exponent === 0 ? 0 : 1;
const nf = new Intl.NumberFormat(locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${nf.format(used / divisor)} / ${nf.format(total / divisor)} ${BYTE_UNITS[exponent]}`;
}
function formatPercent(fraction: number, locale: string): string {
return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }).format(
fraction,
);
}
function formatCount(value: number, locale: string): string {
return new Intl.NumberFormat(locale).format(value);
}
/** Exakter Zeitpunkt fuer das `title`-Attribut der relativen Angaben. */
function exactTime(value: number | string | null | undefined, locale: string): string | undefined {
const ms = toEpochMs(value);
if (ms === null) return undefined;
return new Date(ms).toLocaleString(locale);
}
/** Kleines Produktsymbol je Typ: Server-Einschuebe, Archivkiste, Briefumschlag. */
function ProductIcon({ productType }: { productType: ProxmoxProductType }) {
const common = {
width: 18,
height: 18,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.75,
strokeLinecap: 'round',
strokeLinejoin: 'round',
} as const;
if (productType === 'pve') {
return (
<svg aria-hidden="true" {...common}>
<rect x="3" y="4" width="18" height="7" rx="1.5" />
<rect x="3" y="13" width="18" height="7" rx="1.5" />
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
<line x1="11" y1="7.5" x2="17" y2="7.5" />
<line x1="11" y1="16.5" x2="17" y2="16.5" />
</svg>
);
}
if (productType === 'pbs') {
return (
<svg aria-hidden="true" {...common}>
<rect x="3" y="4" width="18" height="4" rx="1" />
<path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8" />
<line x1="10" y1="12" x2="14" y2="12" />
</svg>
);
}
return value;
}
function formatTimestamp(value: string | number | null | undefined, unknownLabel: string): string {
if (value === null || value === undefined) return unknownLabel;
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
if (Number.isNaN(date.getTime())) return unknownLabel;
return date.toLocaleString();
}
interface MetricsViewProps {
metrics: ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
unknown: string;
}
function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unknown: string }) {
const t = useTranslations('proxmox');
return (
<div className="space-y-2 text-sm">
<div>
{t('card.pve.nodeCount')}: {metrics.nodeCount}
<svg aria-hidden="true" {...common}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<polyline points="3 7 12 13 21 7" />
</svg>
);
}
/** Statuspille: Punkt + Wort, Flaeche in Statusfarbe mit 12 %. Keine Daueranimation. */
function StatusPill({ health, label }: { health: ServerHealth; label: string }) {
const style = HEALTH_STYLE[health];
return (
<span
data-testid="status-pill"
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium ${style.pill}`}
>
<span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${style.fill}`} />
{label}
</span>
);
}
/**
* Schmaler Balken mit Prozentzahl. Ein unbekannter Wert zeigt eine
* schraffierte Spur und das Wort „unbekannt“ — NIE 0 %.
*/
function Meter({
label,
fraction,
detail,
unknownLabel,
locale,
}: {
label: string;
fraction: number | null;
detail?: string | null;
unknownLabel: string;
locale: string;
}) {
const level = meterLevel(fraction);
const known = level !== 'unknown' && fraction !== null;
const width = known ? `${Math.min(100, Math.max(0, fraction * 100))}%` : '100%';
return (
<div data-testid="meter" data-level={level} className="min-w-0">
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={`tabular-nums font-medium ${METER_TEXT[level]}`}>
{known ? formatPercent(fraction, locale) : unknownLabel}
</span>
</div>
<div>
{t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })}
<div aria-hidden="true" className="mt-1 h-1.5 overflow-hidden rounded-full bg-foreground/10">
{known ? (
<div className={`h-full rounded-full ${METER_FILL[level]}`} style={{ width }} />
) : (
<div
className="h-full w-full"
style={{
backgroundImage:
'repeating-linear-gradient(135deg, var(--border) 0 4px, transparent 4px 8px)',
}}
/>
)}
</div>
{detail && (
<div className="mt-1 text-[11px] tabular-nums text-muted-foreground">{detail}</div>
)}
</div>
);
}
function PveMetricsView({
metrics,
t,
locale,
}: {
metrics: ProxmoxPveMetrics;
t: Translator;
locale: string;
}) {
const unknown = t('card.unknownValue');
return (
<div className="space-y-2">
<div className={`${WELL} flex items-end justify-between gap-4 px-4 py-3`}>
<div className="flex items-end gap-6">
<div>
<div
className="text-2xl font-semibold leading-none tabular-nums"
data-testid="guests-running"
>
{formatCount(metrics.guestsRunning, locale)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('card.pve.running', { count: metrics.guestsRunning })}
</div>
</div>
<div>
<div
className="text-2xl font-semibold leading-none tabular-nums text-muted-foreground"
data-testid="guests-stopped"
>
{formatCount(metrics.guestsStopped, locale)}
</div>
<div className="mt-1 text-xs text-muted-foreground">{t('card.pve.stopped')}</div>
</div>
</div>
<div className="text-xs text-muted-foreground">
{t('card.pve.nodes', { count: metrics.nodeCount })}
</div>
</div>
{metrics.nodes.map((node) => (
<div key={node.node} className="rounded border border-border/60 px-2 py-1 text-xs">
<div className="font-medium">{node.node}</div>
<div>
{t('card.pve.cpu')}: {formatMetric(node.cpu, unknown, formatPercent)}
{' · '}
{t('card.pve.mem')}: {formatMetric(node.mem, unknown, formatBytes)}
{node.maxmem !== null && node.mem !== null
? ` / ${formatMetric(node.maxmem, unknown, formatBytes)}`
: ''}
<div
key={node.node}
data-testid={`pve-node-${node.node}`}
className={`${WELL} grid grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] items-start gap-4 px-4 py-3`}
>
<div className="truncate pt-0.5 text-sm font-medium" title={node.node}>
{node.node}
</div>
<div className="grid grid-cols-2 gap-3">
<Meter
label={t('card.pve.cpu')}
fraction={isKnown(node.cpu) ? node.cpu : null}
unknownLabel={unknown}
locale={locale}
/>
<Meter
label={t('card.pve.mem')}
fraction={ratio(node.mem, node.maxmem)}
detail={formatBytePair(node.mem, node.maxmem, locale)}
unknownLabel={unknown}
locale={locale}
/>
</div>
</div>
))}
@@ -90,124 +240,254 @@ function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unkn
);
}
function PbsMetricsView({ metrics, unknown }: { metrics: ProxmoxPbsMetrics; unknown: string }) {
const t = useTranslations('proxmox');
function PbsMetricsView({
metrics,
t,
locale,
now,
}: {
metrics: ProxmoxPbsMetrics;
t: Translator;
locale: string;
now: number;
}) {
const unknown = t('card.unknownValue');
if (metrics.datastores.length === 0) {
return <p className="text-sm text-muted-foreground">{t('card.pbs.noDatastores')}</p>;
}
return (
<div className="space-y-2 text-sm">
{metrics.datastores.map((ds) => (
<div key={ds.name} className="rounded border border-border/60 px-2 py-1 text-xs">
<div className="font-medium">{ds.name}</div>
<div>
{t('card.pbs.used')}: {formatMetric(ds.used, unknown, formatBytes)}
{ds.total !== null && ds.used !== null ? ` / ${formatMetric(ds.total, unknown, formatBytes)}` : ''}
</div>
<div>
{t('card.pbs.lastBackup')}:{' '}
{ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)}
</div>
<div>
{t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)}
<div className="space-y-2">
{metrics.datastores.map((ds) => {
const stale = isBackupStale(ds.lastBackupAt, now);
const backupAge = formatAge(ds.lastBackupAt, now, locale);
const verify =
ds.lastVerifyState === null
? { cls: HEALTH_STYLE.idle.pill, text: t('card.pbs.verifyUnknown') }
: ds.lastVerifyState === 'ok'
? { cls: HEALTH_STYLE.ok.pill, text: t('card.pbs.verifyOk') }
: {
cls: HEALTH_STYLE.warn.pill,
text: t('card.pbs.verifyFailed', { state: ds.lastVerifyState }),
};
return (
<div
key={ds.name}
data-testid={`pbs-datastore-${ds.name}`}
className={`${WELL} space-y-2.5 px-4 py-3`}
>
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium" title={ds.name}>
{ds.name}
</span>
<span
data-testid="verify-pill"
className={`shrink-0 whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium ${verify.cls}`}
>
{verify.text}
</span>
</div>
<Meter
label={t('card.pbs.used')}
fraction={ratio(ds.used, ds.total)}
detail={formatBytePair(ds.used, ds.total, locale)}
unknownLabel={unknown}
locale={locale}
/>
{ds.lastBackupAt === null || backupAge === null ? (
<p className="text-xs text-muted-foreground">{t('card.pbs.noBackupYet')}</p>
) : (
<p
data-testid="last-backup"
className={`text-xs ${stale ? `font-medium ${HEALTH_STYLE.warn.text}` : 'text-muted-foreground'}`}
title={exactTime(ds.lastBackupAt, locale)}
>
{t('card.pbs.lastBackupAgo', { age: backupAge })}
</p>
)}
</div>
);
})}
</div>
);
}
function PmgMetricsView({
metrics,
t,
locale,
}: {
metrics: ProxmoxPmgMetrics;
t: Translator;
locale: string;
}) {
const unknown = t('card.unknownValue');
const tiles: Array<{ key: string; label: string; value: number | null; alarm: boolean }> = [
{ key: 'in', label: t('card.pmg.countIn'), value: metrics.countIn, alarm: false },
{ key: 'out', label: t('card.pmg.countOut'), value: metrics.countOut, alarm: false },
{ key: 'spam', label: t('card.pmg.spamCount'), value: metrics.spamCount, alarm: false },
{
key: 'virus',
label: t('card.pmg.virusCount'),
value: metrics.virusCount,
alarm: isKnown(metrics.virusCount) && metrics.virusCount > 0,
},
];
return (
<div className="grid grid-cols-2 gap-2">
{tiles.map((tile) => (
<div key={tile.key} data-testid={`pmg-${tile.key}`} className={`${WELL} px-4 py-3`}>
<div className="text-xs text-muted-foreground">{tile.label}</div>
{isKnown(tile.value) ? (
<div
className={`mt-1 text-xl font-semibold leading-tight tabular-nums ${tile.alarm ? HEALTH_STYLE.down.text : ''}`}
>
{formatCount(tile.value, locale)}
</div>
) : (
<div className="mt-1 text-sm leading-7 text-muted-foreground">{unknown}</div>
)}
</div>
))}
</div>
);
}
function PmgMetricsView({ metrics, unknown }: { metrics: ProxmoxPmgMetrics; unknown: string }) {
const t = useTranslations('proxmox');
return (
<div className="space-y-1 text-sm">
<div>
{t('card.pmg.countIn')}: {formatMetric(metrics.countIn, unknown)}
</div>
<div>
{t('card.pmg.countOut')}: {formatMetric(metrics.countOut, unknown)}
</div>
<div>
{t('card.pmg.spamCount')}: {formatMetric(metrics.spamCount, unknown)}
</div>
<div>
{t('card.pmg.virusCount')}: {formatMetric(metrics.virusCount, unknown)}
</div>
</div>
);
}
function MetricsView({ metrics, unknown }: MetricsViewProps) {
if (metrics.productType === 'pve') return <PveMetricsView metrics={metrics} unknown={unknown} />;
if (metrics.productType === 'pbs') return <PbsMetricsView metrics={metrics} unknown={unknown} />;
return <PmgMetricsView metrics={metrics} unknown={unknown} />;
}
function errorMessage(t: ReturnType<typeof useTranslations>, kind: ProxmoxErrorKind | null): string {
function errorMessage(t: Translator, kind: ProxmoxErrorKind | null): string {
return t(`errors.${kind ?? 'unbekannt'}`);
}
interface ServerCardProps {
server: ProxmoxServer;
isAdmin?: boolean;
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
now?: number;
}
/**
* Anzeige EINES Servers (Aufgabe 6) — verzweigt ueber `productType` auf der
* unterscheidbaren Union aus Aufgabe 3.
* Anzeige EINES Servers (quick-260924-h7x). Die Tiefe entsteht ueber den
* Status: Leiste links, im Statuston getoenter Schatten, Pille oben rechts.
* Messwerte liegen in eingelassenen Feldern. Ein deaktivierter Server
* („offline & verwaist“) zeigt KEINE alten Messwerte mehr — auch wenn das
* Zwischenlager noch welche hat.
*/
export function ServerCard({ server, isAdmin = false }: ServerCardProps) {
export function ServerCard({ server, isAdmin = false, now = Date.now() }: ServerCardProps) {
const t = useTranslations('proxmox');
const unknown = t('card.unknownValue');
const locale = useLocale();
const health = serverHealth(server, now);
const style = HEALTH_STYLE[health];
const status = server.status;
const isOrphan = health === 'orphan';
const polledAge = formatAge(status?.lastPolledAt ?? null, now, locale);
let body: React.ReactNode = null;
if (health === 'orphan') {
body = (
<div className="space-y-1 text-sm" data-testid="orphan-notice">
<p className="text-foreground">{t('card.orphanText')}</p>
{isAdmin && (
<p className="text-muted-foreground">
{t('card.orphanAdminHint')}{' '}
<Link
href="/modules/proxmox/settings"
className="font-medium text-foreground underline-offset-2 hover:underline"
>
{t('card.settingsLink')}
</Link>
</p>
)}
</div>
);
} else if (health === 'idle') {
// Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt
// ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis
// (260923-le6).
body = (
<p className="text-sm text-muted-foreground">
{isAdmin
? t('card.notPolledYet', { refreshLabel: t('card.refresh') })
: t('card.notPolledYetAutomatic')}
</p>
);
} else if (health === 'down' && status) {
const lastOkAge = formatAge(status.lastOkAt, now, locale);
body = (
<div
data-testid="down-notice"
className="space-y-1 rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3"
>
<p className="text-sm text-foreground">{errorMessage(t, status.errorKind)}</p>
{status.errorDetail && (
<p className="break-words text-xs text-muted-foreground">{status.errorDetail}</p>
)}
{lastOkAge && (
<p className="text-xs text-muted-foreground" title={exactTime(status.lastOkAt, locale)}>
{t('card.lastOkAgo', { age: lastOkAge })}
</p>
)}
</div>
);
} else if (status?.metrics) {
const metrics = status.metrics;
body =
metrics.productType === 'pve' ? (
<PveMetricsView metrics={metrics} t={t} locale={locale} />
) : metrics.productType === 'pbs' ? (
<PbsMetricsView metrics={metrics} t={t} locale={locale} now={now} />
) : (
<PmgMetricsView metrics={metrics} t={t} locale={locale} />
);
}
// Gedaempft wird bei „verwaist“ nur, was zum Server gehoert (Symbol, Name,
// Adresse, Fussangabe) — Pille und Hinweistext bleiben voll lesbar.
const dim = isOrphan ? 'opacity-70 saturate-50' : '';
return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{server.name}</div>
{/* Nachbesserung Befund 3: `uppercase` gilt nur dem Produktkuerzel, nicht der Adresse. */}
<div className="text-xs text-muted-foreground">
<span className="uppercase">{server.productType}</span> — {server.baseUrl}
<article
data-testid="server-card"
data-health={health}
aria-label={`${server.name}: ${t(`health.${health}`)}`}
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${
isOrphan ? 'border-dashed border-border' : 'border-border'
} ${style.shadow}`}
>
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
<div className="flex flex-1 flex-col gap-4 py-4 pl-5 pr-4">
<div className="flex items-start justify-between gap-3">
<div className={`flex min-w-0 items-start gap-3 ${dim}`}>
<div
className={`${WELL} flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground`}
>
<ProductIcon productType={server.productType} />
</div>
<div className="min-w-0">
<div className="truncate font-semibold leading-tight" title={server.name}>
{server.name}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t(`card.product.${server.productType}`)}
</div>
<div className="truncate text-xs text-muted-foreground" title={server.baseUrl}>
{server.baseUrl}
</div>
</div>
</div>
<StatusPill health={health} label={t(`health.${health}`)} />
</div>
{body && <div className="flex-1">{body}</div>}
{polledAge && (
<div
data-testid="last-polled"
className={`mt-auto border-t border-border/60 pt-3 text-xs text-muted-foreground ${dim}`}
title={exactTime(status?.lastPolledAt, locale)}
>
{t('card.lastPolledAgo', { age: polledAge })}
</div>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{t('card.lastPolledLabel')}: {formatTimestamp(status?.lastPolledAt ?? null, unknown)}
</div>
{/*
Nachbesserung Befund 2: ein Server, der noch nie abgefragt wurde
(`lastPolledAt === null`), zeigt einen ruhigen Hinweis statt der
Fehlermeldung — die leere Zwischenlagerzeile aus `createServer` hat
`reachable: false` und `errorKind: null`, was sonst faelschlich als
"unbekannter Fehler" erschien. Nicht-Admins sehen den Knopf nicht
(der Poll-Endpunkt verlangt ADMIN/SUPER_ADMIN) und bekommen deshalb
den Text ohne Knopfverweis (260923-le6).
*/}
{status && !status.lastPolledAt && (
<p className="mt-2 text-xs text-muted-foreground">
{isAdmin ? t('card.notPolledYet', { refreshLabel: t('card.refresh') }) : t('card.notPolledYetAutomatic')}
</p>
)}
{status?.lastPolledAt && !status.reachable && (
<div className="mt-2 space-y-1 text-sm">
<p className="text-destructive">
{errorMessage(t, status.errorKind)}
{status.errorDetail ? ` (${status.errorDetail})` : ''}
</p>
{status.lastOkAt && (
<p className="text-xs text-muted-foreground">
{t('card.lastOkLabel')}: {formatTimestamp(status.lastOkAt, unknown)}
</p>
)}
</div>
)}
{status?.reachable && status.metrics && (
<div className="mt-3">
<MetricsView metrics={status.metrics} unknown={unknown} />
</div>
)}
</div>
</article>
);
}
@@ -53,7 +53,10 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
};
}
function withMetrics(metrics: ProxmoxMetrics, overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
function withMetrics(
metrics: ProxmoxMetrics,
overrides: Partial<ProxmoxServer> = {},
): ProxmoxServer {
return makeServer({
productType: metrics.productType,
status: makeStatus({ metrics }),
@@ -61,7 +64,10 @@ function withMetrics(metrics: ProxmoxMetrics, overrides: Partial<ProxmoxServer>
});
}
function pve(nodes: Array<{ cpu: number | null; mem: number | null; maxmem: number | null }>, storages: Array<{ disk: number | null; maxdisk: number | null }> = []): ProxmoxMetrics {
function pve(
nodes: Array<{ cpu: number | null; mem: number | null; maxmem: number | null }>,
storages: Array<{ disk: number | null; maxdisk: number | null }> = [],
): ProxmoxMetrics {
return {
productType: 'pve',
nodeCount: nodes.length,
@@ -125,12 +131,18 @@ describe('serverHealth', () => {
expect(serverHealth(makeServer({ isActive: false }), NOW)).toBe('orphan');
expect(
serverHealth(
makeServer({ isActive: false, status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
makeServer({
isActive: false,
status: makeStatus({ reachable: false, errorKind: 'netz' }),
}),
NOW,
),
).toBe('orphan');
expect(
serverHealth(withMetrics(pve([{ cpu: 0.99, mem: 99, maxmem: 100 }]), { isActive: false }), NOW),
serverHealth(
withMetrics(pve([{ cpu: 0.99, mem: 99, maxmem: 100 }]), { isActive: false }),
NOW,
),
).toBe('orphan');
expect(serverHealth(makeServer({ isActive: false, status: null }), NOW)).toBe('orphan');
});
@@ -138,13 +150,19 @@ describe('serverHealth', () => {
it('kein Zwischenlager oder noch nie abgefragt -> idle', () => {
expect(serverHealth(makeServer({ status: null }), NOW)).toBe('idle');
expect(
serverHealth(makeServer({ status: makeStatus({ lastPolledAt: null, reachable: false }) }), NOW),
serverHealth(
makeServer({ status: makeStatus({ lastPolledAt: null, reachable: false }) }),
NOW,
),
).toBe('idle');
});
it('abgefragt, aber nicht erreichbar -> down', () => {
expect(
serverHealth(makeServer({ status: makeStatus({ reachable: false, errorKind: 'netz' }) }), NOW),
serverHealth(
makeServer({ status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
NOW,
),
).toBe('down');
});
@@ -174,7 +192,14 @@ describe('serverHealth', () => {
});
it('PBS: Fuellstand ab 0.80, Sicherung aelter als 26 h, Pruefung nicht ok -> warn', () => {
const base = { name: 'ds', total: 100, used: 10, free: 90, lastBackupAt: NOW_S - 3600, lastVerifyState: 'ok' };
const base = {
name: 'ds',
total: 100,
used: 10,
free: 90,
lastBackupAt: NOW_S - 3600,
lastVerifyState: 'ok',
};
const pbs = (ds: Partial<typeof base> & Record<string, unknown>): ProxmoxMetrics => ({
productType: 'pbs',
datastores: [{ ...base, ...ds } as never],
@@ -188,7 +213,16 @@ describe('serverHealth', () => {
it('PBS: null bei Belegung, Sicherung oder Pruefung erzeugt KEINE Warnung', () => {
const metrics: ProxmoxMetrics = {
productType: 'pbs',
datastores: [{ name: 'ds', total: null, used: null, free: null, lastBackupAt: null, lastVerifyState: null }],
datastores: [
{
name: 'ds',
total: null,
used: null,
free: null,
lastBackupAt: null,
lastVerifyState: null,
},
],
};
expect(serverHealth(withMetrics(metrics), NOW)).toBe('ok');
});
@@ -42,7 +42,10 @@ export function meterLevel(fraction: number | null | undefined): MeterLevel {
}
/** `used / total` — `null`, sobald ein Wert fehlt oder `total` nicht positiv ist. */
export function ratio(used: number | null | undefined, total: number | null | undefined): number | null {
export function ratio(
used: number | null | undefined,
total: number | null | undefined,
): number | null {
if (!isKnownNumber(used) || !isKnownNumber(total) || total <= 0) return null;
return used / total;
}
@@ -58,7 +61,10 @@ export function toEpochMs(value: number | string | null | undefined): number | n
}
/** Letzte PBS-Sicherung aelter als {@link THRESHOLDS.backupMaxAgeHours}? `null` (noch nie) ist KEINE Warnung. */
export function isBackupStale(lastBackupAt: number | null | undefined, now: number = Date.now()): boolean {
export function isBackupStale(
lastBackupAt: number | null | undefined,
now: number = Date.now(),
): boolean {
const ms = toEpochMs(lastBackupAt);
if (ms === null) return false;
return now - ms > THRESHOLDS.backupMaxAgeHours * 3600 * 1000;
@@ -103,7 +109,10 @@ export function serverHealth(server: ProxmoxServer, now: number = Date.now()): S
}
/** Neue Liste: down, warn, ok, idle, orphan — innerhalb eines Zustands nach `position`. */
export function sortServersByHealth(servers: readonly ProxmoxServer[], now: number = Date.now()): ProxmoxServer[] {
export function sortServersByHealth(
servers: readonly ProxmoxServer[],
now: number = Date.now(),
): ProxmoxServer[] {
const rank = new Map(servers.map((s) => [s.id, HEALTH_ORDER.indexOf(serverHealth(s, now))]));
return [...servers].sort(
(a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0) || a.position - b.position,
@@ -135,7 +144,10 @@ export function formatAge(
if (ms === null) return null;
const seconds = Math.max(0, Math.round((now - ms) / 1000));
if (seconds < 60) {
return new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'auto' }).format(0, 'second');
return new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'auto' }).format(
0,
'second',
);
}
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'always' });
const minutes = Math.floor(seconds / 60);
@@ -0,0 +1,65 @@
import type { MeterLevel, ServerHealth } from './proxmox-status';
/**
* Tailwind-Klassen je Zustand (quick-260924-h7x). Bewusst als vollstaendige,
* statische Zeichenketten — Tailwind findet Klassen nur, wenn sie woertlich im
* Quelltext stehen, zusammengesetzte Namen wie `bg-status-${x}` gingen verloren.
*
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
* zweischichtiger Schatten, der untere im Statuston getoent.
*/
export const HEALTH_STYLE: Record<
ServerHealth,
{ fill: string; pill: string; text: string; shadow: string }
> = {
ok: {
fill: 'bg-status-ok',
pill: 'bg-status-ok/12 text-status-ok-fg',
text: 'text-status-ok-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-ok)]',
},
warn: {
fill: 'bg-status-warn',
pill: 'bg-status-warn/12 text-status-warn-fg',
text: 'text-status-warn-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-warn)]',
},
down: {
fill: 'bg-status-down',
pill: 'bg-status-down/12 text-status-down-fg',
text: 'text-status-down-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-down)]',
},
idle: {
fill: 'bg-status-idle',
pill: 'bg-status-idle/12 text-status-idle-fg',
text: 'text-status-idle-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-idle)]',
},
// Kein farbiger Schatten: ein verwaister Server soll nicht „leuchten“.
orphan: {
fill: 'bg-status-orphan',
pill: 'bg-status-orphan/12 text-status-orphan-fg',
text: 'text-status-orphan-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.04)]',
},
};
/** Balkenfarbe je Stufe; `unknown` wird schraffiert gezeichnet (siehe Meter). */
export const METER_FILL: Record<Exclude<MeterLevel, 'unknown'>, string> = {
ok: 'bg-status-ok',
warn: 'bg-status-warn',
crit: 'bg-status-down',
};
/** Schriftfarbe der Prozentzahl je Stufe. */
export const METER_TEXT: Record<MeterLevel, string> = {
ok: 'text-foreground',
warn: 'text-status-warn-fg',
crit: 'text-status-down-fg',
unknown: 'text-muted-foreground',
};
/** Eingelassenes Messfeld („Well“). */
export const WELL = 'rounded-lg bg-well shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]';
@@ -1,19 +1,66 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useAuthStore } from '@/lib/stores/auth-store';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from './components/HealthBar';
import { sortServersByHealth, summarizeHealth } from './components/proxmox-status';
import { ServerCard } from './components/ServerCard';
import { WELL } from './components/status-styles';
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
const CLOCK_TICK_MS = 60_000;
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={spinning ? 'animate-spin motion-reduce:animate-none' : ''}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<polyline points="21 3 21 8 16 8" />
</svg>
);
}
function SkeletonCard() {
return (
<div className="rounded-xl border border-border bg-card p-4 pl-5 shadow-[0_1px_2px_oklch(0_0_0/0.06)]">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-3.5 w-1/3 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
</div>
<div className="h-6 w-20 rounded-full bg-muted" />
</div>
<div className="mt-4 h-16 rounded-lg bg-muted" />
<div className="mt-2 h-12 rounded-lg bg-muted" />
</div>
);
}
/**
* Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt. Der Knopf
* erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN,
* SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6).
* Modulseite (quick-260924-h7x, vorher 260923-dhh Aufgabe 6) — liest
* ausschliesslich aus dem Zwischenlager, das `GET servers` liefert; kein
* Live-Zugriff bei Proxmox von hier aus (D-05).
*
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
* (down, warn, ok, idle, orphan, darin `position`). „Jetzt aktualisieren“
* loest je Server eine Abfrage aus und laedt danach neu; der Knopf erscheint
* nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)`
* verlangt (260923-le6).
*/
export default function ProxmoxPage() {
const t = useTranslations('proxmox');
@@ -23,10 +70,14 @@ export default function ProxmoxPage() {
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [now, setNow] = useState(() => Date.now());
const reload = useCallback(() => {
listServers()
.then(setServers)
.then((list) => {
setServers(list);
setNow(Date.now());
})
.catch(() => setError(t('loadError')));
}, [t]);
@@ -34,6 +85,11 @@ export default function ProxmoxPage() {
reload();
}, [reload]);
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
return () => window.clearInterval(id);
}, []);
const handleRefresh = async () => {
if (!servers || servers.length === 0) return;
setIsRefreshing(true);
@@ -45,63 +101,99 @@ export default function ProxmoxPage() {
}
};
const sorted = useMemo(() => (servers ? sortServersByHealth(servers, now) : []), [servers, now]);
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<div className="flex items-center gap-3">
{isAdmin && servers !== null && servers.length > 0 && (
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
{isAdmin && (
<div className="flex items-center gap-4">
<Link
href="/modules/proxmox/settings"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button>
)}
{t('card.settingsNav')}
</Link>
{servers !== null && servers.length > 0 && (
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-70"
>
<RefreshIcon spinning={isRefreshing} />
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button>
)}
</div>
)}
</div>
{error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
{error}
</p>
)}
{!error && servers === null && (
<div
aria-busy="true"
className="grid animate-pulse gap-4 motion-reduce:animate-none lg:grid-cols-2"
>
<span className="sr-only">{t('loading')}</span>
<SkeletonCard />
<SkeletonCard />
</div>
)}
{!error && servers !== null && servers.length === 0 && (
<div className={`${WELL} flex flex-col items-center gap-3 px-6 py-10 text-center`}>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<rect x="3" y="4" width="18" height="7" rx="1.5" />
<rect x="3" y="13" width="18" height="7" rx="1.5" />
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
</svg>
<p className="max-w-md text-sm text-muted-foreground">{t('emptyState')}</p>
{isAdmin && (
<Link
href="/modules/proxmox/settings"
className="text-sm text-primary hover:underline"
className="text-sm font-medium text-foreground hover:underline"
>
{t('card.settingsLink')}
</Link>
)}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{!error && servers === null && (
<p className="text-sm text-muted-foreground">{t('loading')}</p>
)}
{!error && servers !== null && servers.length === 0 && (
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
{t('emptyState')}
{isAdmin && (
<>
{' '}
<Link href="/modules/proxmox/settings" className="text-primary hover:underline">
{t('card.settingsLink')}
</Link>
</>
)}
</div>
)}
{!error && servers !== null && servers.length > 0 && (
<ul className="space-y-3">
{servers.map((server) => (
<li key={server.id}>
<ServerCard server={server} isAdmin={isAdmin} />
</li>
))}
</ul>
<>
<HealthBar counts={counts} />
<ul className="grid gap-4 lg:grid-cols-2">
{sorted.map((server) => (
<li key={server.id} className="min-w-0">
<ServerCard server={server} isAdmin={isAdmin} now={now} />
</li>
))}
</ul>
</>
)}
</div>
);
@@ -1,6 +1,19 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProxmoxServer } from '@/lib/proxmox-api';
import de from '@/messages/de.json';
// Echter next-intl-Provider mit den deutschen Texten (quick-260924-h7x) —
// ServerCard braucht `useLocale` und ICU-Plural.
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
// "Jetzt aktualisieren" ruft `pollServer` je Server.
@@ -26,34 +39,6 @@ vi.mock('next/link', () => ({
),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
title: 'Proxmox',
description: 'Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick.',
loading: 'Lade Serverliste...',
loadError: 'Die Serverliste konnte nicht geladen werden.',
emptyState:
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
'card.refresh': 'Jetzt aktualisieren',
'card.refreshing': 'Wird aktualisiert...',
'card.settingsLink': 'Zu den Einstellungen',
'card.unknownValue': 'unbekannt',
'card.lastPolledLabel': 'Letzte Abfrage',
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
'card.notPolledYetAutomatic':
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, String(v));
}
}
return result;
},
}));
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
selector({ user }),
@@ -176,3 +161,119 @@ describe('ProxmoxPage role gating (260923-le6)', () => {
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
});
});
// Ein erreichbarer, abgefragter Server; Zustand ueber die Ueberschreibungen.
function makePolledServer(
overrides: Partial<ProxmoxServer> = {},
statusOverrides: Record<string, unknown> = {},
) {
const base = makeUnpolledServer(overrides);
return {
...base,
status: {
...base.status,
lastPolledAt: new Date(Date.now() - 3 * 60_000).toISOString(),
lastOkAt: new Date(Date.now() - 3 * 60_000).toISOString(),
reachable: true,
...statusOverrides,
},
} as ProxmoxServer;
}
describe('ProxmoxPage — Gesundheitsbalken und Sortierung (quick-260924-h7x)', () => {
const servers = [
makePolledServer({ id: 'a', name: 'ok-server', position: 0 }),
makePolledServer(
{ id: 'b', name: 'down-server', position: 1 },
{ reachable: false, errorKind: 'netz' },
),
makePolledServer({ id: 'c', name: 'orphan-server', position: 2, isActive: false }),
makePolledServer(
{ id: 'd', name: 'warn-server', position: 3, productType: 'pmg' },
{ metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 0, virusCount: 2 } },
),
makeUnpolledServer({ id: 'e', name: 'idle-server', position: 4 }),
];
it('Karten stehen in der Reihenfolge down, warn, ok, idle, orphan', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue(servers);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('ok-server');
const order = screen.getAllByTestId('server-card').map((c) => c.getAttribute('data-health'));
expect(order).toEqual(['down', 'warn', 'ok', 'idle', 'orphan']);
});
it('der Balken liest die Zusammenfassung vor und hat je belegtem Zustand ein Segment', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue(servers);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('ok-server');
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
expect(bar).toHaveAccessibleName(
'Zustand der Server: 1 nicht erreichbar, 1 mit Warnung, 1 in Ordnung, 1 noch nicht abgefragt, 1 offline & verwaist',
);
expect(Array.from(bar.children).map((s) => s.getAttribute('data-segment'))).toEqual([
'down',
'warn',
'ok',
'idle',
'orphan',
]);
});
it('leere Zustaende entfallen in Balken und Legende', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue([
makePolledServer({ id: 'a', name: 'eins', position: 0 }),
makePolledServer({ id: 'b', name: 'zwei', position: 1 }),
]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('eins');
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
expect(bar).toHaveAccessibleName('Zustand der Server: 2 in Ordnung');
expect(bar.children).toHaveLength(1);
const legend = within(screen.getByTestId('health-bar')).getByRole('list', { hidden: true });
expect(legend.children).toHaveLength(1);
});
it('waehrend des Ladens erscheinen Skelett-Karten ohne Daueranimation bei reduzierter Bewegung', async () => {
mockUser({ role: 'USER' });
mockListServers.mockReturnValue(new Promise(() => undefined));
const { default: ProxmoxPage } = await import('./page');
const { container } = render(<ProxmoxPage />);
const busy = container.querySelector('[aria-busy="true"]');
expect(busy).not.toBeNull();
expect(busy).toHaveClass('animate-pulse', 'motion-reduce:animate-none');
expect(screen.getByText('Lade Serverliste...')).toBeInTheDocument();
});
it('Einstellungen-Verweis nur fuer Admins', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue([makePolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.queryByRole('link', { name: 'Einstellungen' })).not.toBeInTheDocument();
cleanup();
mockUser({ role: 'ADMIN' });
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.getByRole('link', { name: 'Einstellungen' })).toHaveAttribute(
'href',
'/modules/proxmox/settings',
);
});
});
+45
View File
@@ -44,6 +44,19 @@
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
/* Statusfarben (quick-260924-h7x): Flaechen (Balken, Punkte, Leisten) und
die abgedunkelte bzw. aufgehellte Textvariante `-fg` fuer Schrift. */
--color-status-ok: var(--status-ok);
--color-status-warn: var(--status-warn);
--color-status-down: var(--status-down);
--color-status-idle: var(--status-idle);
--color-status-orphan: var(--status-orphan);
--color-status-ok-fg: var(--status-ok-fg);
--color-status-warn-fg: var(--status-warn-fg);
--color-status-down-fg: var(--status-down-fg);
--color-status-idle-fg: var(--status-idle-fg);
--color-status-orphan-fg: var(--status-orphan-fg);
--color-well: var(--well);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
@@ -83,6 +96,24 @@
--sidebar-accent: oklch(0.93 0 0);
--sidebar-accent-foreground: oklch(0.25 0 0);
--sidebar-border: oklch(0.90 0 0);
/* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem
Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis
4,9:1 (gemessen, WCAG-Formel). Fuer Text gibt es deshalb die `-fg`-
Variante, gemessen auf --card: ok 5,6:1, warn 5,7:1, down 6,1:1,
idle/orphan 6,0:1 (auf der Pille mit 12 % Statusflaeche >= 4,9:1). */
--status-ok: oklch(0.62 0.15 152);
--status-warn: oklch(0.72 0.15 70);
--status-down: oklch(0.58 0.21 27);
--status-idle: oklch(0.62 0.02 260);
--status-orphan: oklch(0.55 0.01 260);
--status-ok-fg: oklch(0.50 0.13 152);
--status-warn-fg: oklch(0.52 0.12 60);
--status-down-fg: oklch(0.52 0.20 27);
--status-idle-fg: oklch(0.50 0.02 260);
--status-orphan-fg: oklch(0.50 0.01 260);
/* Eingelassene Messfelder. */
--well: oklch(0.975 0.003 260);
}
.dark {
@@ -110,6 +141,20 @@
--sidebar-accent: oklch(0.91 0.19 102 / 0.15);
--sidebar-accent-foreground: oklch(0.91 0.19 102);
--sidebar-border: oklch(0.30 0.01 260);
/* Statusfarben (dark). Textvariante auf --card gemessen: ok 7,4:1,
warn 8,5:1, down 6,1:1, idle 6,1:1, orphan 5,6:1. */
--status-ok: oklch(0.72 0.15 152);
--status-warn: oklch(0.80 0.14 75);
--status-down: oklch(0.68 0.19 27);
--status-idle: oklch(0.60 0.02 260);
--status-orphan: oklch(0.52 0.01 260);
--status-ok-fg: oklch(0.76 0.15 152);
--status-warn-fg: oklch(0.82 0.14 75);
--status-down-fg: oklch(0.74 0.16 27);
--status-idle-fg: oklch(0.72 0.02 260);
--status-orphan-fg: oklch(0.70 0.01 260);
--well: oklch(0.235 0.01 260);
}
body {