feat(260924-i8v): Proxmox-Kachel als Durchstich mit Balken und Zusammenfassung
- Typ proxmox am Ende von WIDGET_TYPES, WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }
- gemeinsame Statusteile (proxmox-status, HealthBar, status-styles) nach components/proxmox/
- HealthBar variant compact: 6 px, ohne Legende, aria-hidden
- Registry 3/4/8/8, Katalog-/Registry-/API-Tests auf die erste Modul-Kachel umgestellt
- ProxmoxWidget liest nur listServers, zeigt Lade-, Leer-, Fehlerzustand
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,12 +21,18 @@ describe('widget-module-map (quick-260922-m1h)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
|
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle
|
||||||
for (const type of WIDGET_TYPES) {
|
// 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();
|
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', () => {
|
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
|
||||||
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
* 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).
|
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||||
*
|
*
|
||||||
* Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
|
* Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` →
|
||||||
* (clock/search/calendar/note/calculator/favorites/stopwatch/
|
* `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/
|
||||||
* picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
|
* calculator/favorites/stopwatch/picture-frame/xframe) sind
|
||||||
* modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
|
* Plattform-Widgets ohne Modulbezug.
|
||||||
*/
|
*/
|
||||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
* 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
|
* der Typ kein Modul-Widget ist (alle Typen außer `proxmox`). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
|
||||||
* damit Tests sie gezielt mocken können.
|
* damit Tests sie gezielt mocken können.
|
||||||
*/
|
*/
|
||||||
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
type ServerHealth,
|
type ServerHealth,
|
||||||
serverHealth,
|
serverHealth,
|
||||||
toEpochMs,
|
toEpochMs,
|
||||||
} from './proxmox-status';
|
} from '@/components/proxmox/proxmox-status';
|
||||||
import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from './status-styles';
|
import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from '@/components/proxmox/status-styles';
|
||||||
|
|
||||||
type Translator = ReturnType<typeof useTranslations>;
|
type Translator = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { useTranslations } from 'next-intl';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||||
import { HealthBar } from './components/HealthBar';
|
import { HealthBar } from '@/components/proxmox/HealthBar';
|
||||||
import { sortServersByHealth, summarizeHealth } from './components/proxmox-status';
|
import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status';
|
||||||
import { ServerCard } from './components/ServerCard';
|
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. */
|
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
||||||
const CLOCK_TICK_MS = 60_000;
|
const CLOCK_TICK_MS = 60_000;
|
||||||
|
|||||||
@@ -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/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
||||||
vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => 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/xframe-widget', () => ({ XframeWidget: () => null }));
|
||||||
|
vi.mock('@/components/dashboard/widgets/proxmox-widget', () => ({ ProxmoxWidget: () => null }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
catalogProps.accessibleModuleSlugs = undefined;
|
catalogProps.accessibleModuleSlugs = undefined;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget
|
|||||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-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 { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ registerWidget('stopwatch', StopwatchWidget);
|
|||||||
registerWidget('favorites', FavoritesWidget);
|
registerWidget('favorites', FavoritesWidget);
|
||||||
registerWidget('picture-frame', PictureFrameWidget);
|
registerWidget('picture-frame', PictureFrameWidget);
|
||||||
registerWidget('xframe', XframeWidget);
|
registerWidget('xframe', XframeWidget);
|
||||||
|
registerWidget('proxmox', ProxmoxWidget);
|
||||||
|
|
||||||
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
||||||
interface ActiveModule {
|
interface ActiveModule {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ vi.mock('next-intl', () => ({
|
|||||||
'pictureFrame.description': 'Bilder als Diashow',
|
'pictureFrame.description': 'Bilder als Diashow',
|
||||||
'xframe.name': 'XFrame',
|
'xframe.name': 'XFrame',
|
||||||
'xframe.description': 'Webseite einbetten',
|
'xframe.description': 'Webseite einbetten',
|
||||||
|
'proxmox.name': 'Proxmox',
|
||||||
|
'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
close: 'Schließen',
|
close: 'Schließen',
|
||||||
@@ -40,8 +42,9 @@ import { WIDGET_TYPES } from '@tessera/shared';
|
|||||||
import { WIDGET_REGISTRY } from './widget-registry';
|
import { WIDGET_REGISTRY } from './widget-registry';
|
||||||
import { WidgetCatalogModal } from './widget-catalog-modal';
|
import { WidgetCatalogModal } from './widget-catalog-modal';
|
||||||
|
|
||||||
// Alle neun Kacheln sind heute Plattform-Kacheln ohne moduleSlug, also zeigt
|
// Neun Kacheln sind Plattform-Kacheln ohne moduleSlug und erscheinen auch bei
|
||||||
// der Katalog sie auch bei leerer Modulliste vollstaendig an.
|
// leerer Modulliste; die Proxmox-Kachel (quick-260924-i8v) nur mit Zugriff
|
||||||
|
// auf das Modul 'proxmox'.
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
@@ -141,44 +144,39 @@ describe('WidgetCatalogModal', () => {
|
|||||||
* sie aus der Registry ab und filtert nach Modulzugriff.
|
* sie aus der Registry ab und filtert nach Modulzugriff.
|
||||||
*/
|
*/
|
||||||
describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => {
|
describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => {
|
||||||
it('zeigt alle neun Kacheln in der Reihenfolge der Registry', () => {
|
function catalogTypes(): (string | null)[] {
|
||||||
render(<WidgetCatalogModal {...baseProps} />);
|
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
|
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
|
||||||
const cards = Array.from(
|
return Array.from(
|
||||||
dialog.querySelectorAll<HTMLButtonElement>('button[data-widget-type]'),
|
dialog.querySelectorAll<HTMLButtonElement>('button[data-widget-type]'),
|
||||||
);
|
).map((c) => c.getAttribute('data-widget-type'));
|
||||||
|
}
|
||||||
|
|
||||||
expect(cards.map((c) => c.getAttribute('data-widget-type'))).toEqual([
|
it("zeigt mit Zugriff auf 'proxmox' alle zehn Kacheln in der Reihenfolge der Registry, Proxmox zuletzt", () => {
|
||||||
...WIDGET_TYPES,
|
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||||
]);
|
|
||||||
|
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
|
||||||
|
expect(catalogTypes().at(-1)).toBe('proxmox');
|
||||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine Kachel MIT moduleSlug fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
it('zeigt ohne Modulzugriff die neun Plattform-Kacheln ohne Proxmox', () => {
|
||||||
// Die Registry traegt heute keine Modul-Kachel — fuer den Nachweis am
|
render(<WidgetCatalogModal {...baseProps} />);
|
||||||
// echten Bauteil wird clock voruebergehend zu einer gemacht.
|
|
||||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
|
||||||
try {
|
|
||||||
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
|
|
||||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
|
||||||
|
|
||||||
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
expect(catalogTypes()).toEqual(WIDGET_TYPES.filter((type) => type !== 'proxmox'));
|
||||||
expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument();
|
|
||||||
} finally {
|
|
||||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fail-closed: schlaegt der Modulabruf fehl (null), verschwinden Kacheln MIT moduleSlug, Plattform-Kacheln bleiben', () => {
|
it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
||||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
|
||||||
try {
|
expect(screen.queryByRole('button', { name: /Proxmox/ })).toBeNull();
|
||||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
|
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||||
expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Proxmox/ })).toBeInTheDocument();
|
||||||
} finally {
|
});
|
||||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
|
||||||
}
|
it('fail-closed: schlaegt der Modulabruf fehl (null), fehlt „Proxmox“, Plattform-Kacheln bleiben', () => {
|
||||||
|
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /Proxmox/ })).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
|||||||
'picture-frame',
|
'picture-frame',
|
||||||
// XFrame (quick-260921-qd3)
|
// XFrame (quick-260921-qd3)
|
||||||
'xframe',
|
'xframe',
|
||||||
|
// Proxmox — erste Modul-Kachel (quick-260924-i8v)
|
||||||
|
'proxmox',
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||||
@@ -64,7 +66,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
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
|
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
|
||||||
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
||||||
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
// (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 },
|
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||||
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
||||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||||
|
proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 },
|
||||||
});
|
});
|
||||||
|
|
||||||
let counted = 0;
|
let counted = 0;
|
||||||
@@ -88,7 +91,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
counted++;
|
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]);
|
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);
|
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) {
|
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(() =>
|
expect(() =>
|
||||||
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
|
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
|
||||||
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
|
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
|
||||||
registerWidget('proxmox' as WidgetType, makeComponent()),
|
registerWidget('gibt-es-nicht' as WidgetType, makeComponent()),
|
||||||
).toThrow(/proxmox/);
|
).toThrow(/gibt-es-nicht/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
|
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
|
||||||
const previous = process.env.NODE_ENV;
|
const previous = process.env.NODE_ENV;
|
||||||
vi.stubEnv('NODE_ENV', 'production');
|
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.stubEnv('NODE_ENV', previous ?? 'test');
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@@ -182,8 +189,14 @@ describe('visibleWidgetTypes (quick-260922-m1h)', () => {
|
|||||||
note: {},
|
note: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('behaelt die Reihenfolge der Registry bei', () => {
|
it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlt nur Proxmox', () => {
|
||||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual([...WIDGET_TYPES]);
|
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', () => {
|
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
|||||||
* decken den Fall ab).
|
* decken den Fall ab).
|
||||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||||
|
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
|
||||||
*/
|
*/
|
||||||
export type { WidgetType };
|
export type { WidgetType };
|
||||||
|
|
||||||
@@ -71,6 +72,11 @@ export const WIDGET_CONSTRAINTS: Record<
|
|||||||
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
|
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
|
||||||
// Rahmen noch Sinn hat.
|
// Rahmen noch Sinn hat.
|
||||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
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
|
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
|
||||||
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel
|
* `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;
|
moduleSlug?: string;
|
||||||
/** Inline SVG icon as React component */
|
/** Inline SVG icon as React component */
|
||||||
@@ -317,6 +323,29 @@ function XframeIcon({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProxmoxIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||||
|
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||||
|
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
|
||||||
|
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry of all widget types. Used by the widget catalog modal
|
* Registry of all widget types. Used by the widget catalog modal
|
||||||
* and the grid renderer to look up components and constraints.
|
* and the grid renderer to look up components and constraints.
|
||||||
@@ -403,6 +432,17 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
moduleSlug: WIDGET_MODULE_SLUGS.xframe,
|
moduleSlug: WIDGET_MODULE_SLUGS.xframe,
|
||||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
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
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<ServerHealth, number>): 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 };
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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<Extract<ProxmoxMetrics, { productType: 'pve' }>> = {}) {
|
||||||
|
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<ProxmoxServer> & { 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<string, unknown>; isEditMode?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const { ProxmoxWidget } = await import('./proxmox-widget');
|
||||||
|
return render(
|
||||||
|
<ProxmoxWidget
|
||||||
|
instanceId="w-1"
|
||||||
|
config={props.config ?? {}}
|
||||||
|
isEditMode={props.isEditMode ?? false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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;
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full items-center justify-center p-2.5">
|
||||||
|
<p className="text-center text-sm text-muted-foreground">{tp('loadError')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servers === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden p-2.5" aria-busy="true">
|
||||||
|
<div className="h-1.5 w-full shrink-0 animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
|
||||||
|
<span className="sr-only">{tp('loading')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-1.5 p-2.5 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('proxmox.empty')}</p>
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col gap-2 overflow-hidden p-2.5">
|
||||||
|
<HealthBar variant="compact" counts={counts} />
|
||||||
|
<p
|
||||||
|
className={`truncate text-sm font-medium ${summaryColor}`}
|
||||||
|
title={summaryText}
|
||||||
|
data-testid="proxmox-summary"
|
||||||
|
>
|
||||||
|
{summaryText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+36
-10
@@ -6,6 +6,14 @@ import { HEALTH_STYLE } from './status-styles';
|
|||||||
|
|
||||||
interface HealthBarProps {
|
interface HealthBarProps {
|
||||||
counts: Record<ServerHealth, number>;
|
counts: Record<ServerHealth, number>;
|
||||||
|
/**
|
||||||
|
* `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
|
* (down, warn, ok, idle, orphan; leere Zustaende entfallen), darunter die
|
||||||
* Legende „Punkt + Zahl + Wort“. Der Balken liest seine Zusammenfassung als
|
* Legende „Punkt + Zahl + Wort“. Der Balken liest seine Zusammenfassung als
|
||||||
* `role="img"` vor.
|
* `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 t = useTranslations('proxmox');
|
||||||
const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({
|
const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({
|
||||||
health,
|
health,
|
||||||
@@ -24,23 +35,38 @@ export function HealthBar({ counts }: HealthBarProps) {
|
|||||||
}));
|
}));
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
const segments = entries.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.health}
|
||||||
|
data-segment={e.health}
|
||||||
|
className={`h-full min-w-2 first:rounded-l-full last:rounded-r-full ${HEALTH_STYLE[e.health].fill}`}
|
||||||
|
style={{ flexGrow: e.count, flexBasis: 0 }}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
if (variant === 'compact') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="health-bar"
|
||||||
|
data-variant="compact"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-1.5 w-full shrink-0 gap-[3px] overflow-hidden rounded-full"
|
||||||
|
>
|
||||||
|
{segments}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const summary = entries.map((e) => `${e.count} ${e.word}`).join(', ');
|
const summary = entries.map((e) => `${e.count} ${e.word}`).join(', ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2.5" data-testid="health-bar">
|
<div className="space-y-2.5" data-testid="health-bar" data-variant="full">
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={t('legend.barLabel', { summary })}
|
aria-label={t('legend.barLabel', { summary })}
|
||||||
className="flex h-2 w-full gap-[3px] overflow-hidden rounded-full"
|
className="flex h-2 w-full gap-[3px] overflow-hidden rounded-full"
|
||||||
>
|
>
|
||||||
{entries.map((e) => (
|
{segments}
|
||||||
<div
|
|
||||||
key={e.health}
|
|
||||||
data-segment={e.health}
|
|
||||||
className={`h-full min-w-2 first:rounded-l-full last:rounded-r-full ${HEALTH_STYLE[e.health].fill}`}
|
|
||||||
style={{ flexGrow: e.count, flexBasis: 0 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-hidden="true">
|
<ul className="flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-hidden="true">
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
+3
@@ -8,6 +8,9 @@ import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
|
|||||||
* Warnung aus. Die Feldnamen von PBS und PMG sind nur abgeleitet (siehe
|
* Warnung aus. Die Feldnamen von PBS und PMG sind nur abgeleitet (siehe
|
||||||
* ServerCard), ein Fehlalarm aus einem fehlenden Feld waere schlimmer als
|
* ServerCard), ein Fehlalarm aus einem fehlenden Feld waere schlimmer als
|
||||||
* ein ehrliches „unbekannt“.
|
* ein ehrliches „unbekannt“.
|
||||||
|
*
|
||||||
|
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel —
|
||||||
|
* deshalb unter `components/proxmox/` statt im Routenordner der Seite.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Gesamtzustand eines Servers — bestimmt Farbe, Pille und Sortierung. */
|
/** Gesamtzustand eines Servers — bestimmt Farbe, Pille und Sortierung. */
|
||||||
+2
@@ -8,6 +8,8 @@ import type { MeterLevel, ServerHealth } from './proxmox-status';
|
|||||||
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
|
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
|
||||||
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
|
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
|
||||||
* zweischichtiger Schatten, der untere im Statuston getoent.
|
* zweischichtiger Schatten, der untere im Statuston getoent.
|
||||||
|
*
|
||||||
|
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel.
|
||||||
*/
|
*/
|
||||||
export const HEALTH_STYLE: Record<
|
export const HEALTH_STYLE: Record<
|
||||||
ServerHealth,
|
ServerHealth,
|
||||||
@@ -417,6 +417,12 @@
|
|||||||
"zoomLabel": "Vergrößerung der ganzen Seite",
|
"zoomLabel": "Vergrößerung der ganzen Seite",
|
||||||
"zoomOption": "{n} %",
|
"zoomOption": "{n} %",
|
||||||
"readOnly": "Nur anzeigen – Klicks und Scrollen im Rahmen sperren"
|
"readOnly": "Nur anzeigen – Klicks und Scrollen im Rahmen sperren"
|
||||||
|
},
|
||||||
|
"proxmox": {
|
||||||
|
"name": "Proxmox",
|
||||||
|
"description": "Zustand Ihrer Proxmox-Server auf einen Blick",
|
||||||
|
"allOk": "Alles in Ordnung",
|
||||||
|
"empty": "Noch kein Proxmox-Server eingetragen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -417,6 +417,12 @@
|
|||||||
"zoomLabel": "Zoom of the full page",
|
"zoomLabel": "Zoom of the full page",
|
||||||
"zoomOption": "{n} %",
|
"zoomOption": "{n} %",
|
||||||
"readOnly": "View only – block clicks and scrolling inside the frame"
|
"readOnly": "View only – block clicks and scrolling inside the frame"
|
||||||
|
},
|
||||||
|
"proxmox": {
|
||||||
|
"name": "Proxmox",
|
||||||
|
"description": "Health of your Proxmox servers at a glance",
|
||||||
|
"allOk": "All good",
|
||||||
|
"empty": "No Proxmox server added yet."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export const WIDGET_TYPES = [
|
|||||||
'stopwatch',
|
'stopwatch',
|
||||||
'picture-frame',
|
'picture-frame',
|
||||||
'xframe',
|
'xframe',
|
||||||
|
'proxmox',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||||
@@ -114,8 +115,11 @@ export type WidgetType = (typeof WIDGET_TYPES)[number];
|
|||||||
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
|
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
|
||||||
* serverseitig in `DashboardService.getWidgets` (fail-closed).
|
* serverseitig in `DashboardService.getWidgets` (fail-closed).
|
||||||
*
|
*
|
||||||
* Heute bewusst leer: alle neun Kacheln sind Plattform-Kacheln ohne
|
* Die neun Kacheln von vor quick-260924-i8v sind Plattform-Kacheln ohne
|
||||||
* Modulbezug. Die erste modulgebundene Kachel (Proxmox) traegt hier ihren
|
* Modulbezug. Proxmox ist die erste modulgebundene Kachel; ihr Slug ist
|
||||||
* Slug ein.
|
* derselbe wie `@UseModule('proxmox')` im Controller und `slug: 'proxmox'`
|
||||||
|
* in `proxmox.seed.ts`.
|
||||||
*/
|
*/
|
||||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
|
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {
|
||||||
|
proxmox: 'proxmox',
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user