diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx b/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx index 9081799..1b1d572 100644 --- a/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx @@ -2,9 +2,10 @@ import Link from 'next/link'; import { useLocale, useTranslations } from 'next-intl'; -import { type ReactNode, useEffect, useMemo, useState } from 'react'; +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, @@ -15,6 +16,7 @@ import { 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. @@ -31,6 +33,9 @@ import { /** 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'; /** @@ -54,15 +59,55 @@ const ROW_CLASS = 'flex items-center gap-2 rounded-md px-1.5 py-1 text-sm'; * 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({ config, isEditMode }: WidgetProps) { +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'; - const { title, serverIds } = useMemo(() => resolveProxmoxWidgetConfig(config), [config]); + // 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- @@ -110,16 +155,63 @@ export function ProxmoxWidget({ config, isEditMode }: WidgetProps) { 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(); - const header = trimmedTitle !== '' && ( -
-

{trimmedTitle}

+ 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}
@@ -163,7 +255,10 @@ export function ProxmoxWidget({ config, isEditMode }: WidgetProps) { return centered(t('proxmox.empty'), settingsHint); } - if (selection.selectionGone) return centered(t('proxmox.selectionGone')); + 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') @@ -242,28 +337,30 @@ export function ProxmoxWidget({ config, isEditMode }: WidgetProps) { > {summaryText}

-
    - {sorted.map((server) => { - const health = serverHealth(server, now); - return ( -
  • - {isEditMode ? ( -
    {rowContent(server, health)}
    - ) : ( - - {rowContent(server, health)} - - )} -
  • - ); - })} -
+ {picker || ( +
    + {sorted.map((server) => { + const health = serverHealth(server, now); + return ( +
  • + {isEditMode ? ( +
    {rowContent(server, health)}
    + ) : ( + + {rowContent(server, health)} + + )} +
  • + ); + })} +
+ )} , ); } diff --git a/apps/web/src/components/proxmox/proxmox-server-picker.tsx b/apps/web/src/components/proxmox/proxmox-server-picker.tsx new file mode 100644 index 0000000..edba31e --- /dev/null +++ b/apps/web/src/components/proxmox/proxmox-server-picker.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useId, useMemo } from 'react'; +import type { ProxmoxServer } from '@/lib/proxmox-api'; + +interface ProxmoxServerPickerProps { + servers: readonly ProxmoxServer[]; + selectedIds: readonly string[]; + onChange: (next: string[]) => void; +} + +/** + * Serverauswahl der Proxmox-Kachel (quick-260924-i8v) — gemeinsam fuer die + * Kachel im Bearbeitungsmodus und das Formular unter Einstellungen > + * Dashboard. Keine eigene Datenabfrage: die Liste wird hereingereicht. + * + * `onChange` liefert die Kennungen in Listenreihenfolge (nach `position`) + * und nur fuer vorhandene Server — jede Aenderung raeumt so Kennungen + * geloeschter Server mit auf. Keine Auswahl heisst: alle Server. + */ +export function ProxmoxServerPicker({ servers, selectedIds, onChange }: ProxmoxServerPickerProps) { + const t = useTranslations('widgets'); + const tp = useTranslations('proxmox'); + const baseId = useId(); + + const ordered = useMemo(() => [...servers].sort((a, b) => a.position - b.position), [servers]); + + function toggle(id: string, checked: boolean) { + const next = ordered + .map((s) => s.id) + .filter((sid) => (sid === id ? checked : selectedIds.includes(sid))); + onChange(next); + } + + return ( +
+ {t('proxmox.serversLabel')} +

{t('proxmox.serversHint')}

+
    + {ordered.map((server, index) => { + const inputId = `${baseId}-${index}`; + return ( +
  • + toggle(server.id, e.target.checked)} + className="h-4 w-4 shrink-0 rounded border-border text-primary" + /> + +
  • + ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/settings/proxmox-widget-config-form.tsx b/apps/web/src/components/settings/proxmox-widget-config-form.tsx new file mode 100644 index 0000000..97aa787 --- /dev/null +++ b/apps/web/src/components/settings/proxmox-widget-config-form.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { type ReactNode, useEffect, useState } from 'react'; +import { resolveProxmoxWidgetConfig } from '@/components/dashboard/widgets/proxmox-widget-model'; +import { ProxmoxServerPicker } from '@/components/proxmox/proxmox-server-picker'; +// T-I8V-02: nur die Leseabfrage des Zwischenlagers, nie die manuelle Abfrage. +import { listServers, type ProxmoxServer } from '@/lib/proxmox-api'; + +interface ProxmoxWidgetConfigFormProps { + config: Record; + onChange: (cfg: Record) => void; +} + +/** + * Einstellungen der Proxmox-Kachel unter Einstellungen > Dashboard + * (quick-260924-i8v): Titel und Serverauswahl. Laedt die Serverliste einmal; + * dieselbe Auswahl gibt es an der Kachel im Bearbeitungsmodus (dort auch + * fuer Kacheln auf weiteren Reitern, die diese Seite nicht zeigt). + */ +export function ProxmoxWidgetConfigForm({ config, onChange }: ProxmoxWidgetConfigFormProps) { + const t = useTranslations('widgets'); + const tp = useTranslations('proxmox'); + const { title: rawTitle, serverIds } = resolveProxmoxWidgetConfig(config); + // Anzeige getrimmt (Leerraum-Titel sind fachlich „kein Titel“), gesendet + // wird der rohe Tippwert — Muster FavoritesConfig. + const title = rawTitle.trim() === '' ? '' : rawTitle; + + const [servers, setServers] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + listServers() + .then((list) => { + if (!cancelled) setServers(list); + }) + .catch(() => { + if (!cancelled) setLoadFailed(true); + }); + return () => { + cancelled = true; + }; + }, []); + + let serverPart: ReactNode; + if (loadFailed) { + serverPart =

{tp('loadError')}

; + } else if (servers === null) { + serverPart =

{tp('loading')}

; + } else if (servers.length === 0) { + serverPart =

{t('proxmox.empty')}

; + } else { + serverPart = ( + onChange({ serverIds: next })} + /> + ); + } + + return ( +
+
+ + onChange({ title: e.target.value })} + /> +
+ {serverPart} +
+ ); +} diff --git a/apps/web/src/components/settings/widget-settings-panel.tsx b/apps/web/src/components/settings/widget-settings-panel.tsx index bc9146a..317b306 100644 --- a/apps/web/src/components/settings/widget-settings-panel.tsx +++ b/apps/web/src/components/settings/widget-settings-panel.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import Link from 'next/link'; import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry'; import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form'; +import { ProxmoxWidgetConfigForm } from '@/components/settings/proxmox-widget-config-form'; import { SearchProviderForm } from '@/components/settings/search-provider-form'; import { XframeConfigForm } from '@/components/settings/xframe-config-form'; import { updateWidgetConfig } from '@/lib/dashboard-api'; @@ -122,7 +123,8 @@ export function WidgetSettingsPanel({ {t(def.nameKey)} #{idx + 1} {(widget.widgetType === 'note' || widget.widgetType === 'favorites' || - widget.widgetType === 'xframe') && + widget.widgetType === 'xframe' || + widget.widgetType === 'proxmox') && typeof widget.config.title === 'string' && widget.config.title.trim() !== '' && ( @@ -232,6 +234,16 @@ export function WidgetSettingsPanel({ } /> )} + + {/* Proxmox: Titel und Serverauswahl (quick-260924-i8v) */} + {widget.widgetType === 'proxmox' && ( + + handleConfigChange(widget.id, cfg) + } + /> + )}
)}
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index ed7c956..8bb443b 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -428,7 +428,12 @@ "load": "Auslastung {percent}", "backupAgo": "Sicherung {age}", "mailIn": "{count} eingehend", - "selectionGone": "Die ausgewählten Server gibt es nicht mehr. Wählen Sie im Bearbeitungsmodus andere aus." + "selectionGone": "Die ausgewählten Server gibt es nicht mehr. Wählen Sie im Bearbeitungsmodus andere aus.", + "titleLabel": "Titel", + "titlePlaceholder": "Titel (optional)", + "serversLabel": "Angezeigte Server", + "serversHint": "Ohne Auswahl zeigt die Kachel alle Server.", + "chooseServers": "Server auswählen" } }, "admin": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 5f93046..e870449 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -428,7 +428,12 @@ "load": "Load {percent}", "backupAgo": "Backup {age}", "mailIn": "{count} incoming", - "selectionGone": "The selected servers no longer exist. Choose others in edit mode." + "selectionGone": "The selected servers no longer exist. Choose others in edit mode.", + "titleLabel": "Title", + "titlePlaceholder": "Title (optional)", + "serversLabel": "Servers shown", + "serversHint": "With nothing selected, the tile shows all servers.", + "chooseServers": "Choose servers" } }, "admin": {