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:
2026-09-23 14:45:27 +02:00
parent 723cf6814b
commit 06fcdc0157
7 changed files with 622 additions and 34 deletions
@@ -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>
+50 -2
View File
@@ -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;
} }
+26
View File
@@ -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.",
+26
View File
@@ -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',
]; ];