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:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user