feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog
- Kachel mit Zählern und roten/gelben Clouds, nur mit Modulzugriff sichtbar (geteilte Widget-Typen, Modulzuordnung, Katalog) - Anleitung für Anwender und Administratoren, Eintrag im Changelog Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@ vi.mock('next-intl', () => ({
|
||||
'xframe.description': 'Webseite einbetten',
|
||||
'proxmox.name': 'Proxmox',
|
||||
'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick',
|
||||
'nextcloudStatus.name': 'Nextcloud-Status',
|
||||
'nextcloudStatus.description': 'Ampelübersicht Ihrer Nextcloud-Clouds',
|
||||
},
|
||||
common: {
|
||||
close: 'Schließen',
|
||||
@@ -44,7 +46,8 @@ import { WidgetCatalogModal } from './widget-catalog-modal';
|
||||
|
||||
// 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'.
|
||||
// auf das Modul 'proxmox', die Nextcloud-Status-Kachel (quick-261002-k67) nur
|
||||
// mit Zugriff auf das Modul 'nextcloud-status'.
|
||||
const baseProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
@@ -151,18 +154,33 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)',
|
||||
).map((c) => c.getAttribute('data-widget-type'));
|
||||
}
|
||||
|
||||
it("zeigt mit Zugriff auf 'proxmox' alle elf Kacheln in der Reihenfolge der Registry, Erinnerungen zuletzt", () => {
|
||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||
it("zeigt mit Zugriff auf beide Module alle zwoelf Kacheln in der Reihenfolge der Registry, Nextcloud-Status zuletzt", () => {
|
||||
render(
|
||||
<WidgetCatalogModal
|
||||
{...baseProps}
|
||||
accessibleModuleSlugs={['proxmox', 'nextcloud-status']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
|
||||
expect(catalogTypes().at(-1)).toBe('reminder');
|
||||
expect(catalogTypes().at(-1)).toBe('nextcloud-status');
|
||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('zeigt ohne Modulzugriff die neun Plattform-Kacheln ohne Proxmox', () => {
|
||||
it('zeigt ohne Modulzugriff die zehn Plattform-Kacheln ohne die beiden Modul-Kacheln', () => {
|
||||
render(<WidgetCatalogModal {...baseProps} />);
|
||||
|
||||
expect(catalogTypes()).toEqual(WIDGET_TYPES.filter((type) => type !== 'proxmox'));
|
||||
expect(catalogTypes()).toEqual(
|
||||
WIDGET_TYPES.filter((type) => type !== 'proxmox' && type !== 'nextcloud-status'),
|
||||
);
|
||||
});
|
||||
|
||||
it("mit Zugriff nur auf 'nextcloud-status' kommt nur diese Modul-Kachel dazu, Proxmox bleibt aus", () => {
|
||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['nextcloud-status']} />);
|
||||
|
||||
expect(catalogTypes()).toContain('nextcloud-status');
|
||||
expect(catalogTypes()).not.toContain('proxmox');
|
||||
expect(screen.getByRole('button', { name: /Nextcloud-Status/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
||||
'proxmox',
|
||||
// Erinnerungen (quick-260929-if2)
|
||||
'reminder',
|
||||
// Nextcloud-Status — zweite Modul-Kachel (quick-261002-k67)
|
||||
'nextcloud-status',
|
||||
];
|
||||
|
||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
@@ -72,7 +74,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; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf 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; quick-260929-if2: Erinnerungen dazu; quick-261002-k67: Nextcloud-Status dazu, zwoelf Typen)', () => {
|
||||
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
|
||||
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
|
||||
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
|
||||
@@ -90,6 +92,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
|
||||
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
|
||||
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
|
||||
'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 },
|
||||
});
|
||||
|
||||
let counted = 0;
|
||||
@@ -99,7 +102,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
expect(counted).toBe(44);
|
||||
expect(counted).toBe(48);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,7 +150,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('die elf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||
it('die zwoelf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
|
||||
});
|
||||
|
||||
@@ -157,10 +160,12 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("nur proxmox traegt moduleSlug 'proxmox', alle anderen Kacheln keinen (quick-260924-i8v)", () => {
|
||||
it("nur proxmox und nextcloud-status tragen einen moduleSlug (gleichnamig), alle anderen Kacheln keinen (quick-260924-i8v, quick-261002-k67)", () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
if (type === 'proxmox') {
|
||||
expect(WIDGET_REGISTRY[type].moduleSlug).toBe('proxmox');
|
||||
} else if (type === 'nextcloud-status') {
|
||||
expect(WIDGET_REGISTRY[type].moduleSlug).toBe('nextcloud-status');
|
||||
} else {
|
||||
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
|
||||
}
|
||||
@@ -229,14 +234,22 @@ describe('visibleWidgetTypes (quick-260922-m1h)', () => {
|
||||
note: {},
|
||||
};
|
||||
|
||||
it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlt nur Proxmox', () => {
|
||||
it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlen nur die beiden Modul-Kacheln', () => {
|
||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual(
|
||||
WIDGET_TYPES.filter((type) => type !== 'proxmox'),
|
||||
WIDGET_TYPES.filter((type) => type !== 'proxmox' && type !== 'nextcloud-status'),
|
||||
);
|
||||
});
|
||||
|
||||
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("mit Zugriff auf beide Module erscheinen alle zwoelf Kacheln in WIDGET_TYPES-Reihenfolge (quick-260924-i8v, quick-261002-k67)", () => {
|
||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, ['proxmox', 'nextcloud-status'])).toEqual([
|
||||
...WIDGET_TYPES,
|
||||
]);
|
||||
});
|
||||
|
||||
it("mit Zugriff nur auf 'nextcloud-status' kommt nur diese Modul-Kachel dazu (quick-261002-k67)", () => {
|
||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, ['nextcloud-status'])).toEqual(
|
||||
WIDGET_TYPES.filter((type) => type !== 'proxmox'),
|
||||
);
|
||||
});
|
||||
|
||||
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ComponentType } from 'react';
|
||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
|
||||
* reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2).
|
||||
* nextcloud-status: Ampeluebersicht der Nextcloud-Clouds, zweite Modul-Kachel (quick-261002-k67).
|
||||
*/
|
||||
export type { WidgetType };
|
||||
|
||||
@@ -102,6 +103,12 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 =
|
||||
// rund 340x270 px: drei bis vier Erinnerungen auf einmal.
|
||||
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
|
||||
// quick-261002-k67: Vorbild Proxmox. 4 Zeilen = 104 px reichen fuer die
|
||||
// drei Zaehler (Rahmenkopf plus Zaehlerzeile); darunter blendet sich die
|
||||
// Liste der roten/gelben Clouds per Container-Query aus. 12x8 (im 48er-
|
||||
// Raster) = rund 340x216 px: Zaehler und etwa drei Clouds mit Grund.
|
||||
// 6 Spalten = rund 166 px zeigen nur die Zaehler.
|
||||
'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,7 +139,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 Zustand fuer alle Kacheln ausser Proxmox.
|
||||
* und immer sichtbar — der Zustand fuer alle Kacheln ausser Proxmox und Nextcloud-Status.
|
||||
*/
|
||||
moduleSlug?: string;
|
||||
/** Inline SVG icon as React component */
|
||||
@@ -410,6 +417,26 @@ function ReminderIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NextcloudStatusIcon({ 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}
|
||||
>
|
||||
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all widget types. Used by the widget catalog modal
|
||||
* and the grid renderer to look up components and constraints.
|
||||
@@ -516,6 +543,17 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.reminder,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
'nextcloud-status': {
|
||||
type: 'nextcloud-status',
|
||||
nameKey: 'nextcloudStatus.name',
|
||||
descriptionKey: 'nextcloudStatus.description',
|
||||
icon: NextcloudStatusIcon,
|
||||
...WIDGET_CONSTRAINTS['nextcloud-status'],
|
||||
// Zweite Modul-Kachel: ohne Zugriff auf das Modul 'nextcloud-status' fehlt
|
||||
// sie im Katalog (Komfort) und verbindlich serverseitig auf dem Dashboard.
|
||||
moduleSlug: WIDGET_MODULE_SLUGS['nextcloud-status'],
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { cleanup, render as rtlRender, screen, waitFor, within } 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 { NextcloudInstance, NextcloudList, RatingLevel } from '@/lib/nextcloud-status-api';
|
||||
import de from '@/messages/de.json';
|
||||
import { NextcloudStatusWidget } from './nextcloud-status-widget';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockListInstances = vi.fn();
|
||||
const mockCheckAll = vi.fn();
|
||||
const mockCheckOne = vi.fn();
|
||||
|
||||
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
|
||||
return {
|
||||
...actual,
|
||||
listInstances: (...a: unknown[]) => mockListInstances(...a),
|
||||
checkAll: (...a: unknown[]) => mockCheckAll(...a),
|
||||
checkOne: (...a: unknown[]) => mockCheckOne(...a),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
function make(
|
||||
id: string,
|
||||
customerName: string,
|
||||
level: RatingLevel,
|
||||
reason: NextcloudInstance['rating']['reason'] = 'current',
|
||||
updateTo: string | null = null,
|
||||
): NextcloudInstance {
|
||||
return {
|
||||
id,
|
||||
customerName,
|
||||
baseUrl: `https://${id}.example.de`,
|
||||
logoUrl: null,
|
||||
hasUploadedLogo: false,
|
||||
logoVersion: 0,
|
||||
status: {
|
||||
checkedAt: null,
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
},
|
||||
rating: { level, reason, updateTo, eolDate: null, cycle: 35 },
|
||||
};
|
||||
}
|
||||
|
||||
function list(instances: NextcloudInstance[]): NextcloudList {
|
||||
return { instances, reference: { newestVersion: '35.0.1', fetchedAt: null } };
|
||||
}
|
||||
|
||||
const props = { instanceId: 'w1', config: {}, isEditMode: false };
|
||||
|
||||
describe('NextcloudStatusWidget', () => {
|
||||
beforeEach(() => {
|
||||
mockListInstances.mockReset();
|
||||
mockCheckAll.mockReset();
|
||||
mockCheckOne.mockReset();
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('zeigt Zähler 2/1/1 und Rot vor Gelb mit Name und Grund', async () => {
|
||||
mockListInstances.mockResolvedValue(
|
||||
list([
|
||||
make('g1', 'Grün A', 'green'),
|
||||
make('g2', 'Grün B', 'green'),
|
||||
make('y1', 'Gelb A', 'yellow', 'update-available', '34.0.4'),
|
||||
make('r1', 'Rot A', 'red', 'maintenance'),
|
||||
]),
|
||||
);
|
||||
render(<NextcloudStatusWidget {...props} />);
|
||||
await screen.findByTestId('nextcloud-counters');
|
||||
expect(screen.getByTestId('nextcloud-count-green')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('nextcloud-count-yellow')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('nextcloud-count-red')).toHaveTextContent('1');
|
||||
expect(screen.queryByTestId('nextcloud-count-unknown')).toBeNull();
|
||||
|
||||
const rows = screen.getAllByTestId('nextcloud-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(within(rows[0]).getByText('Rot A')).toBeInTheDocument();
|
||||
expect(within(rows[0]).getByText('Wartungsmodus')).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText('Gelb A')).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText('Update auf 34.0.4 verfügbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('alles grün: ruhige Zeile, keine Liste', async () => {
|
||||
mockListInstances.mockResolvedValue(list([make('g1', 'Grün A', 'green')]));
|
||||
render(<NextcloudStatusWidget {...props} />);
|
||||
expect(await screen.findByText('Alle Clouds sind aktuell')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('nextcloud-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('ohne Clouds ein Hinweis', async () => {
|
||||
mockListInstances.mockResolvedValue(list([]));
|
||||
render(<NextcloudStatusWidget {...props} />);
|
||||
expect(await screen.findByText('Noch keine Cloud eingetragen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Ladefehler zeigt den Fehlersatz', async () => {
|
||||
mockListInstances.mockRejectedValue(new Error('boom'));
|
||||
render(<NextcloudStatusWidget {...props} />);
|
||||
expect(await screen.findByText('Status konnte nicht geladen werden')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('zählt Clouds ohne Bewertung getrennt, aber nur wenn es welche gibt', async () => {
|
||||
mockListInstances.mockResolvedValue(
|
||||
list([make('g1', 'Grün A', 'green'), make('u1', 'Grau A', 'unknown', 'no-reference')]),
|
||||
);
|
||||
render(<NextcloudStatusWidget {...props} />);
|
||||
await screen.findByTestId('nextcloud-counters');
|
||||
expect(screen.getByTestId('nextcloud-count-unknown')).toHaveTextContent('1');
|
||||
// Mit einer unbewerteten Cloud gilt „Alle aktuell“ nicht.
|
||||
expect(screen.queryByText('Alle Clouds sind aktuell')).toBeNull();
|
||||
});
|
||||
|
||||
it('Ansichtsmodus: Zeilen und Zählerbereich führen zur Modulseite', async () => {
|
||||
mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')]));
|
||||
render(<NextcloudStatusWidget {...props} />);
|
||||
await screen.findByTestId('nextcloud-list');
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links.length).toBeGreaterThanOrEqual(2);
|
||||
for (const link of links) expect(link).toHaveAttribute('href', '/modules/nextcloud-status');
|
||||
});
|
||||
|
||||
it('Bearbeitungsmodus: keine Links', async () => {
|
||||
mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')]));
|
||||
render(<NextcloudStatusWidget {...props} isEditMode />);
|
||||
await screen.findByTestId('nextcloud-list');
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
expect(screen.getByText('Rot A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ruft nie eine Prüfung auf, nur die Leseabfrage', async () => {
|
||||
mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')]));
|
||||
render(<NextcloudStatusWidget {...props} />);
|
||||
await waitFor(() => expect(mockListInstances).toHaveBeenCalledTimes(1));
|
||||
await screen.findByTestId('nextcloud-list');
|
||||
expect(mockCheckAll).not.toHaveBeenCalled();
|
||||
expect(mockCheckOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { RATING_STYLE, ratingReasonText } from '@/components/nextcloud-status/rating-display';
|
||||
import { sortClouds } from '@/components/nextcloud-status/sort-clouds';
|
||||
// Aus dem Klienten NUR die Leseabfrage (Muster T-I8V-02): die Pruefungen
|
||||
// (`checkAll`/`checkOne`) gehoeren der Modulseite und duerfen hier nie
|
||||
// auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen bei den
|
||||
// Clouds aus.
|
||||
import { listInstances, type NextcloudList, type RatingLevel } from '@/lib/nextcloud-status-api';
|
||||
|
||||
/** Fuenf Minuten: so oft liest die Kachel den gespeicherten Stand neu (die Pruefung selbst laeuft stuendlich). */
|
||||
const REFRESH_MS = 5 * 60_000;
|
||||
|
||||
const ROW_CLASS = 'flex items-center gap-2 rounded-md px-1.5 py-1 text-sm';
|
||||
const MODULE_HREF = '/modules/nextcloud-status';
|
||||
|
||||
/**
|
||||
* NextcloudStatusWidget (quick-261002-k67, L-10) — Ampeluebersicht: drei
|
||||
* Zaehler (gruen/gelb/rot, „ohne Bewertung“ nur wenn es solche gibt) und
|
||||
* darunter die roten, dann gelben Clouds mit Name und Grund.
|
||||
*
|
||||
* - Liest ausschliesslich `GET /modules/nextcloud-status/instances`
|
||||
* (serverseitig hinter `@UseModule`), alle 5 Minuten neu; bei verborgenem
|
||||
* Browser-Tab pausiert der Takt, beim Zurueckkehren laedt die Kachel sofort.
|
||||
* - Groessenstufen per Container-Query am Kachelrumpf des Wrappers (der ist
|
||||
* bereits `@container-size`): niedrig oder schmal zeigt nur die Zaehler.
|
||||
* - Ansichtsmodus: Zaehlerbereich und Zeilen fuehren zur Modulseite.
|
||||
* Bearbeitungsmodus: Zeilen ohne Ziel und ohne Tabstopp — Links stehen im
|
||||
* Abbruch-Selektor von `dashboard-grid.tsx`, Anker-Zeilen wuerden das
|
||||
* Ziehen ueber fast die ganze Kachel blockieren (wie bei Proxmox).
|
||||
* - Die Kopfzeile (Symbol + Name) setzt der Rahmen (`FRAME_HEADER_TYPES`).
|
||||
*/
|
||||
export function NextcloudStatusWidget({ isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('nextcloudStatus');
|
||||
const locale = useLocale();
|
||||
|
||||
const [data, setData] = useState<NextcloudList | null>(null);
|
||||
// Fehler als Flag, nicht als Text: `t` gehoert nicht in die Effekt-Abhaengigkeiten.
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
function load() {
|
||||
listInstances()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setData(list);
|
||||
setLoadFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Ein schon geladener Stand bleibt stehen; der Fehlersatz erscheint nur, solange es noch nie einen gab.
|
||||
if (!cancelled) setLoadFailed(true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible') load();
|
||||
}
|
||||
|
||||
load();
|
||||
const intervalId = setInterval(() => {
|
||||
if (document.visibilityState !== 'hidden') load();
|
||||
}, REFRESH_MS);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result: Record<RatingLevel, number> = { green: 0, yellow: 0, red: 0, unknown: 0 };
|
||||
for (const instance of data?.instances ?? []) result[instance.rating.level] += 1;
|
||||
return result;
|
||||
}, [data]);
|
||||
|
||||
const attention = useMemo(
|
||||
() =>
|
||||
sortClouds(
|
||||
(data?.instances ?? []).filter(
|
||||
(i) => i.rating.level === 'red' || i.rating.level === 'yellow',
|
||||
),
|
||||
'status',
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
function frame(body: ReactNode, bodyClassName = 'gap-2') {
|
||||
return (
|
||||
<div data-testid="nextcloud-status-widget" className="flex h-full flex-col overflow-hidden">
|
||||
<div className={`flex min-h-0 flex-1 flex-col p-2.5 ${bodyClassName}`}>{body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function centered(text: string) {
|
||||
return frame(
|
||||
<p className="text-sm text-muted-foreground">{text}</p>,
|
||||
'items-center justify-center gap-1.5 text-center',
|
||||
);
|
||||
}
|
||||
|
||||
if (data === null && loadFailed) return centered(t('widget.loadError'));
|
||||
|
||||
if (data === null) {
|
||||
return frame(
|
||||
<div aria-busy="true">
|
||||
<div className="h-6 w-full animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
|
||||
<span className="sr-only">{t('widget.loading')}</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (data.instances.length === 0) return centered(t('widget.empty'));
|
||||
|
||||
const chips: RatingLevel[] = ['green', 'yellow', 'red'];
|
||||
if (counts.unknown > 0) chips.push('unknown');
|
||||
|
||||
const counters = (
|
||||
<ul
|
||||
data-testid="nextcloud-counters"
|
||||
aria-label={t('widget.countsLabel')}
|
||||
className="flex shrink-0 flex-wrap gap-1.5"
|
||||
>
|
||||
{chips.map((level) => (
|
||||
<li
|
||||
key={level}
|
||||
data-level={level}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${RATING_STYLE[level].pill}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full ${RATING_STYLE[level].fill}`}
|
||||
/>
|
||||
<span className="tabular-nums" data-testid={`nextcloud-count-${level}`}>
|
||||
{counts[level]}
|
||||
</span>
|
||||
{/* Die Ampelfarbe haengt nie nur an der Farbe: das Wort steht immer dabei. */}
|
||||
<span>{t(`level.${level}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
function row(instance: (typeof attention)[number]): ReactNode {
|
||||
const style = RATING_STYLE[instance.rating.level];
|
||||
const content = (
|
||||
<>
|
||||
<span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${style.fill}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{instance.customerName}</span>
|
||||
<span
|
||||
className={`shrink-0 truncate text-right text-xs @max-[15rem]:hidden ${style.text}`}
|
||||
data-testid="nextcloud-row-reason"
|
||||
>
|
||||
{ratingReasonText(t, instance.rating, locale)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={instance.id} data-testid="nextcloud-row" data-level={instance.rating.level}>
|
||||
{isEditMode ? (
|
||||
<div className={ROW_CLASS}>{content}</div>
|
||||
) : (
|
||||
<Link
|
||||
href={MODULE_HREF}
|
||||
className={`${ROW_CLASS} transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const allGood = counts.yellow === 0 && counts.red === 0 && counts.unknown === 0;
|
||||
|
||||
return frame(
|
||||
<>
|
||||
{isEditMode ? (
|
||||
counters
|
||||
) : (
|
||||
<Link
|
||||
href={MODULE_HREF}
|
||||
aria-label={t('widget.open')}
|
||||
className="rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{counters}
|
||||
</Link>
|
||||
)}
|
||||
{allGood ? (
|
||||
<p className="shrink-0 truncate text-sm font-medium text-status-ok-fg [@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden">
|
||||
{t('widget.allGood')}
|
||||
</p>
|
||||
) : (
|
||||
attention.length > 0 && (
|
||||
<ul
|
||||
data-testid="nextcloud-list"
|
||||
className="-mx-1 min-h-0 flex-1 overflow-y-auto [@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden"
|
||||
>
|
||||
{attention.map((instance) => row(instance))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ const PATHS: Record<string, ReactNode> = {
|
||||
<path d="M6 6h.01M6 18h.01" />
|
||||
</>
|
||||
),
|
||||
'nextcloud-status': <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
|
||||
reminder: (
|
||||
<>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
|
||||
@@ -9,12 +9,20 @@ import { WidgetIcon } from './widget-icon';
|
||||
/**
|
||||
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
|
||||
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2; Erinnerungen seit
|
||||
* quick-260929-if2). Notiz,
|
||||
* quick-260929-if2; Nextcloud-Status seit quick-261002-k67 — die Kachel hat
|
||||
* keinen eigenen Titel, nur Zaehler und Liste). Notiz,
|
||||
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
|
||||
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
|
||||
* ein Foto braucht keine Ueberschrift.
|
||||
*/
|
||||
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch', 'reminder']);
|
||||
const FRAME_HEADER_TYPES = new Set([
|
||||
'calendar',
|
||||
'clock',
|
||||
'calculator',
|
||||
'stopwatch',
|
||||
'reminder',
|
||||
'nextcloud-status',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
|
||||
|
||||
Reference in New Issue
Block a user