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,39 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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
|
||||
* ergaenzt die produktabhaengige Auslastungsanzeige ueber `ServerCard`).
|
||||
* Liest ausschliesslich aus dem Zwischenlager, das `GET servers` liefert —
|
||||
* kein Live-Zugriff bei Proxmox von hier aus (D-05).
|
||||
* Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das
|
||||
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
|
||||
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
|
||||
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt.
|
||||
*/
|
||||
export default function ProxmoxPage() {
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
listServers()
|
||||
.then(setServers)
|
||||
.catch(() => setError(t('loadError')));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
listServers()
|
||||
.then((data) => {
|
||||
if (!cancelled) setServers(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(t('loadError'));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!servers || servers.length === 0) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await Promise.all(servers.map((server) => pollServer(server.id).catch(() => undefined)));
|
||||
reload();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{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>}
|
||||
@@ -45,23 +81,22 @@ export default function ProxmoxPage() {
|
||||
{!error && servers !== null && servers.length === 0 && (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
||||
{t('emptyState')}
|
||||
{isAdmin && (
|
||||
<>
|
||||
{' '}
|
||||
<Link href="/modules/proxmox/settings" className="text-primary hover:underline">
|
||||
{t('card.settingsLink')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && servers !== null && servers.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{servers.map((server) => (
|
||||
<li
|
||||
key={server.id}
|
||||
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 key={server.id}>
|
||||
<ServerCard server={server} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -21,6 +21,54 @@ export type ProxmoxErrorKind =
|
||||
| 'server'
|
||||
| '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 {
|
||||
id: string;
|
||||
serverId: string;
|
||||
@@ -29,7 +77,7 @@ export interface ProxmoxServerStatus {
|
||||
reachable: boolean;
|
||||
errorKind: ProxmoxErrorKind | null;
|
||||
errorDetail: string | null;
|
||||
metrics: unknown;
|
||||
metrics: ProxmoxMetrics | null;
|
||||
rawSample: unknown;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -72,7 +120,7 @@ export interface ProxmoxTestResult {
|
||||
reachable: boolean;
|
||||
errorKind: ProxmoxErrorKind | null;
|
||||
errorDetail: string | null;
|
||||
metrics: unknown;
|
||||
metrics: ProxmoxMetrics | null;
|
||||
rawSample: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -709,6 +709,32 @@
|
||||
"loading": "Lade Serverliste...",
|
||||
"loadError": "Die Serverliste konnte nicht geladen werden.",
|
||||
"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": {
|
||||
"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.",
|
||||
|
||||
@@ -709,6 +709,32 @@
|
||||
"loading": "Loading server list...",
|
||||
"loadError": "Could not load the server list.",
|
||||
"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": {
|
||||
"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.",
|
||||
|
||||
@@ -187,4 +187,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-260923-dhh: Proxmox-Modul — korrektes Deutsch mit „ss“
|
||||
'bewusst',
|
||||
'gemessene',
|
||||
'Messung',
|
||||
'Prozessorlast',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user