feat(260923-dhh): Proxmox-Modul Aufgabe 1 - PVE per Token, Ende-zu-Ende

- ProxmoxServer/ProxmoxServerStatus mit RLS (tenant_isolation_policy +
  system_read_policy auf ProxmoxServer fuer den kommenden Planer)
- proxmox-auth.ts (Token-Kopfzeilen PVE/PBS), proxmox-client.service.ts
  (proxmoxGet, ausschliesslich lesend, Dispatcher je Aufruf aus
  tlsRejectUnauthorized, nie global)
- proxmox.service.ts: Server anlegen (Geheimnis verschluesselt,
  select ohne Geheimnisfelder), Serverliste, PVE-Abfrage mit
  nachsichtiger Grundauswertung (Knoten/Gaeste)
- Controller/Modul/Seed nach Domaincheck-Vorbild, Kategorie
  "infrastructure", @UseModule('proxmox') + @Roles auf Schreibwegen
- Modulseite (duenne Liste) + proxmox-api.ts + Registrierung in
  MODULE_REGISTRY
- Zugriffsklassifikation nachgezogen (rls-access-inventory.spec.ts gruen)

Tore: api 1247/1247 (>=1240), web 693/693, type-check 4/4, lint 5/5,
Biome apps/web 53 Warnungen (unveraendert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:17:33 +02:00
parent ec9c77956d
commit 3a1bfd943e
19 changed files with 1528 additions and 4 deletions
@@ -0,0 +1,6 @@
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
import type { ReactNode } from 'react';
export default function ProxmoxLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="proxmox">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
/**
* 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).
*/
export default function ProxmoxPage() {
const t = useTranslations('proxmox');
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
listServers()
.then((data) => {
if (!cancelled) setServers(data);
})
.catch(() => {
if (!cancelled) setError(t('loadError'));
});
return () => {
cancelled = true;
};
}, [t]);
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>
{error && <p className="text-sm text-destructive">{error}</p>}
{!error && servers === null && (
<p className="text-sm text-muted-foreground">{t('loading')}</p>
)}
{!error && servers !== null && servers.length === 0 && (
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
{t('emptyState')}
</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>
))}
</ul>
)}
</div>
);
}