diff --git a/apps/api/src/dashboard/widget-module-map.spec.ts b/apps/api/src/dashboard/widget-module-map.spec.ts index ff72730..6b908aa 100644 --- a/apps/api/src/dashboard/widget-module-map.spec.ts +++ b/apps/api/src/dashboard/widget-module-map.spec.ts @@ -21,12 +21,18 @@ describe('widget-module-map (quick-260922-m1h)', () => { } }); - it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => { - for (const type of WIDGET_TYPES) { + // quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle + // uebrigen bleiben Plattform-Kacheln ohne Modulbezug. + it('nur proxmox traegt einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => { + for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox')) { expect(getModuleSlugForWidgetType(type)).toBeUndefined(); } }); + it("die Proxmox-Kachel gehoert zum Modul 'proxmox' (T-I8V-01)", () => { + expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox'); + }); + it('ein unbekannter Typ liefert undefined statt zu werfen', () => { expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined(); }); diff --git a/apps/api/src/dashboard/widget-module-map.ts b/apps/api/src/dashboard/widget-module-map.ts index 10ec52e..c5d031d 100644 --- a/apps/api/src/dashboard/widget-module-map.ts +++ b/apps/api/src/dashboard/widget-module-map.ts @@ -23,17 +23,16 @@ import { WIDGET_MODULE_SLUGS } from '@tessera/shared'; * für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als * diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5). * - * Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen - * (clock/search/calendar/note/calculator/favorites/stopwatch/ - * picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste - * modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein. + * Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` → + * `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/ + * calculator/favorites/stopwatch/picture-frame/xframe) sind + * Plattform-Widgets ohne Modulbezug. */ export const WIDGET_MODULE_MAP: Readonly> = WIDGET_MODULE_SLUGS; /** * Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn - * der Typ kein Modul-Widget ist (der heutige Zustand für alle neun - * bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle, + * der Typ kein Modul-Widget ist (alle Typen außer `proxmox`). Einziger Lesezugriff auf die Zuordnungstabelle, * damit Tests sie gezielt mocken können. */ export function getModuleSlugForWidgetType(widgetType: string): string | undefined { 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 8a93fbf..582d786 100644 --- a/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx +++ b/apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx @@ -18,8 +18,8 @@ import { type ServerHealth, serverHealth, toEpochMs, -} from './proxmox-status'; -import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from './status-styles'; +} from '@/components/proxmox/proxmox-status'; +import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from '@/components/proxmox/status-styles'; type Translator = ReturnType; diff --git a/apps/web/src/app/(portal)/modules/proxmox/page.tsx b/apps/web/src/app/(portal)/modules/proxmox/page.tsx index 7ea99c1..607d2d3 100644 --- a/apps/web/src/app/(portal)/modules/proxmox/page.tsx +++ b/apps/web/src/app/(portal)/modules/proxmox/page.tsx @@ -5,10 +5,10 @@ import { useTranslations } from 'next-intl'; import Link from 'next/link'; import { useAuthStore } from '@/lib/stores/auth-store'; import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api'; -import { HealthBar } from './components/HealthBar'; -import { sortServersByHealth, summarizeHealth } from './components/proxmox-status'; +import { HealthBar } from '@/components/proxmox/HealthBar'; +import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status'; import { ServerCard } from './components/ServerCard'; -import { WELL } from './components/status-styles'; +import { WELL } from '@/components/proxmox/status-styles'; /** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */ const CLOCK_TICK_MS = 60_000; diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx index 64071bc..8877407 100644 --- a/apps/web/src/app/(portal)/page.test.tsx +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -79,6 +79,7 @@ vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWid vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null })); vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => null })); vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null })); +vi.mock('@/components/dashboard/widgets/proxmox-widget', () => ({ ProxmoxWidget: () => null })); beforeEach(() => { catalogProps.accessibleModuleSlugs = undefined; diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 6c115ec..ad9523c 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -16,6 +16,7 @@ import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget'; import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget'; +import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -34,6 +35,7 @@ registerWidget('stopwatch', StopwatchWidget); registerWidget('favorites', FavoritesWidget); registerWidget('picture-frame', PictureFrameWidget); registerWidget('xframe', XframeWidget); +registerWidget('proxmox', ProxmoxWidget); /** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */ interface ActiveModule { diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx index ac3d629..566aa67 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx @@ -27,6 +27,8 @@ vi.mock('next-intl', () => ({ 'pictureFrame.description': 'Bilder als Diashow', 'xframe.name': 'XFrame', 'xframe.description': 'Webseite einbetten', + 'proxmox.name': 'Proxmox', + 'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick', }, common: { close: 'Schließen', @@ -40,8 +42,9 @@ import { WIDGET_TYPES } from '@tessera/shared'; import { WIDGET_REGISTRY } from './widget-registry'; import { WidgetCatalogModal } from './widget-catalog-modal'; -// Alle neun Kacheln sind heute Plattform-Kacheln ohne moduleSlug, also zeigt -// der Katalog sie auch bei leerer Modulliste vollstaendig an. +// Neun Kacheln sind Plattform-Kacheln ohne moduleSlug und erscheinen auch bei +// leerer Modulliste; die Proxmox-Kachel (quick-260924-i8v) nur mit Zugriff +// auf das Modul 'proxmox'. const baseProps = { isOpen: true, onClose: vi.fn(), @@ -141,44 +144,39 @@ describe('WidgetCatalogModal', () => { * sie aus der Registry ab und filtert nach Modulzugriff. */ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => { - it('zeigt alle neun Kacheln in der Reihenfolge der Registry', () => { - render(); - + function catalogTypes(): (string | null)[] { const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' }); - const cards = Array.from( + return Array.from( dialog.querySelectorAll('button[data-widget-type]'), - ); + ).map((c) => c.getAttribute('data-widget-type')); + } - expect(cards.map((c) => c.getAttribute('data-widget-type'))).toEqual([ - ...WIDGET_TYPES, - ]); + it("zeigt mit Zugriff auf 'proxmox' alle zehn Kacheln in der Reihenfolge der Registry, Proxmox zuletzt", () => { + render(); + + expect(catalogTypes()).toEqual([...WIDGET_TYPES]); + expect(catalogTypes().at(-1)).toBe('proxmox'); expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]); }); - it('eine Kachel MIT moduleSlug fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => { - // Die Registry traegt heute keine Modul-Kachel — fuer den Nachweis am - // echten Bauteil wird clock voruebergehend zu einer gemacht. - WIDGET_REGISTRY.clock.moduleSlug = 'proxmox'; - try { - const { rerender } = render(); - expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull(); + it('zeigt ohne Modulzugriff die neun Plattform-Kacheln ohne Proxmox', () => { + render(); - rerender(); - expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument(); - } finally { - WIDGET_REGISTRY.clock.moduleSlug = undefined; - } + expect(catalogTypes()).toEqual(WIDGET_TYPES.filter((type) => type !== 'proxmox')); }); - it('fail-closed: schlaegt der Modulabruf fehl (null), verschwinden Kacheln MIT moduleSlug, Plattform-Kacheln bleiben', () => { - WIDGET_REGISTRY.clock.moduleSlug = 'proxmox'; - try { - render(); + it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => { + const { rerender } = render(); + expect(screen.queryByRole('button', { name: /Proxmox/ })).toBeNull(); - expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull(); - expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument(); - } finally { - WIDGET_REGISTRY.clock.moduleSlug = undefined; - } + rerender(); + expect(screen.getByRole('button', { name: /Proxmox/ })).toBeInTheDocument(); + }); + + it('fail-closed: schlaegt der Modulabruf fehl (null), fehlt „Proxmox“, Plattform-Kacheln bleiben', () => { + render(); + + expect(screen.queryByRole('button', { name: /Proxmox/ })).toBeNull(); + expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx index 62b6053..8ac9b5f 100644 --- a/apps/web/src/components/dashboard/widget-registry.test.tsx +++ b/apps/web/src/components/dashboard/widget-registry.test.tsx @@ -29,6 +29,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [ 'picture-frame', // XFrame (quick-260921-qd3) 'xframe', + // Proxmox — erste Modul-Kachel (quick-260924-i8v) + 'proxmox', ]; describe('WIDGET_CONSTRAINTS (DASH-11)', () => { @@ -64,7 +66,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch'); }); - it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu, neun Typen)', () => { + it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => { // Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit // quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben // (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte. @@ -79,6 +81,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 }, 'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 }, xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 }, + proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 }, }); let counted = 0; @@ -88,7 +91,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { counted++; } } - expect(counted).toBe(36); + expect(counted).toBe(40); }); }); @@ -104,7 +107,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => { expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]); }); - it('die neun erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => { + it('die zehn erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => { expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES); }); @@ -114,9 +117,13 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => { } }); - it('heute traegt keine der neun Kacheln einen moduleSlug (alle sind Plattform-Kacheln)', () => { + it("nur proxmox traegt moduleSlug 'proxmox', alle anderen Kacheln keinen (quick-260924-i8v)", () => { for (const type of WIDGET_TYPES) { - expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined(); + if (type === 'proxmox') { + expect(WIDGET_REGISTRY[type].moduleSlug).toBe('proxmox'); + } else { + expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined(); + } } }); }); @@ -154,15 +161,15 @@ describe('registerWidget (quick-260922-m1h)', () => { expect(() => // Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der // Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde). - registerWidget('proxmox' as WidgetType, makeComponent()), - ).toThrow(/proxmox/); + registerWidget('gibt-es-nicht' as WidgetType, makeComponent()), + ).toThrow(/gibt-es-nicht/); }); it('ein unbekannter Typ wird in der Produktion still ignoriert', () => { const previous = process.env.NODE_ENV; vi.stubEnv('NODE_ENV', 'production'); - expect(() => registerWidget('proxmox' as WidgetType, makeComponent())).not.toThrow(); + expect(() => registerWidget('gibt-es-nicht' as WidgetType, makeComponent())).not.toThrow(); vi.stubEnv('NODE_ENV', previous ?? 'test'); vi.unstubAllEnvs(); @@ -182,8 +189,14 @@ describe('visibleWidgetTypes (quick-260922-m1h)', () => { note: {}, }; - it('behaelt die Reihenfolge der Registry bei', () => { - expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual([...WIDGET_TYPES]); + it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlt nur Proxmox', () => { + expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual( + WIDGET_TYPES.filter((type) => type !== 'proxmox'), + ); + }); + + it("mit Zugriff auf 'proxmox' erscheinen alle zehn Kacheln in WIDGET_TYPES-Reihenfolge (quick-260924-i8v)", () => { + expect(visibleWidgetTypes(WIDGET_REGISTRY, ['proxmox'])).toEqual([...WIDGET_TYPES]); }); it('Kacheln ohne moduleSlug sind immer sichtbar', () => { diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index caacebe..d7d61ca 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -18,6 +18,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared'; * decken den Fall ab). * picture-frame: Bilderrahmen (quick-260921-pi9). * xframe: Webseite als Rahmen (quick-260921-qd3). + * proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v). */ export type { WidgetType }; @@ -71,6 +72,11 @@ export const WIDGET_CONSTRAINTS: Record< // fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein // Rahmen noch Sinn hat. xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 }, + // quick-260924-i8v: 4 Zeilen = 104 px reichen genau fuer Balken und + // Zusammenfassung (die Serverliste blendet sich darunter per Container- + // Query aus); 8x8 = rund 456x216 px bei 1400 px Breite zeigt etwa sechs + // Serverzeilen; 3 Spalten = rund 166 px zeigen nur Punkte und Namen. + proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 }, }; /** @@ -83,7 +89,7 @@ export interface WidgetDefinition { /** * Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus * `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel - * und immer sichtbar — der heutige Zustand fuer alle neun Kacheln. + * und immer sichtbar — der Zustand fuer alle Kacheln ausser Proxmox. */ moduleSlug?: string; /** Inline SVG icon as React component */ @@ -317,6 +323,29 @@ function XframeIcon({ className }: { className?: string }) { ); } +function ProxmoxIcon({ className }: { className?: string }) { + return ( + + ); +} + /** * Registry of all widget types. Used by the widget catalog modal * and the grid renderer to look up components and constraints. @@ -403,6 +432,17 @@ export const WIDGET_REGISTRY: Record = { moduleSlug: WIDGET_MODULE_SLUGS.xframe, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, + proxmox: { + type: 'proxmox', + nameKey: 'proxmox.name', + descriptionKey: 'proxmox.description', + icon: ProxmoxIcon, + ...WIDGET_CONSTRAINTS.proxmox, + // Erste Modul-Kachel: ohne Zugriff auf das Modul 'proxmox' fehlt sie im + // Katalog (Komfort) und verbindlich serverseitig auf dem Dashboard. + moduleSlug: WIDGET_MODULE_SLUGS.proxmox, + component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt + }, }; /** diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts b/apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts new file mode 100644 index 0000000..6279ee0 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts @@ -0,0 +1,32 @@ +import { HEALTH_ORDER, type ServerHealth } from '@/components/proxmox/proxmox-status'; + +/** + * 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. + */ + +export interface HealthSummary { + /** Es gibt Server, und keiner weicht von „in Ordnung“ ab. */ + allOk: boolean; + /** Alle Zustaende ausser `ok` mit Anzahl > 0, in `HEALTH_ORDER`. */ + entries: Array<{ health: ServerHealth; count: number }>; + /** Schlimmster vorhandene Zustand (erster in `HEALTH_ORDER` mit Anzahl > 0). */ + worst: ServerHealth | null; +} + +/** + * Zusammenfassung fuer die Zeile unter dem Balken: „Alles in Ordnung“ oder + * die Abweichungen („1 nicht erreichbar, 1 mit Warnung“). Die Anzahl `ok` + * wird bewusst nicht genannt — die Zeile soll sagen, was zu tun ist. + */ +export function healthSummary(counts: Record): HealthSummary { + const entries = HEALTH_ORDER.filter((h) => h !== 'ok' && counts[h] > 0).map((health) => ({ + health, + count: counts[health], + })); + const total = HEALTH_ORDER.reduce((sum, h) => sum + counts[h], 0); + const worst = HEALTH_ORDER.find((h) => counts[h] > 0) ?? null; + return { allOk: total > 0 && entries.length === 0, entries, worst }; +} diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx b/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx new file mode 100644 index 0000000..8f77af2 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx @@ -0,0 +1,233 @@ +import { cleanup, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api'; +import de from '@/messages/de.json'; + +// Echter next-intl-Provider mit den deutschen Texten (Muster +// proxmox-page-roles.test.tsx) — die Kachel braucht ICU-Plural und useLocale. +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +// T-I8V-02: `pollServer` ist nur als Spion da — die Kachel darf ihn nie rufen. +const mockListServers = vi.fn(); +const mockPollServer = vi.fn(); + +vi.mock('@/lib/proxmox-api', () => ({ + listServers: (...args: unknown[]) => mockListServers(...args), + pollServer: (...args: unknown[]) => mockPollServer(...args), +})); + +const mockAuthStore = vi.fn(); +vi.mock('@/lib/stores/auth-store', () => ({ + useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector), +})); + +vi.mock('next/link', () => ({ + default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})); + +function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) { + mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) => + selector({ user }), + ); +} + +type Health = 'ok' | 'warn' | 'down' | 'idle' | 'orphan'; + +const POLLED_AT = new Date(Date.now() - 2 * 60 * 1000).toISOString(); + +function pveMetrics(overrides: Partial> = {}) { + return { + productType: 'pve' as const, + nodeCount: 1, + guestsRunning: 3, + guestsStopped: 1, + nodes: [{ node: 'n1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 10 }], + storages: [], + ...overrides, + }; +} + +/** + * Baukasten: ein Server mit Zwischenlager passend zum gewuenschten Zustand. + * `warn` = PVE-Knoten bei 87 % Last. + */ +function makeServer( + health: Health, + overrides: Partial & { metrics?: ProxmoxMetrics | null } = {}, +): ProxmoxServer { + const { metrics: metricsOverride, ...rest } = overrides; + const id = rest.id ?? `srv-${health}`; + const metrics: ProxmoxMetrics | null = + metricsOverride !== undefined + ? metricsOverride + : health === 'warn' + ? pveMetrics({ nodes: [{ node: 'n1', cpu: 0.87, maxcpu: 8, mem: 1, maxmem: 10 }] }) + : pveMetrics(); + return { + id, + tenantId: 't1', + name: `server-${health}`, + productType: 'pve', + baseUrl: 'https://pve.intern', + authMethod: 'token', + tokenId: 'root@pam!x', + username: null, + tlsRejectUnauthorized: true, + isActive: health !== 'orphan', + pollIntervalMin: 5, + position: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: { + id: `st-${id}`, + serverId: id, + lastPolledAt: health === 'idle' ? null : POLLED_AT, + lastOkAt: health === 'down' ? null : POLLED_AT, + reachable: health !== 'down' && health !== 'idle', + errorKind: health === 'down' ? 'netz' : null, + errorDetail: health === 'down' ? 'connect ECONNREFUSED 10.0.0.1:8006' : null, + rawSample: null, + updatedAt: POLLED_AT, + metrics: health === 'down' || health === 'idle' ? null : metrics, + }, + ...rest, + }; +} + +async function renderWidget( + props: { config?: Record; isEditMode?: boolean } = {}, +) { + const { ProxmoxWidget } = await import('./proxmox-widget'); + return render( + , + ); +} + +beforeEach(() => { + mockUser({ role: 'USER' }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('ProxmoxWidget: Balken und Zusammenfassung (quick-260924-i8v)', () => { + it('alle Server in Ordnung: „Alles in Ordnung“ in der Ok-Farbe', async () => { + mockListServers.mockResolvedValue([ + makeServer('ok', { id: 'a', name: 'pve-a' }), + makeServer('ok', { id: 'b', name: 'pve-b' }), + ]); + await renderWidget(); + + const summary = await screen.findByTestId('proxmox-summary'); + expect(summary).toHaveTextContent('Alles in Ordnung'); + expect(summary).toHaveClass('text-status-ok-fg'); + }); + + it('je ein down, warn, ok: „1 nicht erreichbar, 1 mit Warnung“ in der Farbe von down', async () => { + mockListServers.mockResolvedValue([makeServer('ok'), makeServer('warn'), makeServer('down')]); + await renderWidget(); + + const summary = await screen.findByTestId('proxmox-summary'); + expect(summary).toHaveTextContent('1 nicht erreichbar, 1 mit Warnung'); + expect(summary).toHaveClass('text-status-down-fg'); + expect(summary).toHaveAttribute('title', '1 nicht erreichbar, 1 mit Warnung'); + }); + + it('zeigt den kompakten Balken (6 px, aria-hidden) ohne Legende', async () => { + mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]); + await renderWidget(); + + const bar = await screen.findByTestId('health-bar'); + expect(bar).toHaveAttribute('data-variant', 'compact'); + expect(bar).toHaveClass('h-1.5'); + expect(bar).toHaveAttribute('aria-hidden', 'true'); + expect(bar.querySelectorAll('[data-segment]')).toHaveLength(2); + // Keine Legende: kein Listeneintrag im Balken, kein role="img" + expect(bar.querySelector('li')).toBeNull(); + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('zeigt die Fehlerdetails eines nicht erreichbaren Servers nicht (T-I8V-05)', async () => { + mockListServers.mockResolvedValue([makeServer('down')]); + await renderWidget(); + + await screen.findByTestId('proxmox-summary'); + expect(screen.queryByText(/ECONNREFUSED/)).toBeNull(); + expect(screen.queryByText(/pve\.intern/)).toBeNull(); + }); +}); + +describe('ProxmoxWidget: Lade-, Leer- und Fehlerzustand (quick-260924-i8v)', () => { + it('waehrend des Ladens: pulsierende Leiste und Vorlesetext', async () => { + mockListServers.mockReturnValue(new Promise(() => {})); + await renderWidget(); + + expect(screen.getByText('Lade Serverliste...')).toHaveClass('sr-only'); + }); + + it('leere Liste: Satz; ein Admin sieht den Link zu den Einstellungen', async () => { + mockUser({ role: 'ADMIN' }); + mockListServers.mockResolvedValue([]); + await renderWidget(); + + expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Zu den Einstellungen' })).toHaveAttribute( + 'href', + '/modules/proxmox/settings', + ); + }); + + it('leere Liste: ein Benutzer mit Rolle USER sieht keinen Link', async () => { + mockListServers.mockResolvedValue([]); + await renderWidget(); + + expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument(); + expect(screen.queryByRole('link')).toBeNull(); + expect(screen.queryByText('Zu den Einstellungen')).toBeNull(); + }); + + it('leere Liste im Bearbeitungsmodus: der Hinweis fuer Admins steht ohne Link da', async () => { + mockUser({ role: 'SUPER_ADMIN' }); + mockListServers.mockResolvedValue([]); + await renderWidget({ isEditMode: true }); + + expect(await screen.findByText('Zu den Einstellungen')).toBeInTheDocument(); + expect(screen.queryByRole('link')).toBeNull(); + }); + + it('Laden schlaegt fehl: „Die Serverliste konnte nicht geladen werden.“', async () => { + mockListServers.mockRejectedValue(new Error('500')); + await renderWidget(); + + expect( + await screen.findByText('Die Serverliste konnte nicht geladen werden.'), + ).toBeInTheDocument(); + }); + + it('ruft nie die manuelle Abfrage auf (T-I8V-02)', async () => { + mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]); + await renderWidget(); + + await screen.findByTestId('proxmox-summary'); + await waitFor(() => expect(mockListServers).toHaveBeenCalledTimes(1)); + expect(mockPollServer).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx b/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx new file mode 100644 index 0000000..a878fc3 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget.tsx @@ -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(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 ( +
+

{tp('loadError')}

+
+ ); + } + + if (servers === null) { + return ( +
+
+ {tp('loading')} +
+ ); + } + + if (servers.length === 0) { + return ( +
+

{t('proxmox.empty')}

+ {isAdmin && + (isEditMode ? ( + {tp('card.settingsLink')} + ) : ( + + {tp('card.settingsLink')} + + ))} +
+ ); + } + + 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 ( +
+ +

+ {summaryText} +

+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx b/apps/web/src/components/proxmox/HealthBar.tsx similarity index 56% rename from apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx rename to apps/web/src/components/proxmox/HealthBar.tsx index 088a1c7..4bcb508 100644 --- a/apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx +++ b/apps/web/src/components/proxmox/HealthBar.tsx @@ -6,6 +6,14 @@ import { HEALTH_STYLE } from './status-styles'; interface HealthBarProps { counts: Record; + /** + * `full` (Standard, Modulseite): 8 px hoher Balken samt Legende, der + * Balken liest seine Zusammenfassung als `role="img"` vor. + * `compact` (Dashboard-Kachel, quick-260924-i8v): 6 px hoher Balken OHNE + * Legende und `aria-hidden` — die Kachel zeigt die Zusammenfassung direkt + * darunter als sichtbaren Text, ein Vorleser saehe sie sonst doppelt. + */ + variant?: 'full' | 'compact'; } /** @@ -14,8 +22,11 @@ interface HealthBarProps { * (down, warn, ok, idle, orphan; leere Zustaende entfallen), darunter die * Legende „Punkt + Zahl + Wort“. Der Balken liest seine Zusammenfassung als * `role="img"` vor. + * + * Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel + * (dort als `variant="compact"`). */ -export function HealthBar({ counts }: HealthBarProps) { +export function HealthBar({ counts, variant = 'full' }: HealthBarProps) { const t = useTranslations('proxmox'); const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({ health, @@ -24,23 +35,38 @@ export function HealthBar({ counts }: HealthBarProps) { })); if (entries.length === 0) return null; + const segments = entries.map((e) => ( +
+ )); + + if (variant === 'compact') { + return ( + + ); + } + const summary = entries.map((e) => `${e.count} ${e.word}`).join(', '); return ( -
+
- {entries.map((e) => ( -
- ))} + {segments}