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:
2026-09-24 13:24:51 +02:00
parent 8bfa4fc467
commit a906c6705d
19 changed files with 556 additions and 69 deletions
@@ -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(<WidgetCatalogModal {...baseProps} />);
function catalogTypes(): (string | null)[] {
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
const cards = Array.from(
return Array.from(
dialog.querySelectorAll<HTMLButtonElement>('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(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
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(<WidgetCatalogModal {...baseProps} />);
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
it('zeigt ohne Modulzugriff die neun Plattform-Kacheln ohne Proxmox', () => {
render(<WidgetCatalogModal {...baseProps} />);
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
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(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
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(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
expect(screen.getByRole('button', { name: /Proxmox/ })).toBeInTheDocument();
});
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',
// 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', () => {
@@ -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 (
<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
* 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,
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>
);
}
@@ -0,0 +1,84 @@
'use client';
import { useTranslations } from 'next-intl';
import { HEALTH_ORDER, type ServerHealth } from './proxmox-status';
import { HEALTH_STYLE } from './status-styles';
interface HealthBarProps {
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';
}
/**
* Gesundheitsbalken (quick-260924-h7x) — das eine praegnante Element der
* Seite: ein 8 px hoher, voll gerundeter Balken, anteilig je Zustand geteilt
* (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, variant = 'full' }: HealthBarProps) {
const t = useTranslations('proxmox');
const entries = HEALTH_ORDER.filter((h) => counts[h] > 0).map((health) => ({
health,
count: counts[health],
word: t(`legend.${health}`, { count: counts[health] }),
}));
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(', ');
return (
<div className="space-y-2.5" data-testid="health-bar" data-variant="full">
<div
role="img"
aria-label={t('legend.barLabel', { summary })}
className="flex h-2 w-full gap-[3px] overflow-hidden rounded-full"
>
{segments}
</div>
<ul className="flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-hidden="true">
{entries.map((e) => (
<li key={e.health} className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${HEALTH_STYLE[e.health].fill}`} />
<span>
<span className="font-semibold tabular-nums">{e.count}</span>{' '}
<span className="text-muted-foreground">{e.word}</span>
</span>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,309 @@
import { describe, expect, it } from 'vitest';
import type { ProxmoxMetrics, ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
import {
formatAge,
HEALTH_ORDER,
isBackupStale,
meterLevel,
ratio,
serverHealth,
sortServersByHealth,
summarizeHealth,
THRESHOLDS,
} from './proxmox-status';
/** Fester Bezugszeitpunkt fuer alle Tests: 24.09.2026, 12:00 UTC. */
const NOW = Date.parse('2026-09-24T12:00:00.000Z');
const NOW_S = NOW / 1000;
function makeStatus(overrides: Partial<ProxmoxServerStatus> = {}): ProxmoxServerStatus {
return {
id: 's1',
serverId: 'srv-1',
lastPolledAt: '2026-09-24T11:55:00.000Z',
lastOkAt: '2026-09-24T11:55:00.000Z',
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
updatedAt: '2026-09-24T11:55:00.000Z',
...overrides,
};
}
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
return {
id: 'srv-1',
tenantId: 't1',
name: 'pve-1',
productType: 'pve',
baseUrl: 'https://pve.intern',
authMethod: 'token',
tokenId: 'root@pam!x',
username: null,
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: 5,
position: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: makeStatus(),
...overrides,
};
}
function withMetrics(
metrics: ProxmoxMetrics,
overrides: Partial<ProxmoxServer> = {},
): ProxmoxServer {
return makeServer({
productType: metrics.productType,
status: makeStatus({ metrics }),
...overrides,
});
}
function pve(
nodes: Array<{ cpu: number | null; mem: number | null; maxmem: number | null }>,
storages: Array<{ disk: number | null; maxdisk: number | null }> = [],
): ProxmoxMetrics {
return {
productType: 'pve',
nodeCount: nodes.length,
guestsRunning: 1,
guestsStopped: 0,
nodes: nodes.map((n, i) => ({ node: `n${i}`, maxcpu: 8, ...n })),
storages: storages.map((s, i) => ({ storage: `s${i}`, node: 'n0', ...s })),
};
}
describe('THRESHOLDS', () => {
it('traegt die Schwellen aus dem Plan an EINER Stelle', () => {
expect(THRESHOLDS.usageWarn).toBe(0.8);
expect(THRESHOLDS.usageCrit).toBe(0.92);
expect(THRESHOLDS.backupMaxAgeHours).toBe(26);
});
});
describe('meterLevel', () => {
it('null/undefined/NaN -> unknown (nie ok, nie 0)', () => {
expect(meterLevel(null)).toBe('unknown');
expect(meterLevel(undefined)).toBe('unknown');
expect(meterLevel(Number.NaN)).toBe('unknown');
});
it('unter 0.80 ok, ab 0.80 warn, ab 0.92 crit', () => {
expect(meterLevel(0)).toBe('ok');
expect(meterLevel(0.79)).toBe('ok');
expect(meterLevel(0.8)).toBe('warn');
expect(meterLevel(0.91)).toBe('warn');
expect(meterLevel(0.92)).toBe('crit');
expect(meterLevel(1)).toBe('crit');
});
});
describe('ratio', () => {
it('rechnet used/total', () => {
expect(ratio(25, 100)).toBe(0.25);
});
it('null, wenn einer der Werte fehlt oder total nicht positiv ist', () => {
expect(ratio(null, 100)).toBeNull();
expect(ratio(10, null)).toBeNull();
expect(ratio(10, 0)).toBeNull();
});
});
describe('isBackupStale', () => {
it('null (noch keine Sicherung) ist KEINE Warnung', () => {
expect(isBackupStale(null, NOW)).toBe(false);
});
it('juenger als 26 h ist frisch, aelter ist veraltet', () => {
expect(isBackupStale(NOW_S - 25 * 3600, NOW)).toBe(false);
expect(isBackupStale(NOW_S - 27 * 3600, NOW)).toBe(true);
});
});
describe('serverHealth', () => {
it('deaktivierter Server ist orphan — Vorrang vor allem, auch mit alten Messwerten und Fehler', () => {
expect(serverHealth(makeServer({ isActive: false }), NOW)).toBe('orphan');
expect(
serverHealth(
makeServer({
isActive: false,
status: makeStatus({ reachable: false, errorKind: 'netz' }),
}),
NOW,
),
).toBe('orphan');
expect(
serverHealth(
withMetrics(pve([{ cpu: 0.99, mem: 99, maxmem: 100 }]), { isActive: false }),
NOW,
),
).toBe('orphan');
expect(serverHealth(makeServer({ isActive: false, status: null }), NOW)).toBe('orphan');
});
it('kein Zwischenlager oder noch nie abgefragt -> idle', () => {
expect(serverHealth(makeServer({ status: null }), NOW)).toBe('idle');
expect(
serverHealth(
makeServer({ status: makeStatus({ lastPolledAt: null, reachable: false }) }),
NOW,
),
).toBe('idle');
});
it('abgefragt, aber nicht erreichbar -> down', () => {
expect(
serverHealth(
makeServer({ status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
NOW,
),
).toBe('down');
});
it('erreichbar ohne Messwerte -> ok', () => {
expect(serverHealth(makeServer(), NOW)).toBe('ok');
});
it('PVE: Prozessor, Arbeitsspeicher oder Datenspeicher ab 0.80 -> warn', () => {
expect(serverHealth(withMetrics(pve([{ cpu: 0.2, mem: 10, maxmem: 100 }])), NOW)).toBe('ok');
expect(serverHealth(withMetrics(pve([{ cpu: 0.85, mem: 10, maxmem: 100 }])), NOW)).toBe('warn');
expect(serverHealth(withMetrics(pve([{ cpu: 0.1, mem: 80, maxmem: 100 }])), NOW)).toBe('warn');
expect(
serverHealth(
withMetrics(pve([{ cpu: 0.1, mem: 10, maxmem: 100 }], [{ disk: 95, maxdisk: 100 }])),
NOW,
),
).toBe('warn');
});
it('PVE: unbekannte Werte (null) erzeugen NIE eine Warnung', () => {
expect(
serverHealth(
withMetrics(pve([{ cpu: null, mem: null, maxmem: null }], [{ disk: null, maxdisk: null }])),
NOW,
),
).toBe('ok');
});
it('PBS: Fuellstand ab 0.80, Sicherung aelter als 26 h, Pruefung nicht ok -> warn', () => {
const base = {
name: 'ds',
total: 100,
used: 10,
free: 90,
lastBackupAt: NOW_S - 3600,
lastVerifyState: 'ok',
};
const pbs = (ds: Partial<typeof base> & Record<string, unknown>): ProxmoxMetrics => ({
productType: 'pbs',
datastores: [{ ...base, ...ds } as never],
});
expect(serverHealth(withMetrics(pbs({})), NOW)).toBe('ok');
expect(serverHealth(withMetrics(pbs({ used: 85 })), NOW)).toBe('warn');
expect(serverHealth(withMetrics(pbs({ lastBackupAt: NOW_S - 30 * 3600 })), NOW)).toBe('warn');
expect(serverHealth(withMetrics(pbs({ lastVerifyState: 'failed' })), NOW)).toBe('warn');
});
it('PBS: null bei Belegung, Sicherung oder Pruefung erzeugt KEINE Warnung', () => {
const metrics: ProxmoxMetrics = {
productType: 'pbs',
datastores: [
{
name: 'ds',
total: null,
used: null,
free: null,
lastBackupAt: null,
lastVerifyState: null,
},
],
};
expect(serverHealth(withMetrics(metrics), NOW)).toBe('ok');
});
it('PMG: Viren > 0 -> warn, 0 oder null -> ok', () => {
const pmg = (virusCount: number | null): ProxmoxMetrics => ({
productType: 'pmg',
countIn: 1,
countOut: 1,
spamCount: 1,
virusCount,
});
expect(serverHealth(withMetrics(pmg(2)), NOW)).toBe('warn');
expect(serverHealth(withMetrics(pmg(0)), NOW)).toBe('ok');
expect(serverHealth(withMetrics(pmg(null)), NOW)).toBe('ok');
});
});
describe('sortServersByHealth', () => {
it('ordnet down, warn, ok, idle, orphan — innerhalb eines Zustands nach position', () => {
const servers = [
makeServer({ id: 'orphan', isActive: false, position: 0 }),
makeServer({ id: 'ok-2', position: 5 }),
makeServer({ id: 'idle', status: null, position: 1 }),
makeServer({ id: 'ok-1', position: 2 }),
makeServer({ id: 'down', status: makeStatus({ reachable: false }), position: 9 }),
withMetrics(pve([{ cpu: 0.9, mem: 1, maxmem: 10 }]), { id: 'warn', position: 7 }),
];
expect(sortServersByHealth(servers, NOW).map((s) => s.id)).toEqual([
'down',
'warn',
'ok-1',
'ok-2',
'idle',
'orphan',
]);
});
it('veraendert die uebergebene Liste nicht', () => {
const servers = [makeServer({ id: 'a', isActive: false }), makeServer({ id: 'b' })];
sortServersByHealth(servers, NOW);
expect(servers.map((s) => s.id)).toEqual(['a', 'b']);
});
});
describe('summarizeHealth', () => {
it('zaehlt je Zustand, in der Reihenfolge von HEALTH_ORDER', () => {
const servers = [
makeServer({ id: 'a' }),
makeServer({ id: 'b' }),
makeServer({ id: 'c', isActive: false }),
makeServer({ id: 'd', status: makeStatus({ reachable: false }) }),
];
expect(HEALTH_ORDER).toEqual(['down', 'warn', 'ok', 'idle', 'orphan']);
expect(summarizeHealth(servers, NOW)).toEqual({ down: 1, warn: 0, ok: 2, idle: 0, orphan: 1 });
});
});
describe('formatAge', () => {
it('null oder ungueltig -> null', () => {
expect(formatAge(null, NOW)).toBeNull();
expect(formatAge('kein Datum', NOW)).toBeNull();
});
it('ISO-Zeichenkette: Minuten, Stunden, Tage auf Deutsch', () => {
expect(formatAge('2026-09-24T11:57:00.000Z', NOW)).toBe('vor 3 Min.');
expect(formatAge('2026-09-24T07:00:00.000Z', NOW)).toBe('vor 5 Std.');
expect(formatAge('2026-09-22T12:00:00.000Z', NOW)).toBe('vor 2 Tagen');
});
it('Zahl gilt als Unix-Sekunden (PBS lastBackupAt)', () => {
expect(formatAge(NOW_S - 5 * 3600, NOW)).toBe('vor 5 Std.');
});
it('unter einer Minute und leichte Uhrabweichung in die Zukunft -> "jetzt"', () => {
expect(formatAge('2026-09-24T11:59:40.000Z', NOW)).toBe('jetzt');
expect(formatAge('2026-09-24T12:00:30.000Z', NOW)).toBe('jetzt');
});
it('englisch ueber den Sprachparameter', () => {
expect(formatAge('2026-09-24T11:57:00.000Z', NOW, 'en')).toBe('3 min. ago');
});
});
@@ -0,0 +1,161 @@
import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api';
/**
* Statuslogik der Proxmox-Seite (quick-260924-h7x) — reine Funktionen ohne
* React, damit jede Verzweigung einzeln pruefbar ist.
*
* Grundregel: ein UNBEKANNTER Wert (`null`) ist nie „0“ und loest nie eine
* Warnung aus. Die Feldnamen von PBS und PMG sind nur abgeleitet (siehe
* ServerCard), ein Fehlalarm aus einem fehlenden Feld waere schlimmer als
* 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. */
export type ServerHealth = 'ok' | 'warn' | 'down' | 'idle' | 'orphan';
/** Zustand eines einzelnen Balkens. */
export type MeterLevel = 'ok' | 'warn' | 'crit' | 'unknown';
/** Alle Schwellen an EINER Stelle. Auslastungen sind Anteile (0..1). */
export const THRESHOLDS = {
/** Last, Arbeitsspeicher, Datenspeicher: ab hier Warnung. */
usageWarn: 0.8,
/** Ab hier kritisch (nur fuer die Balkenfarbe). */
usageCrit: 0.92,
/** PBS: letzte Sicherung aelter als so viele Stunden -> Warnung. */
backupMaxAgeHours: 26,
} as const;
/** Reihenfolge fuer Sortierung, Gesundheitsbalken und Legende. */
export const HEALTH_ORDER: readonly ServerHealth[] = ['down', 'warn', 'ok', 'idle', 'orphan'];
function isKnownNumber(value: number | null | undefined): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}
/** Balkenstufe aus einem Anteil (0..1); `null` bleibt `unknown`. */
export function meterLevel(fraction: number | null | undefined): MeterLevel {
if (!isKnownNumber(fraction)) return 'unknown';
if (fraction >= THRESHOLDS.usageCrit) return 'crit';
if (fraction >= THRESHOLDS.usageWarn) return 'warn';
return 'ok';
}
/** `used / total` — `null`, sobald ein Wert fehlt oder `total` nicht positiv ist. */
export function ratio(
used: number | null | undefined,
total: number | null | undefined,
): number | null {
if (!isKnownNumber(used) || !isKnownNumber(total) || total <= 0) return null;
return used / total;
}
/**
* Zeitpunkt in Millisekunden. Zahlen sind Unix-SEKUNDEN (so liefert PBS
* `lastBackupAt`), Zeichenketten ISO-Zeitpunkte (Zwischenlager).
*/
export function toEpochMs(value: number | string | null | undefined): number | null {
if (value === null || value === undefined) return null;
const ms = typeof value === 'number' ? value * 1000 : Date.parse(value);
return Number.isNaN(ms) ? null : ms;
}
/** Letzte PBS-Sicherung aelter als {@link THRESHOLDS.backupMaxAgeHours}? `null` (noch nie) ist KEINE Warnung. */
export function isBackupStale(
lastBackupAt: number | null | undefined,
now: number = Date.now(),
): boolean {
const ms = toEpochMs(lastBackupAt);
if (ms === null) return false;
return now - ms > THRESHOLDS.backupMaxAgeHours * 3600 * 1000;
}
function isHigh(fraction: number | null): boolean {
return fraction !== null && fraction >= THRESHOLDS.usageWarn;
}
/** Liegt irgendein Messwert ueber seiner Warnschwelle? */
function metricsNeedAttention(metrics: ProxmoxMetrics, now: number): boolean {
if (metrics.productType === 'pve') {
const nodeWarn = metrics.nodes.some(
(n) => isHigh(isKnownNumber(n.cpu) ? n.cpu : null) || isHigh(ratio(n.mem, n.maxmem)),
);
const storageWarn = metrics.storages.some((s) => isHigh(ratio(s.disk, s.maxdisk)));
return nodeWarn || storageWarn;
}
if (metrics.productType === 'pbs') {
return metrics.datastores.some(
(ds) =>
isHigh(ratio(ds.used, ds.total)) ||
isBackupStale(ds.lastBackupAt, now) ||
(ds.lastVerifyState !== null && ds.lastVerifyState !== 'ok'),
);
}
return isKnownNumber(metrics.virusCount) && metrics.virusCount > 0;
}
/**
* Gesamtzustand eines Servers. Reihenfolge der Pruefung ist Absicht:
* ein deaktivierter Server ist „offline & verwaist“, egal was im
* Zwischenlager noch steht.
*/
export function serverHealth(server: ProxmoxServer, now: number = Date.now()): ServerHealth {
if (!server.isActive) return 'orphan';
const status = server.status;
if (!status || status.lastPolledAt === null) return 'idle';
if (!status.reachable) return 'down';
if (status.metrics && metricsNeedAttention(status.metrics, now)) return 'warn';
return 'ok';
}
/** Neue Liste: down, warn, ok, idle, orphan — innerhalb eines Zustands nach `position`. */
export function sortServersByHealth(
servers: readonly ProxmoxServer[],
now: number = Date.now(),
): ProxmoxServer[] {
const rank = new Map(servers.map((s) => [s.id, HEALTH_ORDER.indexOf(serverHealth(s, now))]));
return [...servers].sort(
(a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0) || a.position - b.position,
);
}
/** Anzahl Server je Zustand (alle Zustaende vorhanden, auch mit 0). */
export function summarizeHealth(
servers: readonly ProxmoxServer[],
now: number = Date.now(),
): Record<ServerHealth, number> {
const counts: Record<ServerHealth, number> = { down: 0, warn: 0, ok: 0, idle: 0, orphan: 0 };
for (const server of servers) counts[serverHealth(server, now)]++;
return counts;
}
/**
* Relative Zeitangabe („vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“) ueber
* `Intl.RelativeTimeFormat` — keine zusaetzliche Abhaengigkeit. Unter einer
* Minute (und bei leichter Uhrabweichung in die Zukunft) „jetzt“.
* `null`, wenn der Zeitpunkt fehlt oder ungueltig ist.
*/
export function formatAge(
value: number | string | null | undefined,
now: number = Date.now(),
locale = 'de',
): string | null {
const ms = toEpochMs(value);
if (ms === null) return null;
const seconds = Math.max(0, Math.round((now - ms) / 1000));
if (seconds < 60) {
return new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'auto' }).format(
0,
'second',
);
}
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'always' });
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return rtf.format(-minutes, 'minute');
const hours = Math.floor(minutes / 60);
if (hours < 24) return rtf.format(-hours, 'hour');
return rtf.format(-Math.floor(hours / 24), 'day');
}
@@ -0,0 +1,67 @@
import type { MeterLevel, ServerHealth } from './proxmox-status';
/**
* Tailwind-Klassen je Zustand (quick-260924-h7x). Bewusst als vollstaendige,
* statische Zeichenketten — Tailwind findet Klassen nur, wenn sie woertlich im
* Quelltext stehen, zusammengesetzte Namen wie `bg-status-${x}` gingen verloren.
*
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
* zweischichtiger Schatten, der untere im Statuston getoent.
*
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel.
*/
export const HEALTH_STYLE: Record<
ServerHealth,
{ fill: string; pill: string; text: string; shadow: string }
> = {
ok: {
fill: 'bg-status-ok',
pill: 'bg-status-ok/12 text-status-ok-fg',
text: 'text-status-ok-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-ok)]',
},
warn: {
fill: 'bg-status-warn',
pill: 'bg-status-warn/12 text-status-warn-fg',
text: 'text-status-warn-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-warn)]',
},
down: {
fill: 'bg-status-down',
pill: 'bg-status-down/12 text-status-down-fg',
text: 'text-status-down-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-down)]',
},
idle: {
fill: 'bg-status-idle',
pill: 'bg-status-idle/12 text-status-idle-fg',
text: 'text-status-idle-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-idle)]',
},
// Kein farbiger Schatten: ein verwaister Server soll nicht „leuchten“.
orphan: {
fill: 'bg-status-orphan',
pill: 'bg-status-orphan/12 text-status-orphan-fg',
text: 'text-status-orphan-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.04)]',
},
};
/** Balkenfarbe je Stufe; `unknown` wird schraffiert gezeichnet (siehe Meter). */
export const METER_FILL: Record<Exclude<MeterLevel, 'unknown'>, string> = {
ok: 'bg-status-ok',
warn: 'bg-status-warn',
crit: 'bg-status-down',
};
/** Schriftfarbe der Prozentzahl je Stufe. */
export const METER_TEXT: Record<MeterLevel, string> = {
ok: 'text-foreground',
warn: 'text-status-warn-fg',
crit: 'text-status-down-fg',
unknown: 'text-muted-foreground',
};
/** Eingelassenes Messfeld („Well“). */
export const WELL = 'rounded-lg bg-well shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]';