feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht
- NextcloudInstance mit Zeilenschutz, Migration 20261002150000 - Bewertung (Ampel) als reine Funktion, status.php-Abruf mit Grenzen, endoflife.date-Zwischenspeicher - API: Liste mit Bewertung, Anlegen und Einzelprüfung nur mit Verwalten - Modulseite mit Kacheln, Registrierung in Seitenleiste, Marktplatz und Symbolen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import DkvFleetLayout from './dkv-fleet/layout';
|
||||
import DomaincheckLayout from './domaincheck/layout';
|
||||
import HandelswareDatevLayout from './handelsware-datev/layout';
|
||||
import KantineDatevLayout from './kantine-datev/layout';
|
||||
import NextcloudStatusLayout from './nextcloud-status/layout';
|
||||
import TenderRadarLayout from './tender-radar/layout';
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
|
||||
['tender-radar', TenderRadarLayout],
|
||||
['kantine-datev', KantineDatevLayout],
|
||||
['handelsware-datev', HandelswareDatevLayout],
|
||||
['nextcloud-status', NextcloudStatusLayout],
|
||||
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
|
||||
const element = Layout({ children: placeholderChild });
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { RATING_STYLE, ratingReasonText } from '@/components/nextcloud-status/rating-display';
|
||||
import { formatAge } from '@/components/proxmox/proxmox-status';
|
||||
import { logoSrc, type NextcloudInstance } from '@/lib/nextcloud-status-api';
|
||||
|
||||
/** Ein bis zwei Anfangsbuchstaben als Ersatz, wenn kein Logo vorliegt oder es nicht laedt. */
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
const letters = parts.slice(0, 2).map((p) => Array.from(p)[0] ?? '');
|
||||
return letters.join('').toUpperCase() || '?';
|
||||
}
|
||||
|
||||
interface CloudTileProps {
|
||||
instance: NextcloudInstance;
|
||||
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kachel EINER Cloud (quick-261002-k67, L-06): Leiste links und Pille in der
|
||||
* Ampelfarbe, Logo (oder Initialen), Kundenname, Adresse als Verweis in
|
||||
* neuem Tab, installierte Version, kurzer Klartext zur Bewertung und
|
||||
* Zeitpunkt der letzten Pruefung. Die Logo-Adresse kommt vom Verwalter und
|
||||
* wird vom Browser geladen — ohne Referer (`referrerPolicy`, T-k67-05).
|
||||
*/
|
||||
export function CloudTile({ instance, now = Date.now() }: CloudTileProps) {
|
||||
const t = useTranslations('nextcloudStatus');
|
||||
const locale = useLocale();
|
||||
const [logoFailed, setLogoFailed] = useState(false);
|
||||
const style = RATING_STYLE[instance.rating.level];
|
||||
const reason = ratingReasonText(t, instance.rating, locale);
|
||||
const src = logoFailed ? null : logoSrc(instance);
|
||||
const checkedAge = formatAge(instance.status.checkedAt, now, locale);
|
||||
const showErrorDetail =
|
||||
instance.rating.level === 'red' &&
|
||||
instance.rating.reason === 'unreachable' &&
|
||||
instance.status.errorDetail;
|
||||
|
||||
return (
|
||||
<article
|
||||
data-testid="cloud-tile"
|
||||
data-level={instance.rating.level}
|
||||
aria-label={`${instance.customerName}: ${reason}`}
|
||||
className="relative flex h-full flex-col overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border"
|
||||
>
|
||||
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
|
||||
<div className="flex flex-1 flex-col gap-3 py-4 pl-5 pr-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-well text-sm font-semibold text-muted-foreground">
|
||||
{src ? (
|
||||
// Dynamische Adresse (Upload ueber den Proxy oder externe https-Adresse) — next/image waere hier unpassend.
|
||||
// biome-ignore lint/performance/noImgElement: externe, vom Verwalter gesetzte Adresse
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError ist ein Ladefehler-Rueckfall, keine Bedienung
|
||||
<img
|
||||
src={src}
|
||||
alt={instance.customerName}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setLogoFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{initialsOf(instance.customerName)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold leading-tight" title={instance.customerName}>
|
||||
{instance.customerName}
|
||||
</div>
|
||||
<a
|
||||
href={instance.baseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block truncate text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||
title={t('card.openCloud')}
|
||||
>
|
||||
{instance.baseUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span
|
||||
data-testid="rating-pill"
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${style.pill}`}
|
||||
>
|
||||
<span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${style.fill}`} />
|
||||
{reason}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('card.version')}:{' '}
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{instance.status.versionString ?? t('card.versionUnknown')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showErrorDetail && (
|
||||
<p className="break-words text-xs text-muted-foreground">{instance.status.errorDetail}</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
data-testid="last-check"
|
||||
className="mt-auto border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
title={
|
||||
instance.status.checkedAt
|
||||
? new Date(instance.status.checkedAt).toLocaleString(locale)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{checkedAge ? t('card.lastCheck', { age: checkedAge }) : t('card.neverChecked')}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
|
||||
|
||||
export default function NextcloudStatusLayout({ children }: { children: ReactNode }) {
|
||||
return <ModuleAccessGate moduleSlug="nextcloud-status">{children}</ModuleAccessGate>;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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 } from '@/lib/nextcloud-status-api';
|
||||
import de from '@/messages/de.json';
|
||||
import NextcloudStatusPage from './page';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockListInstances = vi.fn();
|
||||
|
||||
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
|
||||
return { ...actual, listInstances: (...args: unknown[]) => mockListInstances(...args) };
|
||||
});
|
||||
|
||||
function makeInstance(
|
||||
over: Partial<NextcloudInstance> & { id: string; customerName: string },
|
||||
): NextcloudInstance {
|
||||
return {
|
||||
baseUrl: `https://${over.id}.example.de`,
|
||||
logoUrl: null,
|
||||
hasUploadedLogo: false,
|
||||
logoVersion: 0,
|
||||
status: {
|
||||
checkedAt: new Date().toISOString(),
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
},
|
||||
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const LIST: NextcloudList = {
|
||||
reference: { newestVersion: '35.0.1', fetchedAt: '2026-10-02T10:00:00.000Z' },
|
||||
instances: [
|
||||
makeInstance({ id: 'a', customerName: 'Kunde Grün' }),
|
||||
makeInstance({
|
||||
id: 'b',
|
||||
customerName: 'Kunde Gelb',
|
||||
status: {
|
||||
checkedAt: new Date().toISOString(),
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '34.0.3',
|
||||
edition: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
},
|
||||
rating: {
|
||||
level: 'yellow',
|
||||
reason: 'update-available',
|
||||
updateTo: '34.0.4',
|
||||
eolDate: '2027-06-30',
|
||||
cycle: 34,
|
||||
},
|
||||
}),
|
||||
makeInstance({
|
||||
id: 'c',
|
||||
customerName: 'Kunde Rot',
|
||||
status: {
|
||||
checkedAt: new Date().toISOString(),
|
||||
reachable: false,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
errorKind: 'timeout',
|
||||
errorDetail: null,
|
||||
},
|
||||
rating: { level: 'red', reason: 'unreachable', updateTo: null, eolDate: null, cycle: null },
|
||||
}),
|
||||
makeInstance({
|
||||
id: 'd',
|
||||
customerName: 'Kunde Grau',
|
||||
rating: {
|
||||
level: 'unknown',
|
||||
reason: 'no-reference',
|
||||
updateTo: null,
|
||||
eolDate: null,
|
||||
cycle: 35,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
describe('NextcloudStatusPage', () => {
|
||||
beforeEach(() => {
|
||||
mockListInstances.mockReset();
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('zeigt je Cloud eine Kachel mit Name, Version, Klartext und Verweis', async () => {
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
render(<NextcloudStatusPage />);
|
||||
|
||||
const tiles = await screen.findAllByTestId('cloud-tile');
|
||||
expect(tiles).toHaveLength(4);
|
||||
|
||||
const green = tiles.find((t) => t.textContent?.includes('Kunde Grün'));
|
||||
expect(green).toBeDefined();
|
||||
expect(within(green as HTMLElement).getByText('Aktuell')).toBeInTheDocument();
|
||||
expect(green).toHaveAttribute('data-level', 'green');
|
||||
|
||||
expect(screen.getByText('Update auf 34.0.4 verfügbar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nicht erreichbar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bewertung nicht möglich')).toBeInTheDocument();
|
||||
expect(screen.getByText('34.0.3')).toBeInTheDocument();
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://a.example.de' });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(link).toHaveAttribute('href', 'https://a.example.de');
|
||||
});
|
||||
|
||||
it('nennt die neueste Nextcloud-Version', async () => {
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
render(<NextcloudStatusPage />);
|
||||
expect(await screen.findByTestId('newest-version')).toHaveTextContent(
|
||||
'Neueste Nextcloud-Version: 35.0.1',
|
||||
);
|
||||
});
|
||||
|
||||
it('weist ohne Vergleichsdaten darauf hin', async () => {
|
||||
mockListInstances.mockResolvedValue({
|
||||
...LIST,
|
||||
reference: { newestVersion: null, fetchedAt: null },
|
||||
});
|
||||
render(<NextcloudStatusPage />);
|
||||
expect(await screen.findByTestId('newest-version')).toHaveTextContent(
|
||||
'Versionsdaten derzeit nicht verfügbar',
|
||||
);
|
||||
});
|
||||
|
||||
it('zeigt den Leerzustand ohne Clouds', async () => {
|
||||
mockListInstances.mockResolvedValue({ ...LIST, instances: [] });
|
||||
render(<NextcloudStatusPage />);
|
||||
expect(await screen.findByText('Es sind noch keine Clouds eingetragen.')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cloud-tile')).toBeNull();
|
||||
});
|
||||
|
||||
it('zeigt einen Hinweis, wenn das Laden fehlschlägt', async () => {
|
||||
mockListInstances.mockRejectedValue(new Error('boom'));
|
||||
render(<NextcloudStatusPage />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/konnten nicht geladen werden/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('verwendet ohne Logo Initialen', async () => {
|
||||
mockListInstances.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
|
||||
render(<NextcloudStatusPage />);
|
||||
const tile = await screen.findByTestId('cloud-tile');
|
||||
expect(within(tile).getByText('KG')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { WELL } from '@/components/proxmox/status-styles';
|
||||
import { listInstances, type NextcloudList } from '@/lib/nextcloud-status-api';
|
||||
import { CloudTile } from './components/CloudTile';
|
||||
|
||||
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
||||
const CLOCK_TICK_MS = 60_000;
|
||||
|
||||
function SkeletonTile() {
|
||||
return (
|
||||
<div className="rounded-lg bg-card p-4 pl-5 shadow-sm dark:border dark:border-border">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3.5 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-2/3 rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-6 w-1/2 rounded-full bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulseite Nextcloud-Status (quick-261002-k67): eine Kachel je Cloud mit
|
||||
* Ampel, installierter Version und Zeitpunkt der letzten Pruefung. Die
|
||||
* Bewertung berechnet die API beim Lesen (D-B) — diese Seite zeigt nur an.
|
||||
*/
|
||||
export default function NextcloudStatusPage() {
|
||||
const t = useTranslations('nextcloudStatus');
|
||||
const [data, setData] = useState<NextcloudList | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const reload = useCallback(() => {
|
||||
listInstances()
|
||||
.then((list) => {
|
||||
setData(list);
|
||||
setNow(Date.now());
|
||||
})
|
||||
.catch(() => setError(t('loadError')));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const newest = data?.reference.newestVersion ?? null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader moduleSlug="nextcloud-status" title={t('title')} description={t('description')} />
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && data === null && (
|
||||
<div
|
||||
aria-busy="true"
|
||||
className="grid animate-pulse gap-4 motion-reduce:animate-none sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<span className="sr-only">{t('loading')}</span>
|
||||
<SkeletonTile />
|
||||
<SkeletonTile />
|
||||
<SkeletonTile />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && data !== null && (
|
||||
<p className="text-sm text-muted-foreground" data-testid="newest-version">
|
||||
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && data !== null && data.instances.length === 0 && (
|
||||
<div className={`${WELL} px-6 py-10 text-center`}>
|
||||
<p className="text-sm text-muted-foreground">{t('empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && data !== null && data.instances.length > 0 && (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.instances.map((instance) => (
|
||||
<li key={instance.id} className="min-w-0">
|
||||
<CloudTile instance={instance} now={now} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user