60b6973933
Eine kurze Karte liess im Raster eine Luecke bis zur hoeheren Nachbarkarte. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
204 lines
7.3 KiB
TypeScript
204 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import Link from 'next/link';
|
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
|
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
|
import { HealthBar } from './components/HealthBar';
|
|
import { sortServersByHealth, summarizeHealth } from './components/proxmox-status';
|
|
import { ServerCard } from './components/ServerCard';
|
|
import { WELL } from './components/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 border border-border bg-card p-4 pl-5 shadow-[0_1px_2px_oklch(0_0_0/0.06)]">
|
|
<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">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
|
</div>
|
|
{isAdmin && (
|
|
<div className="flex items-center gap-4">
|
|
<Link
|
|
href="/modules/proxmox/settings"
|
|
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
{t('card.settingsNav')}
|
|
</Link>
|
|
{servers !== null && servers.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={handleRefresh}
|
|
disabled={isRefreshing}
|
|
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-70"
|
|
>
|
|
<RefreshIcon spinning={isRefreshing} />
|
|
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{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>
|
|
);
|
|
}
|