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
+1
View File
@@ -9,6 +9,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
- Neues Modul „Proxmox“: zeigt Zustand und Auslastung Ihrer Proxmox-Server (Virtualisierung, Datensicherung und Mail-Gateway) – nur lesend, Tessera ändert dort nichts. Die Server trägt ein Administrator in den Einstellungen ein.
- Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig
- Proxmox-Seite neu gestaltet: ein farbiger Balken oben zeigt auf einen Blick, wie viele Server in Ordnung, mit Warnung, nicht erreichbar, noch nicht abgefragt oder offline sind; jede Karte trägt ihren Zustand in Farbe und als Wort, auffällige Server stehen vorn; Auslastung als Balken mit Prozentzahl (ab 80 % Warnung), letzte Sicherung und letzte Abfrage als „vor 5 Std.“; ein deaktivierter Server erscheint als „Offline & verwaist“ ohne veraltete Messwerte
### Behoben
@@ -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 UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
async function card(server: ProxmoxServer, isAdmin?: boolean) {
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',
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />);
return screen.getByTestId('server-card');
}
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 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('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('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: 4_294_967_296, maxmem: 8_589_934_592 },
{ 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');
});
render(<ServerCard server={server} />);
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: [],
},
}),
}),
);
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');
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');
});
});
it('PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
describe('ServerCard — PBS', () => {
it('je Datenspeicher Fuellstand, letzte Sicherung relativ und Pruefpille', async () => {
const el = await card(
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',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{ name: 'backup-store', total: 1000, used: 400, free: 600, lastBackupAt: 1758000000, lastVerifyState: 'ok' },
{
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');
});
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-Datenspeicher ohne Sicherung zeigt "noch keine Sicherung"', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
it('Sicherung aelter als 26 h und fehlgeschlagene Pruefung -> Warnung', async () => {
const el = await card(
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',
status: makeStatus({
metrics: {
productType: 'pbs',
datastores: [
{ name: 'leer', total: 10, used: 0, free: 10, lastBackupAt: null, lastVerifyState: null },
{
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');
});
});
render(<ServerCard server={server} />);
expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung');
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt');
});
it('PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren', async () => {
const { ServerCard } = await import('./ServerCard');
const server = makeServer({
describe('ServerCard — PMG', () => {
it('vier Zahlfelder: eingehend, ausgehend, Spam, Viren', async () => {
const el = await card(
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({
status: makeStatus({
metrics: {
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 },
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');
});
render(<ServerCard server={server} />);
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(screen.getByText(/Eingehend:/)).toHaveTextContent('unbekannt');
expect(screen.queryByText(/Eingehend: 0/)).not.toBeInTheDocument();
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
expect(el).toHaveAttribute('data-health', 'warn');
expect(within(within(el).getByTestId('pmg-virus')).getByText('3')).toHaveClass(
'text-status-down-fg',
);
});
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',
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: null,
rawSample: null,
updatedAt: '2026-09-23T10:05:00.000Z',
metrics: null,
},
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();
});
render(<ServerCard server={server} />);
expect(screen.getByText('Der Zugang wurde abgelehnt.')).toBeInTheDocument();
expect(screen.getByText(/Letzte erfolgreiche Messung/)).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();
});
});
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 />);
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(
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
within(el).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();
expect(
within(el).queryByText('Ein unerwarteter Fehler ist aufgetreten.'),
).not.toBeInTheDocument();
expect(within(el).queryByTestId('last-polled')).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} />);
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
const el = await card(makeServer({ status: UNPOLLED }), false);
expect(
screen.getByText(
within(el).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();
expect(within(el).queryByText(/Jetzt aktualisieren/)).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,
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: [],
},
});
render(<ServerCard server={server} />);
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(
screen.getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
within(el).getByText('Dieser Server ist deaktiviert und wird nicht mehr abgefragt.'),
).toBeInTheDocument();
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
expect(within(el).queryByTestId('guests-running')).not.toBeInTheDocument();
expect(within(el).queryByText('altknoten')).not.toBeInTheDocument();
expect(within(el).queryAllByTestId('meter')).toHaveLength(0);
});
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' });
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();
});
render(<ServerCard server={server} />);
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 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');
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);
}
return 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 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();
function formatPercent(fraction: number, locale: string): string {
return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }).format(
fraction,
);
}
interface MetricsViewProps {
metrics: ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
unknown: string;
function formatCount(value: number, locale: string): string {
return new Intl.NumberFormat(locale).format(value);
}
function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unknown: string }) {
const t = useTranslations('proxmox');
/** 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 (
<div className="space-y-2 text-sm">
<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 (
<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 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>
{t('card.pve.nodeCount')}: {metrics.nodeCount}
<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>
{t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })}
<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 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>
<div>
{t('card.pbs.lastBackup')}:{' '}
{ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)}
<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>
{t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)}
);
})}
</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);
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}
</div>
</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')}
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>
)}
{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>
);
} 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 (
<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>
</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 && (
{isAdmin && (
<div className="flex items-center gap-4">
<Link
href="/modules/proxmox/settings"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{t('card.settingsNav')}
</Link>
{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"
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>
)}
{isAdmin && (
<Link
href="/modules/proxmox/settings"
className="text-sm text-primary hover:underline"
>
{t('card.settingsLink')}
</Link>
</div>
)}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{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 && (
<p className="text-sm text-muted-foreground">{t('loading')}</p>
<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="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
{t('emptyState')}
<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-primary hover:underline">
<Link
href="/modules/proxmox/settings"
className="text-sm font-medium text-foreground 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} />
<>
<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 {
+35 -8
View File
@@ -720,24 +720,36 @@
"emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.",
"card": {
"unknownValue": "unbekannt",
"lastPolledLabel": "Letzte Abfrage",
"lastPolledAgo": "Letzte Abfrage {age}",
"notPolledYet": "Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.",
"notPolledYetAutomatic": "Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.",
"lastOkLabel": "Letzte erfolgreiche Messung",
"lastOkAgo": "Zuletzt erreichbar {age}",
"orphanText": "Dieser Server ist deaktiviert und wird nicht mehr abgefragt.",
"orphanAdminHint": "Sie können ihn in den Einstellungen wieder aktivieren.",
"refresh": "Jetzt aktualisieren",
"refreshing": "Wird aktualisiert...",
"settingsLink": "Zu den Einstellungen",
"settingsNav": "Einstellungen",
"product": {
"pve": "Virtualisierung",
"pbs": "Datensicherung",
"pmg": "Mail-Gateway"
},
"pve": {
"nodeCount": "Knoten",
"guests": "{running} laufend / {stopped} gestoppt",
"cpu": "Prozessorlast",
"mem": "Speicher"
"running": "{count, plural, one {Gast läuft} other {Gäste laufen}}",
"stopped": "gestoppt",
"nodes": "{count, plural, one {# Knoten} other {# Knoten}}",
"cpu": "Prozessor",
"mem": "Arbeitsspeicher"
},
"pbs": {
"used": "Belegt",
"lastBackup": "Letzte Sicherung",
"lastBackupAgo": "Letzte Sicherung {age}",
"noBackupYet": "noch keine Sicherung",
"verifyState": "Letzte Prüfung"
"verifyOk": "Prüfung in Ordnung",
"verifyFailed": "Prüfung: {state}",
"verifyUnknown": "Prüfung unbekannt",
"noDatastores": "Keine Datenspeicher gemeldet."
},
"pmg": {
"countIn": "Eingehend",
@@ -746,6 +758,21 @@
"virusCount": "Viren"
}
},
"health": {
"ok": "In Ordnung",
"warn": "Warnung",
"down": "Nicht erreichbar",
"idle": "Noch nicht abgefragt",
"orphan": "Offline & verwaist"
},
"legend": {
"ok": "{count, plural, one {in Ordnung} other {in Ordnung}}",
"warn": "{count, plural, one {mit Warnung} other {mit Warnung}}",
"down": "{count, plural, one {nicht erreichbar} other {nicht erreichbar}}",
"idle": "{count, plural, one {noch nicht abgefragt} other {noch nicht abgefragt}}",
"orphan": "{count, plural, one {offline & verwaist} other {offline & verwaist}}",
"barLabel": "Zustand der Server: {summary}"
},
"errors": {
"netz": "Der Server ist nicht erreichbar. Bitte prüfen Sie die Adresse und die Netzwerkverbindung.",
"zugang": "Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.",
+34 -7
View File
@@ -720,24 +720,36 @@
"emptyState": "No server configured yet. Add one in the module settings.",
"card": {
"unknownValue": "unknown",
"lastPolledLabel": "Last poll",
"lastPolledAgo": "Last poll {age}",
"notPolledYet": "No poll has run yet. Click \"{refreshLabel}\" above.",
"notPolledYetAutomatic": "No poll has run yet. The values will appear after the next automatic poll.",
"lastOkLabel": "Last successful measurement",
"lastOkAgo": "Last reachable {age}",
"orphanText": "This server is deactivated and is no longer polled.",
"orphanAdminHint": "You can reactivate it in the settings.",
"refresh": "Refresh now",
"refreshing": "Refreshing...",
"settingsLink": "Go to settings",
"settingsNav": "Settings",
"product": {
"pve": "Virtualization",
"pbs": "Backup",
"pmg": "Mail gateway"
},
"pve": {
"nodeCount": "Nodes",
"guests": "{running} running / {stopped} stopped",
"cpu": "CPU load",
"running": "{count, plural, one {guest running} other {guests running}}",
"stopped": "stopped",
"nodes": "{count, plural, one {# node} other {# nodes}}",
"cpu": "CPU",
"mem": "Memory"
},
"pbs": {
"used": "Used",
"lastBackup": "Last backup",
"lastBackupAgo": "Last backup {age}",
"noBackupYet": "no backup yet",
"verifyState": "Last verification"
"verifyOk": "Verification OK",
"verifyFailed": "Verification: {state}",
"verifyUnknown": "Verification unknown",
"noDatastores": "No datastores reported."
},
"pmg": {
"countIn": "Inbound",
@@ -746,6 +758,21 @@
"virusCount": "Viruses"
}
},
"health": {
"ok": "Healthy",
"warn": "Warning",
"down": "Unreachable",
"idle": "Not polled yet",
"orphan": "Offline & orphaned"
},
"legend": {
"ok": "{count, plural, one {healthy} other {healthy}}",
"warn": "{count, plural, one {with warning} other {with warnings}}",
"down": "{count, plural, one {unreachable} other {unreachable}}",
"idle": "{count, plural, one {not polled yet} other {not polled yet}}",
"orphan": "{count, plural, one {offline & orphaned} other {offline & orphaned}}",
"barLabel": "Server health: {summary}"
},
"errors": {
"netz": "The server is unreachable. Please check the address and the network connection.",
"zugang": "Access was denied. Please check the username and password, or the token details.",
@@ -189,4 +189,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'gemessene',
'Messung',
'Prozessorlast',
// quick-260924-h7x: Proxmox-Seite neu gestaltet — korrektes Deutsch mit „ss“
'Prozessor',
'Arbeitsspeicher',
];