feat(260923-dhh): Proxmox-Modul Aufgabe 6 - Modulseite mit Auslastung
- ServerCard.tsx: einzige Stelle, die einen Messwert in Text verwandelt (formatMetric) — null wird ueberall "unbekannt", nie 0/NaN/leer; verzweigt ueber productType auf PVE/PBS/PMG; PBS ohne Sicherung zeigt "noch keine Sicherung" statt eines Fehlers; nicht erreichbarer Server zeigt Klartext-Ursache plus Zeitpunkt der letzten erfolgreichen Messung - page.tsx: "Jetzt aktualisieren" fragt alle Server neu ab und laedt die Liste danach neu, waehrend des Laufs gesperrt; ruhiger Hinweis bei leerer Liste mit Weg zu den Einstellungen; Link zu den Einstellungen nur fuer Administratoren sichtbar (Anzeige, kein Zugriffsriegel) - proxmox-api.ts: ProxmoxMetrics-Union (Pve/Pbs/Pmg) fuer typsichere Verzweigung im Frontend - umlaut-dictionary.ts: zwei weitere korrekte Woerter auf die Positivliste (Messung, Prozessorlast) Tore: web 708/708 (>=693), type-check 4/4, Biome apps/web 53 Warnungen (unveraendert). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
|||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'card.unknownValue': 'unbekannt',
|
||||||
|
'card.lastPolledLabel': 'Letzte Abfrage',
|
||||||
|
'card.lastOkLabel': 'Letzte erfolgreiche Messung',
|
||||||
|
'card.pve.nodeCount': 'Knoten',
|
||||||
|
'card.pve.guests': '{running} laufend / {stopped} gestoppt',
|
||||||
|
'card.pve.cpu': 'Prozessorlast',
|
||||||
|
'card.pve.mem': 'Speicher',
|
||||||
|
'card.pbs.used': 'Belegt',
|
||||||
|
'card.pbs.lastBackup': 'Letzte Sicherung',
|
||||||
|
'card.pbs.noBackupYet': 'noch keine Sicherung',
|
||||||
|
'card.pbs.verifyState': 'Letzte Prüfung',
|
||||||
|
'card.pmg.countIn': 'Eingehend',
|
||||||
|
'card.pmg.countOut': 'Ausgehend',
|
||||||
|
'card.pmg.spamCount': 'Spam',
|
||||||
|
'card.pmg.virusCount': 'Viren',
|
||||||
|
'errors.netz': 'Der Server ist nicht erreichbar.',
|
||||||
|
'errors.zugang': 'Der Zugang wurde abgelehnt.',
|
||||||
|
'errors.rechte': 'Die Rechte reichen nicht aus.',
|
||||||
|
'errors.zertifikat': 'Das Zertifikat wurde abgelehnt.',
|
||||||
|
'errors.antwortform': 'Unerwartete Antwortform.',
|
||||||
|
'errors.server': 'Serverfehler.',
|
||||||
|
'errors.unbekannt': 'Unerwarteter Fehler.',
|
||||||
|
};
|
||||||
|
let result = translations[key] ?? key;
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
result = result.replace(`{${k}}`, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||||
|
return {
|
||||||
|
id: 'srv-1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'pve-1',
|
||||||
|
productType: 'pve',
|
||||||
|
baseUrl: 'https://pve.intern',
|
||||||
|
authMethod: 'token',
|
||||||
|
tokenId: 'root@pam!x',
|
||||||
|
username: null,
|
||||||
|
tlsRejectUnauthorized: true,
|
||||||
|
isActive: true,
|
||||||
|
pollIntervalMin: 5,
|
||||||
|
position: 0,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
status: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ServerCard', () => {
|
||||||
|
it('PVE-Server zeigt Knotenzahl, laufende/gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung', async () => {
|
||||||
|
const { ServerCard } = await import('./ServerCard');
|
||||||
|
const server = makeServer({
|
||||||
|
status: {
|
||||||
|
id: 's1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
reachable: true,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
rawSample: null,
|
||||||
|
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
metrics: {
|
||||||
|
productType: 'pve',
|
||||||
|
nodeCount: 2,
|
||||||
|
guestsRunning: 3,
|
||||||
|
guestsStopped: 1,
|
||||||
|
nodes: [
|
||||||
|
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_294_967_296, maxmem: 8_589_934_592 },
|
||||||
|
],
|
||||||
|
storages: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ServerCard server={server} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Knoten:/)).toHaveTextContent('Knoten: 2');
|
||||||
|
expect(screen.getByText(/laufend/)).toHaveTextContent('3 laufend / 1 gestoppt');
|
||||||
|
expect(screen.getByText(/Prozessorlast/)).toHaveTextContent('Prozessorlast: 25%');
|
||||||
|
expect(screen.getByText(/Speicher:/)).toHaveTextContent('4.0 GB / 8.0 GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung', async () => {
|
||||||
|
const { ServerCard } = await import('./ServerCard');
|
||||||
|
const server = makeServer({
|
||||||
|
productType: 'pbs',
|
||||||
|
status: {
|
||||||
|
id: 's1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
reachable: true,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
rawSample: null,
|
||||||
|
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
metrics: {
|
||||||
|
productType: 'pbs',
|
||||||
|
datastores: [
|
||||||
|
{ name: 'backup-store', total: 1000, used: 400, free: 600, lastBackupAt: 1758000000, lastVerifyState: 'ok' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ServerCard server={server} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('backup-store')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Belegt:/)).toHaveTextContent('400 B / 1000 B');
|
||||||
|
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PBS-Datenspeicher ohne Sicherung zeigt "noch keine Sicherung"', async () => {
|
||||||
|
const { ServerCard } = await import('./ServerCard');
|
||||||
|
const server = makeServer({
|
||||||
|
productType: 'pbs',
|
||||||
|
status: {
|
||||||
|
id: 's1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
lastOkAt: null,
|
||||||
|
reachable: true,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
rawSample: null,
|
||||||
|
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
metrics: {
|
||||||
|
productType: 'pbs',
|
||||||
|
datastores: [
|
||||||
|
{ name: 'leer', total: 10, used: 0, free: 10, lastBackupAt: null, lastVerifyState: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ServerCard server={server} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung');
|
||||||
|
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren', async () => {
|
||||||
|
const { ServerCard } = await import('./ServerCard');
|
||||||
|
const server = makeServer({
|
||||||
|
productType: 'pmg',
|
||||||
|
status: {
|
||||||
|
id: 's1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
lastOkAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
reachable: true,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
rawSample: null,
|
||||||
|
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
metrics: { productType: 'pmg', countIn: 120, countOut: 45, spamCount: 12, virusCount: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ServerCard server={server} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('120');
|
||||||
|
expect(screen.getByText(/Ausgehend:/)).toHaveTextContent('45');
|
||||||
|
expect(screen.getByText(/Spam:/)).toHaveTextContent('12');
|
||||||
|
expect(screen.getByText(/Viren:/)).toHaveTextContent('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Messwert, der null ist, erscheint als "unbekannt" — nie als 0, leer oder NaN', async () => {
|
||||||
|
const { ServerCard } = await import('./ServerCard');
|
||||||
|
const server = makeServer({
|
||||||
|
productType: 'pmg',
|
||||||
|
status: {
|
||||||
|
id: 's1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
lastOkAt: null,
|
||||||
|
reachable: true,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
rawSample: null,
|
||||||
|
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
metrics: { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ServerCard server={server} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('unbekannt');
|
||||||
|
expect(screen.queryByText(/Eingehend: 0/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Server mit reachable:false zeigt den Klartext der Ursache und den Zeitpunkt der letzten erfolgreichen Messung', async () => {
|
||||||
|
const { ServerCard } = await import('./ServerCard');
|
||||||
|
const server = makeServer({
|
||||||
|
status: {
|
||||||
|
id: 's1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
lastPolledAt: '2026-09-23T10:05:00.000Z',
|
||||||
|
lastOkAt: '2026-09-23T09:00:00.000Z',
|
||||||
|
reachable: false,
|
||||||
|
errorKind: 'zugang',
|
||||||
|
errorDetail: null,
|
||||||
|
rawSample: null,
|
||||||
|
updatedAt: '2026-09-23T10:05:00.000Z',
|
||||||
|
metrics: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ServerCard server={server} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Der Zugang wurde abgelehnt.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Letzte erfolgreiche Messung/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Zeitpunkt der letzten Abfrage steht bei jedem Server', async () => {
|
||||||
|
const { ServerCard } = await import('./ServerCard');
|
||||||
|
const server = makeServer({
|
||||||
|
status: {
|
||||||
|
id: 's1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
lastOkAt: null,
|
||||||
|
reachable: true,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
rawSample: null,
|
||||||
|
updatedAt: '2026-09-23T10:00:00.000Z',
|
||||||
|
metrics: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ServerCard server={server} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Letzte Abfrage/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import type {
|
||||||
|
ProxmoxErrorKind,
|
||||||
|
ProxmoxPbsMetrics,
|
||||||
|
ProxmoxPmgMetrics,
|
||||||
|
ProxmoxPveMetrics,
|
||||||
|
ProxmoxServer,
|
||||||
|
} from '@/lib/proxmox-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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%`. */
|
||||||
|
function formatPercent(fraction: number): string {
|
||||||
|
return `${Math.round(fraction * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die EINZIGE Stelle, die einen Messwert in Text verwandelt (Aufgabe 6):
|
||||||
|
* ist der Wert `null`/`undefined`, erscheint der uebersetzte Text
|
||||||
|
* "unbekannt" — sonst der Wert mit seiner Einheit. Dadurch kann kein Zweig
|
||||||
|
* versehentlich eine `0` anzeigen, wo nichts gemessen wurde. Die Feldnamen
|
||||||
|
* von PBS und PMG sind bis zur Pruefung am echten Server nur abgeleitet
|
||||||
|
* (Recherche, Annahmen A2/A3/A5), und ein still falscher Wert waere
|
||||||
|
* schlimmer als ein ehrliches "unbekannt".
|
||||||
|
*/
|
||||||
|
function formatMetric(
|
||||||
|
value: number | string | null | undefined,
|
||||||
|
unknownLabel: string,
|
||||||
|
formatter?: (v: number) => string,
|
||||||
|
): string {
|
||||||
|
if (value === null || value === undefined) return unknownLabel;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (Number.isNaN(value)) return unknownLabel;
|
||||||
|
return formatter ? formatter(value) : String(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string | number | null | undefined, unknownLabel: string): string {
|
||||||
|
if (value === null || value === undefined) return unknownLabel;
|
||||||
|
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return unknownLabel;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricsViewProps {
|
||||||
|
metrics: ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
|
||||||
|
unknown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unknown: string }) {
|
||||||
|
const t = useTranslations('proxmox');
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
{t('card.pve.nodeCount')}: {metrics.nodeCount}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })}
|
||||||
|
</div>
|
||||||
|
{metrics.nodes.map((node) => (
|
||||||
|
<div key={node.node} className="rounded border border-border/60 px-2 py-1 text-xs">
|
||||||
|
<div className="font-medium">{node.node}</div>
|
||||||
|
<div>
|
||||||
|
{t('card.pve.cpu')}: {formatMetric(node.cpu, unknown, formatPercent)}
|
||||||
|
{' · '}
|
||||||
|
{t('card.pve.mem')}: {formatMetric(node.mem, unknown, formatBytes)}
|
||||||
|
{node.maxmem !== null && node.mem !== null
|
||||||
|
? ` / ${formatMetric(node.maxmem, unknown, formatBytes)}`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PbsMetricsView({ metrics, unknown }: { metrics: ProxmoxPbsMetrics; unknown: string }) {
|
||||||
|
const t = useTranslations('proxmox');
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{metrics.datastores.map((ds) => (
|
||||||
|
<div key={ds.name} className="rounded border border-border/60 px-2 py-1 text-xs">
|
||||||
|
<div className="font-medium">{ds.name}</div>
|
||||||
|
<div>
|
||||||
|
{t('card.pbs.used')}: {formatMetric(ds.used, unknown, formatBytes)}
|
||||||
|
{ds.total !== null && ds.used !== null ? ` / ${formatMetric(ds.total, unknown, formatBytes)}` : ''}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('card.pbs.lastBackup')}:{' '}
|
||||||
|
{ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PmgMetricsView({ metrics, unknown }: { metrics: ProxmoxPmgMetrics; unknown: string }) {
|
||||||
|
const t = useTranslations('proxmox');
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
{t('card.pmg.countIn')}: {formatMetric(metrics.countIn, unknown)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('card.pmg.countOut')}: {formatMetric(metrics.countOut, unknown)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('card.pmg.spamCount')}: {formatMetric(metrics.spamCount, unknown)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('card.pmg.virusCount')}: {formatMetric(metrics.virusCount, unknown)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricsView({ metrics, unknown }: MetricsViewProps) {
|
||||||
|
if (metrics.productType === 'pve') return <PveMetricsView metrics={metrics} unknown={unknown} />;
|
||||||
|
if (metrics.productType === 'pbs') return <PbsMetricsView metrics={metrics} unknown={unknown} />;
|
||||||
|
return <PmgMetricsView metrics={metrics} unknown={unknown} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(t: ReturnType<typeof useTranslations>, kind: ProxmoxErrorKind | null): string {
|
||||||
|
return t(`errors.${kind ?? 'unbekannt'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerCardProps {
|
||||||
|
server: ProxmoxServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzeige EINES Servers (Aufgabe 6) — verzweigt ueber `productType` auf der
|
||||||
|
* unterscheidbaren Union aus Aufgabe 3.
|
||||||
|
*/
|
||||||
|
export function ServerCard({ server }: ServerCardProps) {
|
||||||
|
const t = useTranslations('proxmox');
|
||||||
|
const unknown = t('card.unknownValue');
|
||||||
|
const status = server.status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{server.name}</div>
|
||||||
|
<div className="text-xs uppercase text-muted-foreground">
|
||||||
|
{server.productType} — {server.baseUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{t('card.lastPolledLabel')}: {formatTimestamp(status?.lastPolledAt ?? null, unknown)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && !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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,76 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
|
import Link from 'next/link';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||||
|
import { ServerCard } from './components/ServerCard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modulseite (Aufgabe 1: duenne Liste — Name, Typ, Adresse; Aufgabe 6
|
* Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das
|
||||||
* ergaenzt die produktabhaengige Auslastungsanzeige ueber `ServerCard`).
|
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
|
||||||
* Liest ausschliesslich aus dem Zwischenlager, das `GET servers` liefert —
|
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
|
||||||
* kein Live-Zugriff bei Proxmox von hier aus (D-05).
|
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt.
|
||||||
*/
|
*/
|
||||||
export default function ProxmoxPage() {
|
export default function ProxmoxPage() {
|
||||||
const t = useTranslations('proxmox');
|
const t = useTranslations('proxmox');
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||||
|
|
||||||
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 reload = useCallback(() => {
|
||||||
|
listServers()
|
||||||
|
.then(setServers)
|
||||||
|
.catch(() => setError(t('loadError')));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
reload();
|
||||||
listServers()
|
}, [reload]);
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) setServers(data);
|
const handleRefresh = async () => {
|
||||||
})
|
if (!servers || servers.length === 0) return;
|
||||||
.catch(() => {
|
setIsRefreshing(true);
|
||||||
if (!cancelled) setError(t('loadError'));
|
try {
|
||||||
});
|
await Promise.all(servers.map((server) => pollServer(server.id).catch(() => undefined)));
|
||||||
return () => {
|
reload();
|
||||||
cancelled = true;
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
<h1 className="text-2xl font-bold 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">
|
||||||
|
{servers !== null && servers.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/modules/proxmox/settings"
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t('card.settingsLink')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
@@ -45,23 +81,22 @@ export default function ProxmoxPage() {
|
|||||||
{!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="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
||||||
{t('emptyState')}
|
{t('emptyState')}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<Link href="/modules/proxmox/settings" className="text-primary hover:underline">
|
||||||
|
{t('card.settingsLink')}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && servers !== null && servers.length > 0 && (
|
{!error && servers !== null && servers.length > 0 && (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<li
|
<li key={server.id}>
|
||||||
key={server.id}
|
<ServerCard server={server} />
|
||||||
className="rounded-lg border border-border bg-card p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">{server.name}</span>
|
|
||||||
<span className="text-xs uppercase text-muted-foreground">
|
|
||||||
{server.productType}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{server.baseUrl}</p>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -21,6 +21,54 @@ export type ProxmoxErrorKind =
|
|||||||
| 'server'
|
| 'server'
|
||||||
| 'unbekannt';
|
| 'unbekannt';
|
||||||
|
|
||||||
|
export interface ProxmoxPveNodeMetric {
|
||||||
|
node: string;
|
||||||
|
cpu: number | null;
|
||||||
|
maxcpu: number | null;
|
||||||
|
mem: number | null;
|
||||||
|
maxmem: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxmoxPveStorageMetric {
|
||||||
|
storage: string;
|
||||||
|
node: string;
|
||||||
|
disk: number | null;
|
||||||
|
maxdisk: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxmoxPveMetrics {
|
||||||
|
productType: 'pve';
|
||||||
|
nodeCount: number;
|
||||||
|
guestsRunning: number;
|
||||||
|
guestsStopped: number;
|
||||||
|
nodes: ProxmoxPveNodeMetric[];
|
||||||
|
storages: ProxmoxPveStorageMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxmoxPbsDatastoreMetric {
|
||||||
|
name: string;
|
||||||
|
total: number | null;
|
||||||
|
used: number | null;
|
||||||
|
free: number | null;
|
||||||
|
lastBackupAt: number | null;
|
||||||
|
lastVerifyState: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxmoxPbsMetrics {
|
||||||
|
productType: 'pbs';
|
||||||
|
datastores: ProxmoxPbsDatastoreMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxmoxPmgMetrics {
|
||||||
|
productType: 'pmg';
|
||||||
|
countIn: number | null;
|
||||||
|
countOut: number | null;
|
||||||
|
spamCount: number | null;
|
||||||
|
virusCount: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxmoxMetrics = ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
|
||||||
|
|
||||||
export interface ProxmoxServerStatus {
|
export interface ProxmoxServerStatus {
|
||||||
id: string;
|
id: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -29,7 +77,7 @@ export interface ProxmoxServerStatus {
|
|||||||
reachable: boolean;
|
reachable: boolean;
|
||||||
errorKind: ProxmoxErrorKind | null;
|
errorKind: ProxmoxErrorKind | null;
|
||||||
errorDetail: string | null;
|
errorDetail: string | null;
|
||||||
metrics: unknown;
|
metrics: ProxmoxMetrics | null;
|
||||||
rawSample: unknown;
|
rawSample: unknown;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -72,7 +120,7 @@ export interface ProxmoxTestResult {
|
|||||||
reachable: boolean;
|
reachable: boolean;
|
||||||
errorKind: ProxmoxErrorKind | null;
|
errorKind: ProxmoxErrorKind | null;
|
||||||
errorDetail: string | null;
|
errorDetail: string | null;
|
||||||
metrics: unknown;
|
metrics: ProxmoxMetrics | null;
|
||||||
rawSample: unknown;
|
rawSample: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -709,6 +709,32 @@
|
|||||||
"loading": "Lade Serverliste...",
|
"loading": "Lade Serverliste...",
|
||||||
"loadError": "Die Serverliste konnte nicht geladen werden.",
|
"loadError": "Die Serverliste konnte nicht geladen werden.",
|
||||||
"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": {
|
||||||
|
"unknownValue": "unbekannt",
|
||||||
|
"lastPolledLabel": "Letzte Abfrage",
|
||||||
|
"lastOkLabel": "Letzte erfolgreiche Messung",
|
||||||
|
"refresh": "Jetzt aktualisieren",
|
||||||
|
"refreshing": "Wird aktualisiert...",
|
||||||
|
"settingsLink": "Zu den Einstellungen",
|
||||||
|
"pve": {
|
||||||
|
"nodeCount": "Knoten",
|
||||||
|
"guests": "{running} laufend / {stopped} gestoppt",
|
||||||
|
"cpu": "Prozessorlast",
|
||||||
|
"mem": "Speicher"
|
||||||
|
},
|
||||||
|
"pbs": {
|
||||||
|
"used": "Belegt",
|
||||||
|
"lastBackup": "Letzte Sicherung",
|
||||||
|
"noBackupYet": "noch keine Sicherung",
|
||||||
|
"verifyState": "Letzte Prüfung"
|
||||||
|
},
|
||||||
|
"pmg": {
|
||||||
|
"countIn": "Eingehend",
|
||||||
|
"countOut": "Ausgehend",
|
||||||
|
"spamCount": "Spam",
|
||||||
|
"virusCount": "Viren"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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.",
|
||||||
|
|||||||
@@ -709,6 +709,32 @@
|
|||||||
"loading": "Loading server list...",
|
"loading": "Loading server list...",
|
||||||
"loadError": "Could not load the server list.",
|
"loadError": "Could not load the server list.",
|
||||||
"emptyState": "No server configured yet. Add one in the module settings.",
|
"emptyState": "No server configured yet. Add one in the module settings.",
|
||||||
|
"card": {
|
||||||
|
"unknownValue": "unknown",
|
||||||
|
"lastPolledLabel": "Last poll",
|
||||||
|
"lastOkLabel": "Last successful measurement",
|
||||||
|
"refresh": "Refresh now",
|
||||||
|
"refreshing": "Refreshing...",
|
||||||
|
"settingsLink": "Go to settings",
|
||||||
|
"pve": {
|
||||||
|
"nodeCount": "Nodes",
|
||||||
|
"guests": "{running} running / {stopped} stopped",
|
||||||
|
"cpu": "CPU load",
|
||||||
|
"mem": "Memory"
|
||||||
|
},
|
||||||
|
"pbs": {
|
||||||
|
"used": "Used",
|
||||||
|
"lastBackup": "Last backup",
|
||||||
|
"noBackupYet": "no backup yet",
|
||||||
|
"verifyState": "Last verification"
|
||||||
|
},
|
||||||
|
"pmg": {
|
||||||
|
"countIn": "Inbound",
|
||||||
|
"countOut": "Outbound",
|
||||||
|
"spamCount": "Spam",
|
||||||
|
"virusCount": "Viruses"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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.",
|
||||||
|
|||||||
@@ -187,4 +187,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
// quick-260923-dhh: Proxmox-Modul — korrektes Deutsch mit „ss“
|
// quick-260923-dhh: Proxmox-Modul — korrektes Deutsch mit „ss“
|
||||||
'bewusst',
|
'bewusst',
|
||||||
'gemessene',
|
'gemessene',
|
||||||
|
'Messung',
|
||||||
|
'Prozessorlast',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user