feat(260924-h7x): Proxmox-Seite mit Status-Design und Tiefe
- Statusfarben als OKLCH-Tokens (Flaeche + kontrastgepruefte Textvariante) und Well-Token - Gesundheitsbalken mit Legende und vorgelesener Zusammenfassung - Karten nach Zustand sortiert, Statusleiste, getoenter Schatten, Statuspille - Messwerte in eingelassenen Feldern: PVE-Knoten mit Balken, PBS-Fuellstand/Sicherung/Pruefung, PMG-Zahlfelder - offline & verwaist: gestrichelt, gedaempft, keine alten Messwerte - Skelett-Karten beim Laden, Leerzustand als Well, relative Zeitangaben Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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 (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. Der Knopf
|
||||
* erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN,
|
||||
* SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6).
|
||||
* 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');
|
||||
@@ -23,10 +70,14 @@ export default function ProxmoxPage() {
|
||||
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(setServers)
|
||||
.then((list) => {
|
||||
setServers(list);
|
||||
setNow(Date.now());
|
||||
})
|
||||
.catch(() => setError(t('loadError')));
|
||||
}, [t]);
|
||||
|
||||
@@ -34,6 +85,11 @@ export default function ProxmoxPage() {
|
||||
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);
|
||||
@@ -45,63 +101,99 @@ export default function ProxmoxPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => (servers ? sortServersByHealth(servers, now) : []), [servers, now]);
|
||||
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && 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"
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
||||
</button>
|
||||
)}
|
||||
{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 text-primary hover:underline"
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{t('card.settingsLink')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</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')}
|
||||
{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}>
|
||||
<ServerCard server={server} isAdmin={isAdmin} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<HealthBar counts={counts} />
|
||||
<ul className="grid gap-4 lg:grid-cols-2">
|
||||
{sorted.map((server) => (
|
||||
<li key={server.id} className="min-w-0">
|
||||
<ServerCard server={server} isAdmin={isAdmin} now={now} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user