586da44fa1
- ProxmoxServerPicker gemeinsam fuer Kachel und Einstellungsformular - Bearbeitungsmodus: entprelltes Titelfeld, Server auswaehlen statt Liste, sofort gespeichert - ProxmoxWidgetConfigForm unter Einstellungen > Dashboard, Titel-Zusatz in der Kopfzeile Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
'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<ReturnType<typeof setTimeout> | 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<string, unknown>) {
|
|
try {
|
|
await updateWidgetConfig(instanceId, partial);
|
|
} catch {
|
|
// still: der naechste Ladevorgang zeigt den gespeicherten Stand
|
|
}
|
|
}
|
|
|
|
function handleTitleChange(e: ChangeEvent<HTMLInputElement>) {
|
|
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<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;
|
|
|
|
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 = (
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2.5 py-1.5">
|
|
<input
|
|
type="text"
|
|
className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
|
value={title}
|
|
onChange={handleTitleChange}
|
|
placeholder={t('proxmox.titlePlaceholder')}
|
|
aria-label={t('proxmox.titleLabel')}
|
|
/>
|
|
{canChoose && (
|
|
<button
|
|
type="button"
|
|
data-no-drag=""
|
|
aria-expanded={pickerOpen}
|
|
onClick={() => setPickerOpen((open) => !open)}
|
|
className="shrink-0 rounded-sm text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{t('proxmox.chooseServers')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
} else if (trimmedTitle !== '') {
|
|
header = (
|
|
<div className="shrink-0 border-b border-border px-2.5 py-1.5">
|
|
<h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Die Auswahl ersetzt den Listenbereich; die Huelle traegt `widgetNoDrag`,
|
|
// damit ein Klick auf eine Beschriftung kein Ziehen der Kachel startet.
|
|
const picker = showPicker && (
|
|
<div className="widgetNoDrag min-h-0 flex-1 overflow-y-auto">
|
|
<ProxmoxServerPicker
|
|
servers={servers ?? []}
|
|
selectedIds={serverIds}
|
|
onChange={handleSelectionChange}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
function frame(body: ReactNode, bodyClassName = 'gap-2') {
|
|
return (
|
|
<div
|
|
data-testid="proxmox-widget"
|
|
// pt-5: die 20 px hohe Griffleiste des Wrappers verdeckt sonst das
|
|
// Titelfeld (vgl. top-6 im XFrame).
|
|
className={`flex h-full flex-col overflow-hidden${isEditMode ? ' pt-5' : ''}`}
|
|
>
|
|
{header}
|
|
<div className={`flex min-h-0 flex-1 flex-col p-2.5 ${bodyClassName}`}>{body}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function centered(text: string, extra?: ReactNode) {
|
|
return frame(
|
|
<>
|
|
<p className="text-sm text-muted-foreground">{text}</p>
|
|
{extra}
|
|
</>,
|
|
'items-center justify-center gap-1.5 text-center',
|
|
);
|
|
}
|
|
|
|
if (servers === null && loadFailed) return centered(tp('loadError'));
|
|
|
|
if (servers === null) {
|
|
return frame(
|
|
<div aria-busy="true">
|
|
<div className="h-1.5 w-full animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
|
|
<span className="sr-only">{tp('loading')}</span>
|
|
</div>,
|
|
);
|
|
}
|
|
|
|
if (servers.length === 0) {
|
|
const settingsHint =
|
|
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>
|
|
));
|
|
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 (
|
|
<>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`h-2 w-2 shrink-0 rounded-full ${HEALTH_STYLE[health].fill}`}
|
|
/>
|
|
<span className={`min-w-0 flex-1 truncate${health === 'orphan' ? ' opacity-70' : ''}`}>
|
|
{server.name}
|
|
</span>
|
|
{/* Der Zustand haengt nie nur an der Farbe. */}
|
|
<span className="sr-only">{tp(`health.${health}`)}</span>
|
|
<span
|
|
data-testid="proxmox-key-figure"
|
|
// Wiederholt die Kennzahl nur das Zustandswort, liest der Vorleser
|
|
// sie nicht ein zweites Mal.
|
|
aria-hidden={figure.kind === 'status' ? true : undefined}
|
|
className={`shrink-0 text-right text-xs tabular-nums @max-[15rem]:hidden ${keyFigureColor(figure)}`}
|
|
>
|
|
{keyFigureText(figure)}
|
|
</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return frame(
|
|
<>
|
|
<HealthBar variant="compact" counts={counts} />
|
|
<p
|
|
className={`shrink-0 truncate text-sm font-medium ${summaryColor}`}
|
|
title={summaryText}
|
|
data-testid="proxmox-summary"
|
|
>
|
|
{summaryText}
|
|
</p>
|
|
{picker || (
|
|
<ul
|
|
data-testid="proxmox-list"
|
|
className="-mx-1 min-h-0 flex-1 overflow-y-auto [@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden"
|
|
>
|
|
{sorted.map((server) => {
|
|
const health = serverHealth(server, now);
|
|
return (
|
|
<li key={server.id} data-testid="proxmox-row" data-server-name={server.name}>
|
|
{isEditMode ? (
|
|
<div className={ROW_CLASS}>{rowContent(server, health)}</div>
|
|
) : (
|
|
<Link
|
|
href="/modules/proxmox"
|
|
className={`${ROW_CLASS} transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
|
|
>
|
|
{rowContent(server, health)}
|
|
</Link>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</>,
|
|
);
|
|
}
|