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:
2026-10-02 14:49:02 +02:00
parent 7188733f70
commit 5ef7b0c6cc
31 changed files with 2513 additions and 2 deletions
@@ -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>
);
}
@@ -57,6 +57,7 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
<path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z" />
</>
),
cloud: <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
tile: (
<>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
@@ -0,0 +1,81 @@
import type { NextcloudRating, RatingLevel } from '@/lib/nextcloud-status-api';
/**
* Darstellung der Ampel (quick-261002-k67), gemeinsam fuer Modulseite und
* Dashboard-Kachel. Bewusst vollstaendige, statische Klassenzeichenketten —
* Tailwind findet Klassen nur, wenn sie woertlich im Quelltext stehen.
*
* `fill` = Flaeche (Leiste, Punkt), `pill` = Pille (12 % Flaeche + Textvariante),
* `text` = Schrift in Statusfarbe.
*/
export const RATING_STYLE: Record<RatingLevel, { fill: string; pill: string; text: string }> = {
green: {
fill: 'bg-status-ok',
pill: 'bg-status-ok/12 text-status-ok-fg',
text: 'text-status-ok-fg',
},
yellow: {
fill: 'bg-status-warn',
pill: 'bg-status-warn/12 text-status-warn-fg',
text: 'text-status-warn-fg',
},
red: {
fill: 'bg-status-down',
pill: 'bg-status-down/12 text-status-down-fg',
text: 'text-status-down-fg',
},
unknown: {
fill: 'bg-status-idle',
pill: 'bg-status-idle/12 text-status-idle-fg',
text: 'text-status-idle-fg',
},
};
type Translate = (key: string, values?: Record<string, string | number>) => string;
/** 'YYYY-MM-DD' als Datum nach Sprache, in UTC (kein Tagesversatz durch die Zeitzone). */
export function formatEolDate(isoDay: string, locale: string): string {
const date = new Date(`${isoDay}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return isoDay;
return new Intl.DateTimeFormat(locale, {
timeZone: 'UTC',
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
/**
* Kurzer Klartext zur Bewertung. `t` ist der Uebersetzer des Namensraums
* `nextcloudStatus`; die API liefert nur Grundkennung plus Parameter (D-B).
*/
export function ratingReasonText(t: Translate, rating: NextcloudRating, locale: string): string {
switch (rating.reason) {
case 'current':
return t('reason.current');
case 'update-available':
return t('reason.updateAvailable', { version: rating.updateTo ?? '' });
case 'eol-soon':
return t('reason.eolSoon', {
date: rating.eolDate ? formatEolDate(rating.eolDate, locale) : '',
});
case 'eol-passed':
return rating.eolDate
? t('reason.eolPassed', { date: formatEolDate(rating.eolDate, locale) })
: t('reason.eolPassedNoDate');
case 'unreachable':
return t('reason.unreachable');
case 'invalid-response':
return t('reason.invalidResponse');
case 'maintenance':
return t('reason.maintenance');
case 'needs-db-upgrade':
return t('reason.needsDbUpgrade');
case 'no-reference':
return t('reason.noReference');
case 'version-unknown':
return t('reason.versionUnknown');
case 'not-checked':
return t('reason.notChecked');
}
}
+2 -1
View File
@@ -7,7 +7,7 @@
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
*/
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'tile';
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'tile';
const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar',
@@ -15,6 +15,7 @@ const ICONS: Record<string, ModuleIconId> = {
'cert-manager': 'certificate',
domaincheck: 'globe',
proxmox: 'server',
'nextcloud-status': 'cloud',
'kantine-datev': 'utensils',
'handelsware-datev': 'shopping-bag',
};
+6
View File
@@ -59,6 +59,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
{ ssr: false },
),
},
'nextcloud-status': {
component: dynamic(
() => import('@/app/(portal)/modules/nextcloud-status/page'),
{ ssr: false },
),
},
'kantine-datev': {
component: dynamic(
() => import('@/app/(portal)/modules/kantine-datev/page'),
+86
View File
@@ -0,0 +1,86 @@
/**
* Nextcloud-Status Module API client (quick-261002-k67). Konsumiert
* `/modules/nextcloud-status/*`. Vorbild `proxmox-api.ts`:
* `credentials: 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*
* Die Antworten tragen nur ausgewertete Felder — nie einen rohen
* Antworttext einer Cloud und nie Logo-Bytes (die kommen ueber die
* eigene Logo-Adresse, siehe `logoSrc`).
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export type RatingLevel = 'green' | 'yellow' | 'red' | 'unknown';
export type RatingReason =
| 'current'
| 'update-available'
| 'eol-soon'
| 'eol-passed'
| 'unreachable'
| 'invalid-response'
| 'maintenance'
| 'needs-db-upgrade'
| 'no-reference'
| 'version-unknown'
| 'not-checked';
export interface NextcloudRating {
level: RatingLevel;
reason: RatingReason;
/** Neuester Patchstand im eigenen Zweig, nur wenn ein Update ansteht. */
updateTo: string | null;
/** Support-Ende des Zweigs als 'YYYY-MM-DD', wenn bekannt. */
eolDate: string | null;
cycle: number | null;
}
export interface NextcloudInstanceStatus {
checkedAt: string | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
errorKind: string | null;
errorDetail: string | null;
}
export interface NextcloudInstance {
id: string;
customerName: string;
baseUrl: string;
logoUrl: string | null;
hasUploadedLogo: boolean;
logoVersion: number;
status: NextcloudInstanceStatus;
rating: NextcloudRating;
}
export interface NextcloudList {
instances: NextcloudInstance[];
reference: { newestVersion: string | null; fetchedAt: string | null };
}
/** GET /modules/nextcloud-status/instances — Clouds samt Bewertung und neuester Version. */
export async function listInstances(): Promise<NextcloudList> {
const res = await fetch(`${API_URL}/modules/nextcloud-status/instances`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch nextcloud instances');
return res.json();
}
/**
* Bildquelle fuer das Logo einer Cloud: ein hochgeladenes Logo kommt ueber den
* same-origin-Proxy (angemeldet, mit `?v=` gegen alte Zwischenspeicher), eine
* Bildadresse laedt der Browser direkt; ohne beides `null` (Initialen).
*/
export function logoSrc(
instance: Pick<NextcloudInstance, 'id' | 'hasUploadedLogo' | 'logoVersion' | 'logoUrl'>,
): string | null {
if (instance.hasUploadedLogo) {
return `/api-proxy/modules/nextcloud-status/instances/${instance.id}/logo?v=${instance.logoVersion}`;
}
return instance.logoUrl ?? null;
}
+1
View File
@@ -25,6 +25,7 @@ export const useNavStore = create<NavState>()((set) => ({
/** Ersatztitel, solange die Seitenleiste die Module noch nicht geladen hat. */
const MODULE_TITLE_KEYS: Record<string, string> = {
proxmox: 'proxmox.title',
'nextcloud-status': 'nextcloudStatus.title',
domaincheck: 'domaincheck.title',
'cert-manager': 'certManager.title',
'dkv-fleet': 'dkvFleet.pageTitle',
+38
View File
@@ -1814,5 +1814,43 @@
"procurement": "Beschaffung",
"accounting": "Finanzbuchhaltung",
"custom-modules": "Eigene Module"
},
"nextcloudStatus": {
"title": "Nextcloud-Status",
"description": "Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick",
"loading": "Clouds werden geladen …",
"loadError": "Die Clouds konnten nicht geladen werden. Bitte laden Sie die Seite neu.",
"empty": "Es sind noch keine Clouds eingetragen.",
"emptyManage": "Fügen Sie Ihre erste Cloud über „Cloud hinzufügen“ hinzu.",
"newestVersion": "Neueste Nextcloud-Version: {version}",
"referenceUnavailable": "Versionsdaten derzeit nicht verfügbar",
"card": {
"version": "Version",
"versionUnknown": "—",
"lastCheck": "Zuletzt geprüft {age}",
"neverChecked": "Noch nie geprüft",
"openCloud": "Cloud im neuen Tab öffnen",
"noLogo": "Kein Logo"
},
"level": {
"green": "Grün",
"yellow": "Gelb",
"red": "Rot",
"unknown": "Ohne Bewertung"
},
"reason": {
"current": "Aktuell",
"updateAvailable": "Update auf {version} verfügbar",
"eolSoon": "Support endet am {date}",
"eolPassed": "Support abgelaufen seit {date}",
"eolPassedNoDate": "Support abgelaufen",
"unreachable": "Nicht erreichbar",
"invalidResponse": "Keine gültige Nextcloud-Antwort",
"maintenance": "Wartungsmodus",
"needsDbUpgrade": "Datenbank-Aktualisierung ausstehend",
"noReference": "Bewertung nicht möglich",
"versionUnknown": "Version unbekannt",
"notChecked": "Noch nicht geprüft"
}
}
}
+38
View File
@@ -1814,5 +1814,43 @@
"procurement": "Procurement",
"accounting": "Financial accounting",
"custom-modules": "Custom modules"
},
"nextcloudStatus": {
"title": "Nextcloud status",
"description": "Keep track of versions and availability of your Nextcloud clouds",
"loading": "Loading clouds …",
"loadError": "The clouds could not be loaded. Please reload the page.",
"empty": "No clouds have been added yet.",
"emptyManage": "Add your first cloud with “Add cloud”.",
"newestVersion": "Newest Nextcloud version: {version}",
"referenceUnavailable": "Version data currently unavailable",
"card": {
"version": "Version",
"versionUnknown": "—",
"lastCheck": "Last checked {age}",
"neverChecked": "Never checked",
"openCloud": "Open cloud in a new tab",
"noLogo": "No logo"
},
"level": {
"green": "Green",
"yellow": "Yellow",
"red": "Red",
"unknown": "Not rated"
},
"reason": {
"current": "Up to date",
"updateAvailable": "Update to {version} available",
"eolSoon": "Support ends on {date}",
"eolPassed": "Support ended on {date}",
"eolPassedNoDate": "Support ended",
"unreachable": "Not reachable",
"invalidResponse": "Not a valid Nextcloud response",
"maintenance": "Maintenance mode",
"needsDbUpgrade": "Database upgrade pending",
"noReference": "Rating not possible",
"versionUnknown": "Version unknown",
"notChecked": "Not checked yet"
}
}
}
@@ -103,6 +103,9 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
* and must never be touched by the replacement or flagged by the guard.
*/
export const UMLAUT_ALLOWLIST: readonly string[] = [
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
'Neueste',
'ausstehend',
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
'Aussteller',
'Betriebssystemen',