'use client'; import Link from 'next/link'; import { useLocale, useTranslations } from 'next-intl'; import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import type { WidgetProps } from '@/components/dashboard/widget-registry'; import { HealthBar } from '@/components/proxmox/HealthBar'; import { ProxmoxServerPicker } from '@/components/proxmox/proxmox-server-picker'; import { formatAge, formatCount, formatPercent, type ServerHealth, serverHealth, sortServersByHealth, summarizeHealth, } from '@/components/proxmox/proxmox-status'; import { HEALTH_STYLE, METER_TEXT } from '@/components/proxmox/status-styles'; import { updateWidgetConfig } from '@/lib/dashboard-api'; // 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, type KeyFigure, resolveProxmoxWidgetConfig, selectServers, widgetKeyFigure, } from './proxmox-widget-model'; /** Minutentakt: so oft liest die Kachel das Zwischenlager neu. */ const REFRESH_MS = 60_000; /** Wie TITLE_DEBOUNCE_MS der Favoriten (quick-260916-iex). */ const TITLE_DEBOUNCE_MS = 1500; const ROW_CLASS = 'flex items-center gap-2 rounded-md px-1.5 py-1 text-sm'; /** * ProxmoxWidget (quick-260924-i8v) — die erste Modul-Kachel. Zeigt den * Zustand der Proxmox-Server in der Statussprache der Modulseite: kompakter * Gesundheitsbalken, Zusammenfassung in Worten, darunter die Server nach * Dringlichkeit (down, warn, ok, idle, orphan) mit je einer Kennzahl. * * - Liest ausschliesslich das Zwischenlager (`GET /modules/proxmox/servers`, * serverseitig hinter `@UseModule('proxmox')`), alle 60 s neu; bei * verborgenem Browser-Tab pausiert der Takt, beim Zurueckkehren laedt * die Kachel sofort. * - Groessenstufen per Container-Query am Kachelrumpf des Wrappers (der ist * bereits `@container-size`; hier bewusst KEIN weiterer Container, der * die Abfragen umlenken wuerde): schmal (< 15rem) nur Punkte und Namen, * sehr klein (< 7.5rem hoch oder < 8rem breit) nur Balken und Satz. * - Ansichtsmodus: jede Zeile fuehrt zur Modulseite. Bearbeitungsmodus: * Zeilen ohne Ziel und ohne Tabstopp — bewusst KEIN Anker mit * verhindertem Klick wie bei den Favoriten, denn Links stehen im * Abbruch-Selektor von `dashboard-grid.tsx`; Anker-Zeilen wuerden das * Ziehen ueber fast die ganze Kachel blockieren. * - T-I8V-05: bei „nicht erreichbar“ nur das Zustandswort, keine * Fehlerdetails und keine Adresse — die bleiben auf der Modulseite. * - Bearbeitungsmodus (Aufgabe 3): Titelfeld (entprellt gespeichert) und * „Server auswählen“ direkt an der Kachel. Grund: Einstellungen > * Dashboard zeigt nur die Kacheln des ersten Reiters (quick-260923-ad9), * eine Proxmox-Kachel auf einem weiteren Reiter waere sonst nicht * einstellbar. Die Auswahl bekommt die schon geladene, ungefilterte * Liste — kein zusaetzlicher Abruf. */ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) { const t = useTranslations('widgets'); const tp = useTranslations('proxmox'); const locale = useLocale(); const user = useAuthStore((s) => s.user); const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; // Lokaler Zustand aus `config` initialisiert (wie `viewMode` bei den // Favoriten); Aenderungen gehen per updateWidgetConfig an den Server. const [title, setTitle] = useState(() => resolveProxmoxWidgetConfig(config).title); const [serverIds, setServerIds] = useState(() => resolveProxmoxWidgetConfig(config).serverIds); const [pickerOpen, setPickerOpen] = useState(false); const titleTimerRef = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(titleTimerRef.current), []); // Verlaesst der Nutzer den Bearbeitungsmodus, schliesst die Auswahl. useEffect(() => { if (!isEditMode) setPickerOpen(false); }, [isEditMode]); async function saveConfig(partial: Record) { try { await updateWidgetConfig(instanceId, partial); } catch { // still: der naechste Ladevorgang zeigt den gespeicherten Stand } } function handleTitleChange(e: ChangeEvent) { const value = e.target.value; setTitle(value); clearTimeout(titleTimerRef.current); titleTimerRef.current = setTimeout(() => { void saveConfig({ title: value }); }, TITLE_DEBOUNCE_MS); } function handleSelectionChange(next: string[]) { setServerIds(next); void saveConfig({ serverIds: next }); } const [servers, setServers] = useState(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; function load() { listServers() .then((list) => { if (cancelled) return; setServers(list); setLoadFailed(false); setNow(Date.now()); }) .catch(() => { // Eine schon geladene Liste bleibt stehen; der Fehlersatz // erscheint nur, solange es noch nie eine Liste gab. if (!cancelled) setLoadFailed(true); }); } function handleVisibilityChange() { if (document.visibilityState === 'visible') load(); } load(); const intervalId = setInterval(() => { if (document.visibilityState !== 'hidden') load(); }, REFRESH_MS); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { cancelled = true; clearInterval(intervalId); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); const selection = useMemo(() => selectServers(servers ?? [], serverIds), [servers, serverIds]); const counts = useMemo(() => summarizeHealth(selection.servers, now), [selection, now]); const summary = useMemo(() => healthSummary(counts), [counts]); const sorted = useMemo(() => sortServersByHealth(selection.servers, now), [selection, now]); const canChoose = servers !== null && servers.length > 0; const showPicker = isEditMode && pickerOpen && canChoose; const trimmedTitle = title.trim(); let header: ReactNode = null; if (isEditMode) { header = (
{canChoose && ( )}
); } else if (trimmedTitle !== '') { header = (

{trimmedTitle}

); } // Die Auswahl ersetzt den Listenbereich; die Huelle traegt `widgetNoDrag`, // damit ein Klick auf eine Beschriftung kein Ziehen der Kachel startet. const picker = showPicker && (
); function frame(body: ReactNode, bodyClassName = 'gap-2') { return (
{header}
{body}
); } function centered(text: string, extra?: ReactNode) { return frame( <>

{text}

{extra} , 'items-center justify-center gap-1.5 text-center', ); } if (servers === null && loadFailed) return centered(tp('loadError')); if (servers === null) { return frame(
{tp('loading')}
, ); } if (servers.length === 0) { const settingsHint = isAdmin && (isEditMode ? ( {tp('card.settingsLink')} ) : ( {tp('card.settingsLink')} )); return centered(t('proxmox.empty'), settingsHint); } if (selection.selectionGone) { // Mit offener Auswahl kann der Nutzer das gleich beheben. return picker ? frame(picker) : centered(t('proxmox.selectionGone')); } 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'; function keyFigureText(figure: KeyFigure): string { switch (figure.kind) { case 'status': return tp(`legend.${figure.health}`, { count: 1 }); case 'guests': return t('proxmox.guests', { running: figure.running, total: figure.total }); case 'noGuests': return t('proxmox.noGuests'); case 'load': return t('proxmox.load', { percent: formatPercent(figure.fraction, locale) }); case 'backup': { const age = formatAge(figure.at, now, locale); return age === null ? tp('card.unknownValue') : t('proxmox.backupAgo', { age }); } case 'noBackup': return tp('card.pbs.noBackupYet'); case 'mailIn': return t('proxmox.mailIn', { count: formatCount(figure.count, locale) }); case 'unknown': return tp('card.unknownValue'); } } function keyFigureColor(figure: KeyFigure): string { if (figure.kind === 'status') return HEALTH_STYLE[figure.health].text; if (figure.kind === 'load') return METER_TEXT[figure.level]; if (figure.kind === 'backup' && figure.stale) return HEALTH_STYLE.warn.text; return 'text-muted-foreground'; } function rowContent(server: ProxmoxServer, health: ServerHealth) { const figure = widgetKeyFigure(server, now); return ( <>