feat(260924-i8v): Proxmox-Kachel als Durchstich mit Balken und Zusammenfassung

- Typ proxmox am Ende von WIDGET_TYPES, WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }
- gemeinsame Statusteile (proxmox-status, HealthBar, status-styles) nach components/proxmox/
- HealthBar variant compact: 6 px, ohne Legende, aria-hidden
- Registry 3/4/8/8, Katalog-/Registry-/API-Tests auf die erste Modul-Kachel umgestellt
- ProxmoxWidget liest nur listServers, zeigt Lade-, Leer-, Fehlerzustand

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 13:24:51 +02:00
parent 8bfa4fc467
commit a906c6705d
19 changed files with 556 additions and 69 deletions
@@ -0,0 +1,116 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { HealthBar } from '@/components/proxmox/HealthBar';
import { summarizeHealth } from '@/components/proxmox/proxmox-status';
import { HEALTH_STYLE } from '@/components/proxmox/status-styles';
// T-I8V-02: aus dem Proxmox-Klienten NUR die Leseabfrage des Zwischenlagers.
// Die manuelle Abfrage (POST .../poll) gehoert der Modulseite und darf hier
// nie auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen aus.
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { healthSummary } from './proxmox-widget-model';
/**
* ProxmoxWidget (quick-260924-i8v) — die erste Modul-Kachel. Zeigt den
* Zustand der Proxmox-Server in der Statussprache der Modulseite: kompakter
* Gesundheitsbalken und darunter die Zusammenfassung in Worten.
*
* Liest ausschliesslich das Zwischenlager (`GET /modules/proxmox/servers`),
* das `@UseModule('proxmox')` serverseitig schuetzt.
*/
export function ProxmoxWidget({ isEditMode }: WidgetProps) {
const t = useTranslations('widgets');
const tp = useTranslations('proxmox');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
// Fehler als Flag, nicht als Text: `t` gehoert nicht in die Effekt-
// Abhaengigkeiten (Befund 14 aus favorites-widget).
const [loadFailed, setLoadFailed] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
let cancelled = false;
listServers()
.then((list) => {
if (cancelled) return;
setServers(list);
setLoadFailed(false);
setNow(Date.now());
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
return () => {
cancelled = true;
};
}, []);
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
const summary = useMemo(() => healthSummary(counts), [counts]);
if (servers === null && loadFailed) {
return (
<div className="flex h-full items-center justify-center p-2.5">
<p className="text-center text-sm text-muted-foreground">{tp('loadError')}</p>
</div>
);
}
if (servers === null) {
return (
<div className="flex h-full flex-col overflow-hidden p-2.5" aria-busy="true">
<div className="h-1.5 w-full shrink-0 animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
<span className="sr-only">{tp('loading')}</span>
</div>
);
}
if (servers.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-1.5 p-2.5 text-center">
<p className="text-sm text-muted-foreground">{t('proxmox.empty')}</p>
{isAdmin &&
(isEditMode ? (
<span className="text-sm font-medium text-foreground">{tp('card.settingsLink')}</span>
) : (
<Link
href="/modules/proxmox/settings"
className="rounded-sm text-sm font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{tp('card.settingsLink')}
</Link>
))}
</div>
);
}
const summaryText = summary.allOk
? t('proxmox.allOk')
: summary.entries
.map((e) => `${e.count} ${tp(`legend.${e.health}`, { count: e.count })}`)
.join(', ');
const summaryColor = summary.allOk
? HEALTH_STYLE.ok.text
: summary.worst
? HEALTH_STYLE[summary.worst].text
: 'text-muted-foreground';
return (
<div className="flex h-full flex-col gap-2 overflow-hidden p-2.5">
<HealthBar variant="compact" counts={counts} />
<p
className={`truncate text-sm font-medium ${summaryColor}`}
title={summaryText}
data-testid="proxmox-summary"
>
{summaryText}
</p>
</div>
);
}