From a217d606bb04e8543bdd1ed364c6d7934607c461 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 24 Sep 2026 13:28:43 +0200 Subject: [PATCH] feat(260924-i8v): Serverliste mit Kennzahl, Auswahlfilter, Links und Minutentakt der Proxmox-Kachel - Zeilen nach down, warn, ok, idle, orphan mit genau einer Kennzahl, unbekannt statt 0 - config.serverIds filtert Liste und Balken, nur geloeschte Kennungen: eigener Satz - Ansichtsmodus Links auf /modules/proxmox, Bearbeitungsmodus ohne Links - Groessenstufen per Container-Query, Nachladen alle 60 s, pausiert bei verborgenem Tab - formatPercent/formatCount einmal in components/proxmox, ServerCard nutzt sie Co-Authored-By: Claude Opus 5.5 (1M context) --- .../modules/proxmox/components/ServerCard.tsx | 28 +- .../dashboard/widgets/proxmox-widget-model.ts | 127 ++++++++- .../dashboard/widgets/proxmox-widget.tsx | 247 ++++++++++++++---- .../src/components/proxmox/proxmox-status.ts | 16 ++ apps/web/src/messages/de.json | 8 +- apps/web/src/messages/en.json | 8 +- 6 files changed, 366 insertions(+), 68 deletions(-) diff --git a/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx index 582d786..77fdec7 100644 --- a/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx +++ b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx @@ -2,16 +2,10 @@ import Link from 'next/link'; import { useLocale, useTranslations } from 'next-intl'; -import type { - ProxmoxErrorKind, - ProxmoxPbsMetrics, - ProxmoxPmgMetrics, - ProxmoxProductType, - ProxmoxPveMetrics, - ProxmoxServer, -} from '@/lib/proxmox-api'; import { formatAge, + formatCount, + formatPercent, isBackupStale, meterLevel, ratio, @@ -20,6 +14,14 @@ import { toEpochMs, } from '@/components/proxmox/proxmox-status'; import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from '@/components/proxmox/status-styles'; +import type { + ProxmoxErrorKind, + ProxmoxPbsMetrics, + ProxmoxPmgMetrics, + ProxmoxProductType, + ProxmoxPveMetrics, + ProxmoxServer, +} from '@/lib/proxmox-api'; type Translator = ReturnType; @@ -50,16 +52,6 @@ function formatBytePair(used: number | null, total: number | null, locale: strin return `${nf.format(used / divisor)} / ${nf.format(total / divisor)} ${BYTE_UNITS[exponent]}`; } -function formatPercent(fraction: number, locale: string): string { - return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }).format( - fraction, - ); -} - -function formatCount(value: number, locale: string): string { - return new Intl.NumberFormat(locale).format(value); -} - /** Exakter Zeitpunkt fuer das `title`-Attribut der relativen Angaben. */ function exactTime(value: number | string | null | undefined, locale: string): string | undefined { const ms = toEpochMs(value); diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts b/apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts index 6279ee0..3b6053e 100644 --- a/apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts @@ -1,10 +1,21 @@ -import { HEALTH_ORDER, type ServerHealth } from '@/components/proxmox/proxmox-status'; +import { + HEALTH_ORDER, + isBackupStale, + type MeterLevel, + meterLevel, + ratio, + type ServerHealth, + serverHealth, +} from '@/components/proxmox/proxmox-status'; +import type { ProxmoxServer } from '@/lib/proxmox-api'; /** * Reine Funktionen der Proxmox-Kachel (quick-260924-i8v) — ohne React, * damit jede Verzweigung einzeln pruefbar ist (Muster `picture-frame-config.ts`). * Die Statuslogik selbst (Zustand je Server, Schwellen, Sortierung) kommt * aus `components/proxmox/proxmox-status.ts` und wird hier nicht kopiert. + * + * Grundregel wie dort: ein unbekannter Wert ist nie 0. */ export interface HealthSummary { @@ -30,3 +41,117 @@ export function healthSummary(counts: Record): HealthSumma const worst = HEALTH_ORDER.find((h) => counts[h] > 0) ?? null; return { allOk: total > 0 && entries.length === 0, entries, worst }; } + +export interface ProxmoxWidgetConfig { + /** Leer = keine Kopfzeile. */ + title: string; + /** Leer = alle Server. */ + serverIds: string[]; +} + +/** + * Konfiguration abwehrend lesen (T-I8V-03): die API prueft Kachel- + * Konfigurationen nicht inhaltlich, `config` kann also alles enthalten. + * Nicht-Strings und leere Kennungen fallen weg, doppelte zaehlen einmal. + */ +export function resolveProxmoxWidgetConfig(config: Record): ProxmoxWidgetConfig { + const title = typeof config.title === 'string' ? config.title : ''; + const rawIds = Array.isArray(config.serverIds) ? config.serverIds : []; + const serverIds: string[] = []; + for (const id of rawIds) { + if (typeof id === 'string' && id !== '' && !serverIds.includes(id)) serverIds.push(id); + } + return { title, serverIds }; +} + +/** + * Serverauswahl anwenden. Keine Auswahl = alle Server. Sind nur noch + * Kennungen geloeschter Server ausgewaehlt, ist `selectionGone` gesetzt — + * die Kachel sagt das dann, statt still „alle“ oder gar nichts zu zeigen. + * Die Reihenfolge der gelieferten Liste bleibt erhalten. + */ +export function selectServers( + servers: readonly ProxmoxServer[], + serverIds: readonly string[], +): { servers: ProxmoxServer[]; selectionGone: boolean } { + if (serverIds.length === 0) return { servers: [...servers], selectionGone: false }; + const selected = servers.filter((s) => serverIds.includes(s.id)); + return { servers: selected, selectionGone: selected.length === 0 }; +} + +/** Die eine Kennzahl rechts in einer Serverzeile. */ +export type KeyFigure = + | { kind: 'status'; health: 'down' | 'idle' | 'orphan' } + | { kind: 'guests'; running: number; total: number } + | { kind: 'noGuests' } + | { kind: 'load'; fraction: number; level: MeterLevel } + | { kind: 'backup'; at: number; stale: boolean } + | { kind: 'noBackup' } + | { kind: 'mailIn'; count: number } + | { kind: 'unknown' }; + +function isKnownNumber(value: unknown): value is number { + return typeof value === 'number' && !Number.isNaN(value); +} + +function maxKnown(values: Array): number | null { + let max: number | null = null; + for (const value of values) { + if (isKnownNumber(value) && (max === null || value > max)) max = value; + } + return max; +} + +/** + * Kennzahl je Server. Zuerst der Zustand: nicht erreichbar, noch nicht + * abgefragt und verwaist zeigen immer nur den Zustand (ein verwaister + * Server zeigt keine alten Messwerte, wie auf der Modulseite). + * + * - PVE: bei Warnung der hoechste bekannte Anteil aus Knoten-CPU, + * Knoten-RAM und Speicher (das ist der Grund der Warnung), sonst die + * laufenden Gaeste. + * - PBS: die AELTESTE bekannte letzte Sicherung ueber alle Datenspeicher — + * sie ist der Grund fuer „Sicherung zu alt“. + * - PMG: eingehende Mails. + */ +export function widgetKeyFigure(server: ProxmoxServer, now: number = Date.now()): KeyFigure { + const health = serverHealth(server, now); + if (health === 'down' || health === 'idle' || health === 'orphan') { + return { kind: 'status', health }; + } + + const metrics = server.status?.metrics ?? null; + if (metrics === null) return { kind: 'unknown' }; + + if (metrics.productType === 'pve') { + if (health === 'warn') { + const fraction = maxKnown([ + ...metrics.nodes.map((n) => (isKnownNumber(n.cpu) ? n.cpu : null)), + ...metrics.nodes.map((n) => ratio(n.mem, n.maxmem)), + ...metrics.storages.map((s) => ratio(s.disk, s.maxdisk)), + ]); + if (fraction !== null) return { kind: 'load', fraction, level: meterLevel(fraction) }; + } + if (!isKnownNumber(metrics.guestsRunning) || !isKnownNumber(metrics.guestsStopped)) { + return { kind: 'unknown' }; + } + const total = metrics.guestsRunning + metrics.guestsStopped; + if (total === 0) return { kind: 'noGuests' }; + return { kind: 'guests', running: metrics.guestsRunning, total }; + } + + if (metrics.productType === 'pbs') { + if (metrics.datastores.length === 0) return { kind: 'unknown' }; + let oldest: number | null = null; + for (const ds of metrics.datastores) { + if (isKnownNumber(ds.lastBackupAt) && (oldest === null || ds.lastBackupAt < oldest)) { + oldest = ds.lastBackupAt; + } + } + if (oldest === null) return { kind: 'noBackup' }; + return { kind: 'backup', at: oldest, stale: isBackupStale(oldest, now) }; + } + + if (!isKnownNumber(metrics.countIn)) return { kind: 'unknown' }; + return { kind: 'mailIn', count: metrics.countIn }; +} diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx b/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx index a878fc3..9081799 100644 --- a/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx @@ -1,33 +1,69 @@ 'use client'; import Link from 'next/link'; -import { useTranslations } from 'next-intl'; -import { useEffect, useMemo, useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { type ReactNode, 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'; +import { + formatAge, + formatCount, + formatPercent, + type ServerHealth, + serverHealth, + sortServersByHealth, + summarizeHealth, +} from '@/components/proxmox/proxmox-status'; +import { HEALTH_STYLE, METER_TEXT } 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'; +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; + +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 und darunter die Zusammenfassung in Worten. + * 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`), - * das `@UseModule('proxmox')` serverseitig schuetzt. + * - 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. */ -export function ProxmoxWidget({ isEditMode }: WidgetProps) { +export function ProxmoxWidget({ 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]); + const [servers, setServers] = useState(null); // Fehler als Flag, nicht als Text: `t` gehoert nicht in die Effekt- // Abhaengigkeiten (Befund 14 aus favorites-widget). @@ -36,60 +72,99 @@ export function ProxmoxWidget({ isEditMode }: WidgetProps) { useEffect(() => { let cancelled = false; - listServers() - .then((list) => { - if (cancelled) return; - setServers(list); - setLoadFailed(false); - setNow(Date.now()); - }) - .catch(() => { - if (!cancelled) setLoadFailed(true); - }); + + 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 counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]); + 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]); - if (servers === null && loadFailed) { + const trimmedTitle = title.trim(); + const header = trimmedTitle !== '' && ( +
+

{trimmedTitle}

+
+ ); + + function frame(body: ReactNode, bodyClassName = 'gap-2') { return ( -
-

{tp('loadError')}

+
+ {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 ( -
-
+ return frame( +
+
{tp('loading')} -
+
, ); } if (servers.length === 0) { - return ( -
-

{t('proxmox.empty')}

- {isAdmin && - (isEditMode ? ( - {tp('card.settingsLink')} - ) : ( - - {tp('card.settingsLink')} - - ))} -
- ); + const settingsHint = + isAdmin && + (isEditMode ? ( + {tp('card.settingsLink')} + ) : ( + + {tp('card.settingsLink')} + + )); + return centered(t('proxmox.empty'), settingsHint); } + if (selection.selectionGone) return centered(t('proxmox.selectionGone')); + const summaryText = summary.allOk ? t('proxmox.allOk') : summary.entries @@ -101,16 +176,94 @@ export function ProxmoxWidget({ isEditMode }: WidgetProps) { ? HEALTH_STYLE[summary.worst].text : 'text-muted-foreground'; - return ( -
+ 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 ( + <> +
+
    + {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-status.ts b/apps/web/src/components/proxmox/proxmox-status.ts index bbc29af..dcdc14d 100644 --- a/apps/web/src/components/proxmox/proxmox-status.ts +++ b/apps/web/src/components/proxmox/proxmox-status.ts @@ -159,3 +159,19 @@ export function formatAge( if (hours < 24) return rtf.format(-hours, 'hour'); return rtf.format(-Math.floor(hours / 24), 'day'); } + +/** + * „87 %“ — Anteil (0..1) als ganze Prozentzahl nach Sprache (deutsch mit + * geschuetztem Leerzeichen). Seit quick-260924-i8v hier statt in ServerCard, + * damit Modulseite und Dashboard-Kachel dasselbe Zahlformat nutzen. + */ +export function formatPercent(fraction: number, locale: string): string { + return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }).format( + fraction, + ); +} + +/** „12.904“ — ganze Zahl mit Tausendertrennzeichen nach Sprache. */ +export function formatCount(value: number, locale: string): string { + return new Intl.NumberFormat(locale).format(value); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 7255866..ed7c956 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -422,7 +422,13 @@ "name": "Proxmox", "description": "Zustand Ihrer Proxmox-Server auf einen Blick", "allOk": "Alles in Ordnung", - "empty": "Noch kein Proxmox-Server eingetragen." + "empty": "Noch kein Proxmox-Server eingetragen.", + "guests": "{running}/{total} {total, plural, one {Gast läuft} other {Gäste laufen}}", + "noGuests": "keine Gäste", + "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." } }, "admin": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 0c93f38..5f93046 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -422,7 +422,13 @@ "name": "Proxmox", "description": "Health of your Proxmox servers at a glance", "allOk": "All good", - "empty": "No Proxmox server added yet." + "empty": "No Proxmox server added yet.", + "guests": "{running}/{total} {total, plural, one {guest running} other {guests running}}", + "noGuests": "no guests", + "load": "Load {percent}", + "backupAgo": "Backup {age}", + "mailIn": "{count} incoming", + "selectionGone": "The selected servers no longer exist. Choose others in edit mode." } }, "admin": {