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:
@@ -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.
|
- 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.
|
- 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
|
- 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
|
### 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 { 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>) => {
|
* ServerCard (quick-260924-h7x) — mit dem ECHTEN next-intl-Provider und den
|
||||||
const translations: Record<string, string> = {
|
* echten deutschen Texten, damit ICU-Plural und Zahlformate mitgeprueft
|
||||||
'card.unknownValue': 'unbekannt',
|
* werden.
|
||||||
'card.lastPolledLabel': 'Letzte Abfrage',
|
*/
|
||||||
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
|
|
||||||
'card.notPolledYetAutomatic':
|
vi.mock('next/link', () => ({
|
||||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||||
'card.refresh': 'Jetzt aktualisieren',
|
<a href={href} {...rest}>
|
||||||
'card.lastOkLabel': 'Letzte erfolgreiche Messung',
|
{children}
|
||||||
'card.pve.nodeCount': 'Knoten',
|
</a>
|
||||||
'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;
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
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 {
|
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||||
return {
|
return {
|
||||||
id: 'srv-1',
|
id: 'srv-1',
|
||||||
@@ -67,290 +72,393 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ServerCard', () => {
|
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
|
||||||
it('PVE-Server zeigt Knotenzahl, laufende/gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung', async () => {
|
|
||||||
|
async function card(server: ProxmoxServer, isAdmin?: boolean) {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
const { ServerCard } = await import('./ServerCard');
|
||||||
const server = makeServer({
|
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />);
|
||||||
status: {
|
return screen.getByTestId('server-card');
|
||||||
id: 's1',
|
}
|
||||||
serverId: 'srv-1',
|
|
||||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
describe('ServerCard — Kopf und Status', () => {
|
||||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
it('zeigt Name, ausgeschriebenen Produktnamen und die Adresse unveraendert (keine Grossschrift)', async () => {
|
||||||
reachable: true,
|
const el = await card(makeServer({ baseUrl: 'https://172.21.0.1:8006', status: makeStatus() }));
|
||||||
errorKind: null,
|
|
||||||
errorDetail: null,
|
expect(within(el).getByText('pve-1')).toBeInTheDocument();
|
||||||
rawSample: null,
|
expect(within(el).getByText('Virtualisierung')).toBeInTheDocument();
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
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: {
|
metrics: {
|
||||||
productType: 'pve',
|
productType: 'pve',
|
||||||
nodeCount: 2,
|
nodeCount: 2,
|
||||||
guestsRunning: 3,
|
guestsRunning: 3,
|
||||||
guestsStopped: 1,
|
guestsStopped: 1,
|
||||||
nodes: [
|
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: [],
|
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');
|
const [cpu, mem] = within(el).getAllByTestId('meter');
|
||||||
expect(screen.getByText(/laufend/)).toHaveTextContent('3 laufend / 1 gestoppt');
|
expect(cpu).toHaveTextContent('unbekannt');
|
||||||
expect(screen.getByText(/Prozessorlast/)).toHaveTextContent('Prozessorlast: 25%');
|
expect(mem).toHaveTextContent('unbekannt');
|
||||||
expect(screen.getByText(/Speicher:/)).toHaveTextContent('4.0 GB / 8.0 GB');
|
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 () => {
|
describe('ServerCard — PBS', () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
it('je Datenspeicher Fuellstand, letzte Sicherung relativ und Pruefpille', async () => {
|
||||||
const server = makeServer({
|
const el = await card(
|
||||||
|
makeServer({
|
||||||
productType: 'pbs',
|
productType: 'pbs',
|
||||||
status: {
|
status: makeStatus({
|
||||||
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: {
|
metrics: {
|
||||||
productType: 'pbs',
|
productType: 'pbs',
|
||||||
datastores: [
|
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} />);
|
it('Sicherung aelter als 26 h und fehlgeschlagene Pruefung -> Warnung', async () => {
|
||||||
|
const el = await card(
|
||||||
expect(screen.getByText('backup-store')).toBeInTheDocument();
|
makeServer({
|
||||||
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({
|
|
||||||
productType: 'pbs',
|
productType: 'pbs',
|
||||||
status: {
|
status: makeStatus({
|
||||||
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: {
|
metrics: {
|
||||||
productType: 'pbs',
|
productType: 'pbs',
|
||||||
datastores: [
|
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} />);
|
describe('ServerCard — PMG', () => {
|
||||||
|
it('vier Zahlfelder: eingehend, ausgehend, Spam, Viren', async () => {
|
||||||
expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung');
|
const el = await card(
|
||||||
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt');
|
makeServer({
|
||||||
});
|
|
||||||
|
|
||||||
it('PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren', async () => {
|
|
||||||
const { ServerCard } = await import('./ServerCard');
|
|
||||||
const server = makeServer({
|
|
||||||
productType: 'pmg',
|
productType: 'pmg',
|
||||||
status: {
|
status: makeStatus({
|
||||||
id: 's1',
|
metrics: {
|
||||||
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',
|
productType: 'pmg',
|
||||||
status: {
|
countIn: 1200,
|
||||||
id: 's1',
|
countOut: 45,
|
||||||
serverId: 'srv-1',
|
spamCount: 12,
|
||||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
virusCount: 0,
|
||||||
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 },
|
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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(el).toHaveAttribute('data-health', 'warn');
|
||||||
expect(screen.queryByText(/Eingehend: 0/)).not.toBeInTheDocument();
|
expect(within(within(el).getByTestId('pmg-virus')).getByText('3')).toHaveClass(
|
||||||
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
|
'text-status-down-fg',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein Server mit reachable:false zeigt den Klartext der Ursache und den Zeitpunkt der letzten erfolgreichen Messung', async () => {
|
it('null-Werte erscheinen als „unbekannt“ — nie 0', async () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
const el = await card(
|
||||||
const server = makeServer({
|
makeServer({
|
||||||
status: {
|
productType: 'pmg',
|
||||||
id: 's1',
|
status: makeStatus({
|
||||||
serverId: 'srv-1',
|
metrics: {
|
||||||
lastPolledAt: '2026-09-23T10:05:00.000Z',
|
productType: 'pmg',
|
||||||
lastOkAt: '2026-09-23T09:00:00.000Z',
|
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,
|
reachable: false,
|
||||||
errorKind: 'zugang',
|
errorKind: 'zugang',
|
||||||
errorDetail: null,
|
errorDetail: 'HTTP 401',
|
||||||
rawSample: null,
|
}),
|
||||||
updatedAt: '2026-09-23T10:05:00.000Z',
|
}),
|
||||||
metrics: null,
|
);
|
||||||
},
|
|
||||||
|
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} />);
|
it('nie erreichbar gewesen: keine „Zuletzt erreichbar“-Zeile', async () => {
|
||||||
|
const el = await card(
|
||||||
expect(screen.getByText('Der Zugang wurde abgelehnt.')).toBeInTheDocument();
|
makeServer({ status: makeStatus({ lastOkAt: null, reachable: false, errorKind: 'netz' }) }),
|
||||||
expect(screen.getByText(/Letzte erfolgreiche Messung/)).toBeInTheDocument();
|
);
|
||||||
|
expect(within(el).queryByText(/Zuletzt erreichbar/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('der Zeitpunkt der letzten Abfrage steht bei jedem Server', async () => {
|
describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
it('Admin: ruhiger Hinweis mit Knopfverweis statt Fehlermeldung', async () => {
|
||||||
const server = makeServer({
|
const el = await card(makeServer({ status: UNPOLLED }), true);
|
||||||
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(el).toHaveAttribute('data-health', 'idle');
|
||||||
|
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Noch nicht abgefragt');
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
// Ohne die Korrektur erschiene hier faelschlich die Sammelmeldung — roter Test.
|
expect(
|
||||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
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 () => {
|
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
const el = await card(makeServer({ status: UNPOLLED }), false);
|
||||||
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(
|
expect(
|
||||||
screen.getByText(
|
within(el).getByText(
|
||||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
expect(within(el).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 () => {
|
it('isAdmin weggelassen: verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
const el = await card(makeServer({ status: UNPOLLED }));
|
||||||
const server = makeServer({
|
|
||||||
status: {
|
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||||
id: 's1',
|
expect(within(el).getByText(/automatischen Abfrage/)).toBeInTheDocument();
|
||||||
serverId: 'srv-1',
|
});
|
||||||
lastPolledAt: null,
|
});
|
||||||
lastOkAt: null,
|
|
||||||
reachable: false,
|
describe('ServerCard — offline & verwaist', () => {
|
||||||
errorKind: null,
|
const staleMetrics = makeStatus({
|
||||||
errorDetail: null,
|
metrics: {
|
||||||
rawSample: null,
|
productType: 'pve',
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
nodeCount: 1,
|
||||||
metrics: null,
|
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(
|
expect(
|
||||||
screen.getByText(
|
within(el).getByText('Dieser Server ist deaktiviert und wird nicht mehr abgefragt.'),
|
||||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
expect(within(el).queryByTestId('guests-running')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Unerwarteter Fehler.')).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 () => {
|
it('deaktiviert und nicht erreichbar: keine Fehlermeldung, sondern der Verwaist-Hinweis', async () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
const el = await card(
|
||||||
const server = makeServer({ baseUrl: 'https://172.21.0.1:8006' });
|
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/);
|
const user = await card(makeServer({ isActive: false, status: staleMetrics }), false);
|
||||||
expect(line).toHaveTextContent('pve — https://172.21.0.1:8006');
|
expect(within(user).queryByRole('link')).not.toBeInTheDocument();
|
||||||
const productSpan = line.querySelector('span');
|
expect(within(user).queryByText(/wieder aktivieren/)).not.toBeInTheDocument();
|
||||||
expect(productSpan).not.toBeNull();
|
|
||||||
expect(productSpan).toHaveClass('uppercase');
|
|
||||||
expect(line).not.toHaveClass('uppercase');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,88 +1,238 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import Link from 'next/link';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import type {
|
import type {
|
||||||
ProxmoxErrorKind,
|
ProxmoxErrorKind,
|
||||||
ProxmoxPbsMetrics,
|
ProxmoxPbsMetrics,
|
||||||
ProxmoxPmgMetrics,
|
ProxmoxPmgMetrics,
|
||||||
|
ProxmoxProductType,
|
||||||
ProxmoxPveMetrics,
|
ProxmoxPveMetrics,
|
||||||
ProxmoxServer,
|
ProxmoxServer,
|
||||||
} from '@/lib/proxmox-api';
|
} 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';
|
||||||
|
|
||||||
/**
|
type Translator = ReturnType<typeof useTranslations>;
|
||||||
* 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]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prozentwert einer Auslastung (0..1) gerundet, z. B. `25%`. */
|
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
function formatPercent(fraction: number): string {
|
|
||||||
return `${Math.round(fraction * 100)}%`;
|
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):
|
* „12,0 / 64,0 GB“ — beide Werte in DERSELBEN Einheit, gewaehlt nach der
|
||||||
* ist der Wert `null`/`undefined`, erscheint der uebersetzte Text
|
* Gesamtgroesse, Zahlformat nach Sprache (deutsch mit Komma). `null`, wenn
|
||||||
* "unbekannt" — sonst der Wert mit seiner Einheit. Dadurch kann kein Zweig
|
* einer der Werte unbekannt ist — dann steht an der Stelle nichts statt
|
||||||
* versehentlich eine `0` anzeigen, wo nichts gemessen wurde. Die Feldnamen
|
* einer erfundenen Zahl.
|
||||||
* 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".
|
|
||||||
*/
|
*/
|
||||||
function formatMetric(
|
function formatBytePair(used: number | null, total: number | null, locale: string): string | null {
|
||||||
value: number | string | null | undefined,
|
if (!isKnown(used) || !isKnown(total) || total <= 0) return null;
|
||||||
unknownLabel: string,
|
const exponent = Math.min(
|
||||||
formatter?: (v: number) => string,
|
BYTE_UNITS.length - 1,
|
||||||
): string {
|
Math.max(0, Math.floor(Math.log(total) / Math.log(1024))),
|
||||||
if (value === null || value === undefined) return unknownLabel;
|
);
|
||||||
if (typeof value === 'number') {
|
const divisor = 1024 ** exponent;
|
||||||
if (Number.isNaN(value)) return unknownLabel;
|
const digits = exponent === 0 ? 0 : 1;
|
||||||
return formatter ? formatter(value) : String(value);
|
const nf = new Intl.NumberFormat(locale, {
|
||||||
}
|
minimumFractionDigits: digits,
|
||||||
return value;
|
maximumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
return `${nf.format(used / divisor)} / ${nf.format(total / divisor)} ${BYTE_UNITS[exponent]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(value: string | number | null | undefined, unknownLabel: string): string {
|
function formatPercent(fraction: number, locale: string): string {
|
||||||
if (value === null || value === undefined) return unknownLabel;
|
return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }).format(
|
||||||
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
fraction,
|
||||||
if (Number.isNaN(date.getTime())) return unknownLabel;
|
);
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetricsViewProps {
|
function formatCount(value: number, locale: string): string {
|
||||||
metrics: ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
|
return new Intl.NumberFormat(locale).format(value);
|
||||||
unknown: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unknown: string }) {
|
/** Exakter Zeitpunkt fuer das `title`-Attribut der relativen Angaben. */
|
||||||
const t = useTranslations('proxmox');
|
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 (
|
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>
|
<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>
|
||||||
<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>
|
||||||
|
<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) => (
|
{metrics.nodes.map((node) => (
|
||||||
<div key={node.node} className="rounded border border-border/60 px-2 py-1 text-xs">
|
<div
|
||||||
<div className="font-medium">{node.node}</div>
|
key={node.node}
|
||||||
<div>
|
data-testid={`pve-node-${node.node}`}
|
||||||
{t('card.pve.cpu')}: {formatMetric(node.cpu, unknown, formatPercent)}
|
className={`${WELL} grid grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] items-start gap-4 px-4 py-3`}
|
||||||
{' · '}
|
>
|
||||||
{t('card.pve.mem')}: {formatMetric(node.mem, unknown, formatBytes)}
|
<div className="truncate pt-0.5 text-sm font-medium" title={node.node}>
|
||||||
{node.maxmem !== null && node.mem !== null
|
{node.node}
|
||||||
? ` / ${formatMetric(node.maxmem, unknown, formatBytes)}`
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -90,124 +240,254 @@ function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unkn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PbsMetricsView({ metrics, unknown }: { metrics: ProxmoxPbsMetrics; unknown: string }) {
|
function PbsMetricsView({
|
||||||
const t = useTranslations('proxmox');
|
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 (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2">
|
||||||
{metrics.datastores.map((ds) => (
|
{metrics.datastores.map((ds) => {
|
||||||
<div key={ds.name} className="rounded border border-border/60 px-2 py-1 text-xs">
|
const stale = isBackupStale(ds.lastBackupAt, now);
|
||||||
<div className="font-medium">{ds.name}</div>
|
const backupAge = formatAge(ds.lastBackupAt, now, locale);
|
||||||
<div>
|
const verify =
|
||||||
{t('card.pbs.used')}: {formatMetric(ds.used, unknown, formatBytes)}
|
ds.lastVerifyState === null
|
||||||
{ds.total !== null && ds.used !== null ? ` / ${formatMetric(ds.total, unknown, formatBytes)}` : ''}
|
? { 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>
|
||||||
<div>
|
<Meter
|
||||||
{t('card.pbs.lastBackup')}:{' '}
|
label={t('card.pbs.used')}
|
||||||
{ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)}
|
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>
|
||||||
<div>
|
);
|
||||||
{t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)}
|
})}
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PmgMetricsView({ metrics, unknown }: { metrics: ProxmoxPmgMetrics; unknown: string }) {
|
function errorMessage(t: Translator, kind: ProxmoxErrorKind | null): 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 {
|
|
||||||
return t(`errors.${kind ?? 'unbekannt'}`);
|
return t(`errors.${kind ?? 'unbekannt'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerCardProps {
|
interface ServerCardProps {
|
||||||
server: ProxmoxServer;
|
server: ProxmoxServer;
|
||||||
isAdmin?: boolean;
|
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
|
* Anzeige EINES Servers (quick-260924-h7x). Die Tiefe entsteht ueber den
|
||||||
* unterscheidbaren Union aus Aufgabe 3.
|
* 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 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 status = server.status;
|
||||||
|
const isOrphan = health === 'orphan';
|
||||||
|
const polledAge = formatAge(status?.lastPolledAt ?? null, now, locale);
|
||||||
|
|
||||||
return (
|
let body: React.ReactNode = null;
|
||||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
if (health === 'orphan') {
|
||||||
<div className="flex items-center justify-between">
|
body = (
|
||||||
<div>
|
<div className="space-y-1 text-sm" data-testid="orphan-notice">
|
||||||
<div className="font-medium">{server.name}</div>
|
<p className="text-foreground">{t('card.orphanText')}</p>
|
||||||
{/* Nachbesserung Befund 3: `uppercase` gilt nur dem Produktkuerzel, nicht der Adresse. */}
|
{isAdmin && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
<span className="uppercase">{server.productType}</span> — {server.baseUrl}
|
{t('card.orphanAdminHint')}{' '}
|
||||||
</div>
|
<Link
|
||||||
</div>
|
href="/modules/proxmox/settings"
|
||||||
</div>
|
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
{t('card.settingsLink')}
|
||||||
{t('card.lastPolledLabel')}: {formatTimestamp(status?.lastPolledAt ?? null, unknown)}
|
</Link>
|
||||||
</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>
|
</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>
|
</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({
|
return makeServer({
|
||||||
productType: metrics.productType,
|
productType: metrics.productType,
|
||||||
status: makeStatus({ metrics }),
|
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 {
|
return {
|
||||||
productType: 'pve',
|
productType: 'pve',
|
||||||
nodeCount: nodes.length,
|
nodeCount: nodes.length,
|
||||||
@@ -125,12 +131,18 @@ describe('serverHealth', () => {
|
|||||||
expect(serverHealth(makeServer({ isActive: false }), NOW)).toBe('orphan');
|
expect(serverHealth(makeServer({ isActive: false }), NOW)).toBe('orphan');
|
||||||
expect(
|
expect(
|
||||||
serverHealth(
|
serverHealth(
|
||||||
makeServer({ isActive: false, status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
|
makeServer({
|
||||||
|
isActive: false,
|
||||||
|
status: makeStatus({ reachable: false, errorKind: 'netz' }),
|
||||||
|
}),
|
||||||
NOW,
|
NOW,
|
||||||
),
|
),
|
||||||
).toBe('orphan');
|
).toBe('orphan');
|
||||||
expect(
|
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');
|
).toBe('orphan');
|
||||||
expect(serverHealth(makeServer({ isActive: false, status: null }), 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', () => {
|
it('kein Zwischenlager oder noch nie abgefragt -> idle', () => {
|
||||||
expect(serverHealth(makeServer({ status: null }), NOW)).toBe('idle');
|
expect(serverHealth(makeServer({ status: null }), NOW)).toBe('idle');
|
||||||
expect(
|
expect(
|
||||||
serverHealth(makeServer({ status: makeStatus({ lastPolledAt: null, reachable: false }) }), NOW),
|
serverHealth(
|
||||||
|
makeServer({ status: makeStatus({ lastPolledAt: null, reachable: false }) }),
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
).toBe('idle');
|
).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('abgefragt, aber nicht erreichbar -> down', () => {
|
it('abgefragt, aber nicht erreichbar -> down', () => {
|
||||||
expect(
|
expect(
|
||||||
serverHealth(makeServer({ status: makeStatus({ reachable: false, errorKind: 'netz' }) }), NOW),
|
serverHealth(
|
||||||
|
makeServer({ status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
).toBe('down');
|
).toBe('down');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,7 +192,14 @@ describe('serverHealth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('PBS: Fuellstand ab 0.80, Sicherung aelter als 26 h, Pruefung nicht ok -> warn', () => {
|
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 => ({
|
const pbs = (ds: Partial<typeof base> & Record<string, unknown>): ProxmoxMetrics => ({
|
||||||
productType: 'pbs',
|
productType: 'pbs',
|
||||||
datastores: [{ ...base, ...ds } as never],
|
datastores: [{ ...base, ...ds } as never],
|
||||||
@@ -188,7 +213,16 @@ describe('serverHealth', () => {
|
|||||||
it('PBS: null bei Belegung, Sicherung oder Pruefung erzeugt KEINE Warnung', () => {
|
it('PBS: null bei Belegung, Sicherung oder Pruefung erzeugt KEINE Warnung', () => {
|
||||||
const metrics: ProxmoxMetrics = {
|
const metrics: ProxmoxMetrics = {
|
||||||
productType: 'pbs',
|
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');
|
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. */
|
/** `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;
|
if (!isKnownNumber(used) || !isKnownNumber(total) || total <= 0) return null;
|
||||||
return used / total;
|
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. */
|
/** 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);
|
const ms = toEpochMs(lastBackupAt);
|
||||||
if (ms === null) return false;
|
if (ms === null) return false;
|
||||||
return now - ms > THRESHOLDS.backupMaxAgeHours * 3600 * 1000;
|
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`. */
|
/** 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))]));
|
const rank = new Map(servers.map((s) => [s.id, HEALTH_ORDER.indexOf(serverHealth(s, now))]));
|
||||||
return [...servers].sort(
|
return [...servers].sort(
|
||||||
(a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0) || a.position - b.position,
|
(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;
|
if (ms === null) return null;
|
||||||
const seconds = Math.max(0, Math.round((now - ms) / 1000));
|
const seconds = Math.max(0, Math.round((now - ms) / 1000));
|
||||||
if (seconds < 60) {
|
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 rtf = new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'always' });
|
||||||
const minutes = Math.floor(seconds / 60);
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
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 { 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
|
* Modulseite (quick-260924-h7x, vorher 260923-dhh Aufgabe 6) — liest
|
||||||
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
|
* ausschliesslich aus dem Zwischenlager, das `GET servers` liefert; kein
|
||||||
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
|
* Live-Zugriff bei Proxmox von hier aus (D-05).
|
||||||
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt. Der Knopf
|
*
|
||||||
* erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN,
|
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
|
||||||
* SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6).
|
* (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() {
|
export default function ProxmoxPage() {
|
||||||
const t = useTranslations('proxmox');
|
const t = useTranslations('proxmox');
|
||||||
@@ -23,10 +70,14 @@ export default function ProxmoxPage() {
|
|||||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
listServers()
|
listServers()
|
||||||
.then(setServers)
|
.then((list) => {
|
||||||
|
setServers(list);
|
||||||
|
setNow(Date.now());
|
||||||
|
})
|
||||||
.catch(() => setError(t('loadError')));
|
.catch(() => setError(t('loadError')));
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
@@ -34,6 +85,11 @@ export default function ProxmoxPage() {
|
|||||||
reload();
|
reload();
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
if (!servers || servers.length === 0) return;
|
if (!servers || servers.length === 0) return;
|
||||||
setIsRefreshing(true);
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
{isAdmin && (
|
||||||
{isAdmin && servers !== null && servers.length > 0 && (
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isRefreshing}
|
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')}
|
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
</div>
|
||||||
<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 && (
|
{!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 && (
|
{!error && servers !== null && servers.length === 0 && (
|
||||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
<div className={`${WELL} flex flex-col items-center gap-3 px-6 py-10 text-center`}>
|
||||||
{t('emptyState')}
|
<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 && (
|
{isAdmin && (
|
||||||
<>
|
<Link
|
||||||
{' '}
|
href="/modules/proxmox/settings"
|
||||||
<Link href="/modules/proxmox/settings" className="text-primary hover:underline">
|
className="text-sm font-medium text-foreground hover:underline"
|
||||||
|
>
|
||||||
{t('card.settingsLink')}
|
{t('card.settingsLink')}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && servers !== null && servers.length > 0 && (
|
{!error && servers !== null && servers.length > 0 && (
|
||||||
<ul className="space-y-3">
|
<>
|
||||||
{servers.map((server) => (
|
<HealthBar counts={counts} />
|
||||||
<li key={server.id}>
|
<ul className="grid gap-4 lg:grid-cols-2">
|
||||||
<ServerCard server={server} isAdmin={isAdmin} />
|
{sorted.map((server) => (
|
||||||
|
<li key={server.id} className="min-w-0">
|
||||||
|
<ServerCard server={server} isAdmin={isAdmin} now={now} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
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,
|
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
|
||||||
// "Jetzt aktualisieren" ruft `pollServer` je Server.
|
// "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) {
|
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
||||||
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
|
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
|
||||||
selector({ user }),
|
selector({ user }),
|
||||||
@@ -176,3 +161,119 @@ describe('ProxmoxPage role gating (260923-le6)', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
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: var(--sidebar-accent);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--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-sm: calc(var(--radius) - 2px);
|
||||||
--radius-md: var(--radius);
|
--radius-md: var(--radius);
|
||||||
--radius-lg: calc(var(--radius) + 2px);
|
--radius-lg: calc(var(--radius) + 2px);
|
||||||
@@ -83,6 +96,24 @@
|
|||||||
--sidebar-accent: oklch(0.93 0 0);
|
--sidebar-accent: oklch(0.93 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.25 0 0);
|
--sidebar-accent-foreground: oklch(0.25 0 0);
|
||||||
--sidebar-border: oklch(0.90 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 {
|
.dark {
|
||||||
@@ -110,6 +141,20 @@
|
|||||||
--sidebar-accent: oklch(0.91 0.19 102 / 0.15);
|
--sidebar-accent: oklch(0.91 0.19 102 / 0.15);
|
||||||
--sidebar-accent-foreground: oklch(0.91 0.19 102);
|
--sidebar-accent-foreground: oklch(0.91 0.19 102);
|
||||||
--sidebar-border: oklch(0.30 0.01 260);
|
--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 {
|
body {
|
||||||
|
|||||||
@@ -720,24 +720,36 @@
|
|||||||
"emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.",
|
"emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.",
|
||||||
"card": {
|
"card": {
|
||||||
"unknownValue": "unbekannt",
|
"unknownValue": "unbekannt",
|
||||||
"lastPolledLabel": "Letzte Abfrage",
|
"lastPolledAgo": "Letzte Abfrage {age}",
|
||||||
"notPolledYet": "Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.",
|
"notPolledYet": "Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.",
|
||||||
"notPolledYetAutomatic": "Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.",
|
"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",
|
"refresh": "Jetzt aktualisieren",
|
||||||
"refreshing": "Wird aktualisiert...",
|
"refreshing": "Wird aktualisiert...",
|
||||||
"settingsLink": "Zu den Einstellungen",
|
"settingsLink": "Zu den Einstellungen",
|
||||||
|
"settingsNav": "Einstellungen",
|
||||||
|
"product": {
|
||||||
|
"pve": "Virtualisierung",
|
||||||
|
"pbs": "Datensicherung",
|
||||||
|
"pmg": "Mail-Gateway"
|
||||||
|
},
|
||||||
"pve": {
|
"pve": {
|
||||||
"nodeCount": "Knoten",
|
"running": "{count, plural, one {Gast läuft} other {Gäste laufen}}",
|
||||||
"guests": "{running} laufend / {stopped} gestoppt",
|
"stopped": "gestoppt",
|
||||||
"cpu": "Prozessorlast",
|
"nodes": "{count, plural, one {# Knoten} other {# Knoten}}",
|
||||||
"mem": "Speicher"
|
"cpu": "Prozessor",
|
||||||
|
"mem": "Arbeitsspeicher"
|
||||||
},
|
},
|
||||||
"pbs": {
|
"pbs": {
|
||||||
"used": "Belegt",
|
"used": "Belegt",
|
||||||
"lastBackup": "Letzte Sicherung",
|
"lastBackupAgo": "Letzte Sicherung {age}",
|
||||||
"noBackupYet": "noch keine Sicherung",
|
"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": {
|
"pmg": {
|
||||||
"countIn": "Eingehend",
|
"countIn": "Eingehend",
|
||||||
@@ -746,6 +758,21 @@
|
|||||||
"virusCount": "Viren"
|
"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": {
|
"errors": {
|
||||||
"netz": "Der Server ist nicht erreichbar. Bitte prüfen Sie die Adresse und die Netzwerkverbindung.",
|
"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.",
|
"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.",
|
"emptyState": "No server configured yet. Add one in the module settings.",
|
||||||
"card": {
|
"card": {
|
||||||
"unknownValue": "unknown",
|
"unknownValue": "unknown",
|
||||||
"lastPolledLabel": "Last poll",
|
"lastPolledAgo": "Last poll {age}",
|
||||||
"notPolledYet": "No poll has run yet. Click \"{refreshLabel}\" above.",
|
"notPolledYet": "No poll has run yet. Click \"{refreshLabel}\" above.",
|
||||||
"notPolledYetAutomatic": "No poll has run yet. The values will appear after the next automatic poll.",
|
"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",
|
"refresh": "Refresh now",
|
||||||
"refreshing": "Refreshing...",
|
"refreshing": "Refreshing...",
|
||||||
"settingsLink": "Go to settings",
|
"settingsLink": "Go to settings",
|
||||||
|
"settingsNav": "Settings",
|
||||||
|
"product": {
|
||||||
|
"pve": "Virtualization",
|
||||||
|
"pbs": "Backup",
|
||||||
|
"pmg": "Mail gateway"
|
||||||
|
},
|
||||||
"pve": {
|
"pve": {
|
||||||
"nodeCount": "Nodes",
|
"running": "{count, plural, one {guest running} other {guests running}}",
|
||||||
"guests": "{running} running / {stopped} stopped",
|
"stopped": "stopped",
|
||||||
"cpu": "CPU load",
|
"nodes": "{count, plural, one {# node} other {# nodes}}",
|
||||||
|
"cpu": "CPU",
|
||||||
"mem": "Memory"
|
"mem": "Memory"
|
||||||
},
|
},
|
||||||
"pbs": {
|
"pbs": {
|
||||||
"used": "Used",
|
"used": "Used",
|
||||||
"lastBackup": "Last backup",
|
"lastBackupAgo": "Last backup {age}",
|
||||||
"noBackupYet": "no backup yet",
|
"noBackupYet": "no backup yet",
|
||||||
"verifyState": "Last verification"
|
"verifyOk": "Verification OK",
|
||||||
|
"verifyFailed": "Verification: {state}",
|
||||||
|
"verifyUnknown": "Verification unknown",
|
||||||
|
"noDatastores": "No datastores reported."
|
||||||
},
|
},
|
||||||
"pmg": {
|
"pmg": {
|
||||||
"countIn": "Inbound",
|
"countIn": "Inbound",
|
||||||
@@ -746,6 +758,21 @@
|
|||||||
"virusCount": "Viruses"
|
"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": {
|
"errors": {
|
||||||
"netz": "The server is unreachable. Please check the address and the network connection.",
|
"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.",
|
"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',
|
'gemessene',
|
||||||
'Messung',
|
'Messung',
|
||||||
'Prozessorlast',
|
'Prozessorlast',
|
||||||
|
// quick-260924-h7x: Proxmox-Seite neu gestaltet — korrektes Deutsch mit „ss“
|
||||||
|
'Prozessor',
|
||||||
|
'Arbeitsspeicher',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user