30e4bc3a4f
Einheitliche Seitenkoepfe mit Modul-Kachel, Knopf- und Linkklassen statt gelber Schrift, Karten ohne harte Rahmen, Tabellenkoepfe ohne Grossbuchstaben, Marktplatz mit Fluent-Reitern, Filterpillen und Kachel-Karten, Einstellungs-/Verwaltungsnavigation mit Auswahlpille, leere Zustaende mit Symbol im Kreis, Proxmox ohne leuchtende Schatten. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
203 lines
6.8 KiB
TypeScript
203 lines
6.8 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import Link from 'next/link';
|
|
import { PageHeader } from '@/components/layout/page-header';
|
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
|
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
|
import { HealthBar } from '@/components/proxmox/HealthBar';
|
|
import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status';
|
|
import { ServerCard } from './components/ServerCard';
|
|
import { WELL } from '@/components/proxmox/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 bg-card p-4 pl-5 shadow-sm dark:border dark:border-border">
|
|
<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 (quick-260924-h7x, vorher 260923-dhh Aufgabe 6) — liest
|
|
* ausschliesslich aus dem Zwischenlager, das `GET servers` liefert; kein
|
|
* Live-Zugriff bei Proxmox von hier aus (D-05).
|
|
*
|
|
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
|
|
* (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() {
|
|
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 [now, setNow] = useState(() => Date.now());
|
|
|
|
const reload = useCallback(() => {
|
|
listServers()
|
|
.then((list) => {
|
|
setServers(list);
|
|
setNow(Date.now());
|
|
})
|
|
.catch(() => setError(t('loadError')));
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
reload();
|
|
}, [reload]);
|
|
|
|
useEffect(() => {
|
|
const id = window.setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const sorted = useMemo(() => (servers ? sortServersByHealth(servers, now) : []), [servers, now]);
|
|
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
|
|
|
|
return (
|
|
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
|
<PageHeader
|
|
moduleSlug="proxmox"
|
|
title={t('title')}
|
|
description={t('description')}
|
|
actions={
|
|
isAdmin ? (
|
|
<>
|
|
<Link href="/modules/proxmox/settings" className="btn btn-subtle">
|
|
{t('card.settingsNav')}
|
|
</Link>
|
|
{servers !== null && servers.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={handleRefresh}
|
|
disabled={isRefreshing}
|
|
className="btn btn-primary"
|
|
>
|
|
<RefreshIcon spinning={isRefreshing} />
|
|
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
|
</button>
|
|
)}
|
|
</>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
{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 && (
|
|
<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 && (
|
|
<div className={`${WELL} flex flex-col items-center gap-3 px-6 py-10 text-center`}>
|
|
<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 && (
|
|
<Link
|
|
href="/modules/proxmox/settings"
|
|
className="text-sm font-medium text-foreground hover:underline"
|
|
>
|
|
{t('card.settingsLink')}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!error && servers !== null && servers.length > 0 && (
|
|
<>
|
|
<HealthBar counts={counts} />
|
|
{/* Spaltenfluss statt Zeilenraster: eine kurze Karte (z. B. "nicht
|
|
erreichbar") liesse im Raster eine Luecke bis zur hoeheren
|
|
Nachbarkarte; so rueckt die naechste Karte direkt nach. */}
|
|
<ul className="gap-4 lg:columns-2">
|
|
{sorted.map((server) => (
|
|
<li key={server.id} className="mb-4 min-w-0 break-inside-avoid">
|
|
<ServerCard server={server} isAdmin={isAdmin} now={now} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|