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:
@@ -0,0 +1,65 @@
|
||||
-- quick-261002-k67 — Modul Nextcloud-Status.
|
||||
--
|
||||
-- Zweck: neue Tabelle "NextcloudInstance" fuer die vom Verwalter
|
||||
-- eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales
|
||||
-- Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Wartungsmodus,
|
||||
-- Versionstext, Fehlerart). Der Zustand liegt direkt auf der Zeile, es gibt
|
||||
-- kein Zwischenlager. Logo-Bytes liegen als BYTEA an der Zeile (hoechstens
|
||||
-- 1 MiB, Pruefung im Dienst); Abfragen ausser dem Logo-Abruf waehlen sie
|
||||
-- nie mit aus.
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||
--
|
||||
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||
-- Clouds sind gemeinsame Daten der Organisation, nicht persoenliche Daten
|
||||
-- eines einzelnen Benutzers.
|
||||
--
|
||||
-- Zusaetzlich eine `system_read_policy` (Form aus
|
||||
-- 20260914120000_rls_system_context_read): der stuendliche Hintergrunddienst
|
||||
-- liest ueber `forSystem()` genau einmal je Durchlauf Kennung und Mandant
|
||||
-- aller Clouds und prueft danach jede Cloud an ihren eigenen Mandanten
|
||||
-- gebunden. Geschrieben wird nie im Systemkontext.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
CREATE TABLE "NextcloudInstance" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"baseUrl" TEXT NOT NULL,
|
||||
"logoUrl" TEXT,
|
||||
"logoData" BYTEA,
|
||||
"logoMime" TEXT,
|
||||
"logoVersion" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastCheckedAt" TIMESTAMP(3),
|
||||
"reachable" BOOLEAN,
|
||||
"maintenance" BOOLEAN,
|
||||
"needsDbUpgrade" BOOLEAN,
|
||||
"versionString" TEXT,
|
||||
"edition" TEXT,
|
||||
"productName" TEXT,
|
||||
"errorKind" TEXT,
|
||||
"errorDetail" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NextcloudInstance_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "NextcloudInstance_tenantId_idx" ON "NextcloudInstance"("tenantId");
|
||||
|
||||
ALTER TABLE "NextcloudInstance" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "NextcloudInstance" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "NextcloudInstance"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
CREATE POLICY system_read_policy ON "NextcloudInstance"
|
||||
FOR SELECT USING (is_system_context());
|
||||
@@ -784,6 +784,35 @@ model ProxmoxServerStatus {
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Nextcloud-Status (quick-261002-k67): vom Verwalter eingetragene Clouds der
|
||||
// Kunden. Der zuletzt ermittelte Zustand liegt direkt auf der Zeile (L-03),
|
||||
// es gibt kein Zwischenlager. Logo-Bytes liegen als bytea an der Zeile (D-A,
|
||||
// hoechstens 1 MiB); Listen- und Planerabfragen waehlen sie nie mit aus.
|
||||
// Zeilenschutz nach Muster ProxmoxServer (tenantId, keine Relation zu Tenant).
|
||||
model NextcloudInstance {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
customerName String
|
||||
baseUrl String
|
||||
logoUrl String?
|
||||
logoData Bytes?
|
||||
logoMime String?
|
||||
logoVersion Int @default(0)
|
||||
lastCheckedAt DateTime?
|
||||
reachable Boolean? // null = noch nie geprueft
|
||||
maintenance Boolean?
|
||||
needsDbUpgrade Boolean?
|
||||
versionString String?
|
||||
edition String?
|
||||
productName String?
|
||||
errorKind String?
|
||||
errorDetail String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
|
||||
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
|
||||
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TendersModule } from './tenders/tenders.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
|
||||
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
|
||||
import { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
|
||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
@@ -57,6 +58,7 @@ import { RemindersModule } from './reminders/reminders.module';
|
||||
TendersModule,
|
||||
BugReportsModule,
|
||||
ProxmoxModule,
|
||||
NextcloudStatusModule,
|
||||
KantineDatevModule,
|
||||
HandelswareDatevModule,
|
||||
CustomModulesModule,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Eingaben fuer das Anlegen und Aendern einer Nextcloud-Cloud
|
||||
* (quick-261002-k67). Die Adresse wird im Dienst zusaetzlich normalisiert
|
||||
* (`normalizeCloudUrl`); hier gilt nur die Formpruefung. Eine Logo-Adresse
|
||||
* ist nur mit https erlaubt — sie wird vom Browser geladen, nie vom Server.
|
||||
*/
|
||||
export class CreateNextcloudInstanceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
customerName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2048)
|
||||
baseUrl!: string;
|
||||
|
||||
// Leere Zeichenkette = kein Logo; sonst nur eine https-Adresse.
|
||||
@IsOptional()
|
||||
@ValidateIf((_o, value) => typeof value === 'string' && value !== '')
|
||||
@IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false })
|
||||
@MaxLength(2048)
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateNextcloudInstanceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
customerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2048)
|
||||
baseUrl?: string;
|
||||
|
||||
// Leere Zeichenkette = Logo-Adresse entfernen.
|
||||
@IsOptional()
|
||||
@ValidateIf((_o, value) => typeof value === 'string' && value !== '')
|
||||
@IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false })
|
||||
@MaxLength(2048)
|
||||
logoUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type NextcloudReference,
|
||||
type NextcloudStatusInput,
|
||||
newestVersion,
|
||||
parseVersionTriple,
|
||||
rateNextcloud,
|
||||
} from './nextcloud-rating';
|
||||
|
||||
const REFERENCE: NextcloudReference = {
|
||||
fetchedAt: '2026-10-02T10:00:00.000Z',
|
||||
cycles: [
|
||||
{ cycle: 35, eol: '2027-09-30', latest: '35.0.1' },
|
||||
{ cycle: 34, eol: '2027-06-30', latest: '34.0.4' },
|
||||
{ cycle: 33, eol: '2027-02-28', latest: '33.0.9' },
|
||||
{ cycle: 32, eol: '2026-09-30', latest: '32.0.15' },
|
||||
{ cycle: 31, eol: '2026-02-28', latest: '31.0.14' },
|
||||
],
|
||||
};
|
||||
|
||||
const NOW = new Date('2026-10-02T12:00:00Z');
|
||||
|
||||
function status(
|
||||
versionString: string | null,
|
||||
over: Partial<NextcloudStatusInput> = {},
|
||||
): NextcloudStatusInput {
|
||||
return {
|
||||
checkedAt: '2026-10-02T11:00:00.000Z',
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString,
|
||||
errorKind: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('rateNextcloud', () => {
|
||||
it('neuester Patchstand eines unterstuetzten Zweigs ist gruen', () => {
|
||||
expect(rateNextcloud(status('35.0.1'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
updateTo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('neuer als der gelistete Patchstand ist ebenfalls gruen', () => {
|
||||
expect(rateNextcloud(status('35.0.2'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
});
|
||||
});
|
||||
|
||||
it('Update im eigenen Zweig ist gelb', () => {
|
||||
expect(rateNextcloud(status('34.0.3'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'update-available',
|
||||
updateTo: '34.0.4',
|
||||
});
|
||||
});
|
||||
|
||||
it('Support-Ende ueberschritten ist rot mit Datum', () => {
|
||||
expect(rateNextcloud(status('32.0.15'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'eol-passed',
|
||||
eolDate: '2026-09-30',
|
||||
});
|
||||
});
|
||||
|
||||
it('Support endet bald ist gelb', () => {
|
||||
expect(
|
||||
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-08-01T00:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
eolDate: '2026-09-30',
|
||||
});
|
||||
expect(
|
||||
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-12-15T00:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
eolDate: '2027-02-28',
|
||||
});
|
||||
});
|
||||
|
||||
it('Grenze: genau 90 Tage bis zum Ende ist gelb, 91 Tage sind gruen', () => {
|
||||
// 2027-02-28 minus 90 Tage = 2026-11-30
|
||||
expect(
|
||||
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-11-30T08:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
});
|
||||
expect(
|
||||
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-11-29T08:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
});
|
||||
});
|
||||
|
||||
it('Grenze: am Tag des Support-Endes gelb, am Tag danach rot', () => {
|
||||
expect(
|
||||
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-09-30T23:59:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
});
|
||||
expect(
|
||||
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-10-01T00:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'eol-passed',
|
||||
});
|
||||
});
|
||||
|
||||
it('Support-Ende bald hat Vorrang und nennt zusaetzlich das Update', () => {
|
||||
expect(
|
||||
rateNextcloud(status('33.0.5'), REFERENCE, new Date('2026-12-15T00:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
updateTo: '33.0.9',
|
||||
});
|
||||
});
|
||||
|
||||
it('Hauptversion aelter als jeder gelistete Zweig ist rot ohne Datum', () => {
|
||||
expect(rateNextcloud(status('20.0.1'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'eol-passed',
|
||||
eolDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('Hauptversion neuer als jeder gelistete Zweig ist gruen', () => {
|
||||
expect(rateNextcloud(status('36.0.0'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
});
|
||||
});
|
||||
|
||||
it('Hauptversion innerhalb der Spanne, aber nicht gelistet, ist grau', () => {
|
||||
const lueckenhaft: NextcloudReference = {
|
||||
...REFERENCE,
|
||||
cycles: REFERENCE.cycles.filter((c) => c.cycle !== 33),
|
||||
};
|
||||
expect(rateNextcloud(status('33.0.1'), lueckenhaft, NOW)).toMatchObject({
|
||||
level: 'unknown',
|
||||
reason: 'no-reference',
|
||||
});
|
||||
});
|
||||
|
||||
it('Zweig mit eol false gilt als unterstuetzt, eol true als abgelaufen', () => {
|
||||
const ohneEnde: NextcloudReference = {
|
||||
...REFERENCE,
|
||||
cycles: [
|
||||
{ cycle: 35, eol: false, latest: '35.0.1' },
|
||||
{ cycle: 30, eol: true, latest: '30.0.9' },
|
||||
],
|
||||
};
|
||||
expect(rateNextcloud(status('35.0.1'), ohneEnde, NOW)).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
});
|
||||
expect(rateNextcloud(status('30.0.9'), ohneEnde, NOW)).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'eol-passed',
|
||||
eolDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('nicht erreichbar ist rot, auch ohne Vergleichsdaten', () => {
|
||||
expect(
|
||||
rateNextcloud(status(null, { reachable: false, errorKind: 'timeout' }), null, NOW),
|
||||
).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'unreachable',
|
||||
});
|
||||
});
|
||||
|
||||
it('keine gueltige Nextcloud-Antwort ist rot mit eigenem Grund', () => {
|
||||
expect(
|
||||
rateNextcloud(status(null, { reachable: false, errorKind: 'not-nextcloud' }), REFERENCE, NOW),
|
||||
).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'invalid-response',
|
||||
});
|
||||
});
|
||||
|
||||
it('Wartungsmodus und ausstehende Datenbankaktualisierung sind rot', () => {
|
||||
expect(rateNextcloud(status('35.0.1', { maintenance: true }), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'maintenance',
|
||||
});
|
||||
expect(rateNextcloud(status('35.0.1', { needsDbUpgrade: true }), REFERENCE, NOW)).toMatchObject(
|
||||
{
|
||||
level: 'red',
|
||||
reason: 'needs-db-upgrade',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('Prioritaet der roten Gruende', () => {
|
||||
expect(
|
||||
rateNextcloud(status('32.0.15', { maintenance: true, needsDbUpgrade: true }), REFERENCE, NOW)
|
||||
.reason,
|
||||
).toBe('maintenance');
|
||||
expect(rateNextcloud(status('32.0.15', { needsDbUpgrade: true }), REFERENCE, NOW).reason).toBe(
|
||||
'needs-db-upgrade',
|
||||
);
|
||||
expect(
|
||||
rateNextcloud(
|
||||
status('32.0.15', { reachable: false, errorKind: 'not-nextcloud', maintenance: true }),
|
||||
REFERENCE,
|
||||
NOW,
|
||||
).reason,
|
||||
).toBe('invalid-response');
|
||||
expect(
|
||||
rateNextcloud(
|
||||
status('32.0.15', { reachable: false, errorKind: 'timeout', maintenance: true }),
|
||||
REFERENCE,
|
||||
NOW,
|
||||
).reason,
|
||||
).toBe('unreachable');
|
||||
});
|
||||
|
||||
it('ohne Vergleichsdaten ist die Bewertung grau', () => {
|
||||
expect(rateNextcloud(status('35.0.1'), null, NOW)).toMatchObject({
|
||||
level: 'unknown',
|
||||
reason: 'no-reference',
|
||||
});
|
||||
});
|
||||
|
||||
it('erreichbar ohne lesbare Version ist grau', () => {
|
||||
expect(rateNextcloud(status(null), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'unknown',
|
||||
reason: 'version-unknown',
|
||||
});
|
||||
expect(rateNextcloud(status('abc'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'unknown',
|
||||
reason: 'version-unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('noch nie geprueft ist grau', () => {
|
||||
expect(
|
||||
rateNextcloud(
|
||||
{
|
||||
checkedAt: null,
|
||||
reachable: null,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
errorKind: null,
|
||||
},
|
||||
REFERENCE,
|
||||
NOW,
|
||||
),
|
||||
).toMatchObject({ level: 'unknown', reason: 'not-checked' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('newestVersion', () => {
|
||||
it('liefert den Patchstand des hoechsten Zweigs', () => {
|
||||
expect(newestVersion(REFERENCE)).toBe('35.0.1');
|
||||
});
|
||||
it('ohne Daten null', () => {
|
||||
expect(newestVersion(null)).toBeNull();
|
||||
expect(newestVersion({ cycles: [], fetchedAt: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVersionTriple', () => {
|
||||
it('liest drei Teile und ignoriert weitere', () => {
|
||||
expect(parseVersionTriple('31.0.5')).toEqual([31, 0, 5]);
|
||||
expect(parseVersionTriple('31.0.5.1')).toEqual([31, 0, 5]);
|
||||
expect(parseVersionTriple('31.0')).toBeNull();
|
||||
expect(parseVersionTriple(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* nextcloud-rating — reine Bewertung einer Nextcloud-Cloud (quick-261002-k67,
|
||||
* L-05). Kein Nest, kein Prisma, kein Zugriff auf die Uhr: das Datum wird
|
||||
* hereingereicht, damit jede Regel an festen Tagen testbar ist.
|
||||
*
|
||||
* Die Ampel (festgelegt):
|
||||
* - GRUEN: neuester Patchstand seines Hauptzweigs UND der Zweig wird noch
|
||||
* unterstuetzt (Support-Ende liegt mehr als 90 Tage entfernt).
|
||||
* - GELB: im eigenen Zweig gibt es ein Update ODER das Support-Ende des
|
||||
* Zweigs liegt innerhalb der naechsten 90 Tage (inklusive).
|
||||
* - ROT: Support-Ende erreicht/ueberschritten (oder Hauptzweig aelter als
|
||||
* jeder gelistete), nicht erreichbar, keine gueltige Nextcloud-Antwort,
|
||||
* Wartungsmodus oder ausstehende Datenbankaktualisierung.
|
||||
* - GRAU ("unknown"): ohne Vergleichsdaten, ohne lesbare Version oder noch
|
||||
* nie geprueft — die roten Zustandsmerkmale gelten trotzdem.
|
||||
*
|
||||
* Reihenfolge der roten Gruende: nicht erreichbar > ungueltige Antwort >
|
||||
* Wartungsmodus > Datenbankaktualisierung > Support abgelaufen.
|
||||
*/
|
||||
|
||||
export interface NextcloudCycle {
|
||||
/** Hauptversion, z. B. 34. */
|
||||
cycle: number;
|
||||
/** Support-Ende als Tag 'YYYY-MM-DD', `true` = beendet, `false` = ohne Ende. */
|
||||
eol: string | boolean;
|
||||
/** Neuester Patchstand des Zweigs, z. B. '34.0.4'. */
|
||||
latest: string;
|
||||
}
|
||||
|
||||
export interface NextcloudReference {
|
||||
cycles: NextcloudCycle[];
|
||||
/** ISO-Zeitpunkt des letzten erfolgreichen Abrufs. */
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
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;
|
||||
/** Hauptversion der Cloud, wenn lesbar. */
|
||||
cycle: number | null;
|
||||
}
|
||||
|
||||
/** Eingabe der Bewertung: der zuletzt gespeicherte Zustand einer Cloud. */
|
||||
export interface NextcloudStatusInput {
|
||||
/** null = noch nie geprueft. */
|
||||
checkedAt: Date | string | null;
|
||||
reachable: boolean | null;
|
||||
maintenance: boolean | null;
|
||||
needsDbUpgrade: boolean | null;
|
||||
versionString: string | null;
|
||||
errorKind: string | null;
|
||||
}
|
||||
|
||||
/** Warnfenster vor dem Support-Ende in Tagen (einschliesslich). */
|
||||
export const EOL_WARNING_DAYS = 90;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Versionsangabe `major.minor.patch` als Zahlentripel, sonst null. */
|
||||
export function parseVersionTriple(
|
||||
raw: string | null | undefined,
|
||||
): [number, number, number] | null {
|
||||
if (!raw) return null;
|
||||
const match = /^(\d{1,4})\.(\d{1,4})\.(\d{1,4})(?:\D.*)?$/.exec(raw.trim());
|
||||
if (!match) return null;
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function compareTriples(a: [number, number, number], b: [number, number, number]): number {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Tage zwischen zwei Kalendertagen 'YYYY-MM-DD' (UTC), `to - from`. */
|
||||
function dayDiff(from: string, to: string): number {
|
||||
return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS);
|
||||
}
|
||||
|
||||
/** Neueste Gesamtversion laut Vergleichsdaten: Patchstand des hoechsten Zweigs. */
|
||||
export function newestVersion(reference: NextcloudReference | null): string | null {
|
||||
if (!reference || reference.cycles.length === 0) return null;
|
||||
let best: NextcloudCycle | null = null;
|
||||
for (const cycle of reference.cycles) {
|
||||
if (!best || cycle.cycle > best.cycle) best = cycle;
|
||||
}
|
||||
return best?.latest ?? null;
|
||||
}
|
||||
|
||||
function result(
|
||||
level: RatingLevel,
|
||||
reason: RatingReason,
|
||||
extra: Partial<Omit<NextcloudRating, 'level' | 'reason'>> = {},
|
||||
): NextcloudRating {
|
||||
return { level, reason, updateTo: null, eolDate: null, cycle: null, ...extra };
|
||||
}
|
||||
|
||||
export function rateNextcloud(
|
||||
status: NextcloudStatusInput,
|
||||
reference: NextcloudReference | null,
|
||||
now: Date,
|
||||
): NextcloudRating {
|
||||
const triple = parseVersionTriple(status.versionString);
|
||||
const cycleNo = triple ? triple[0] : null;
|
||||
|
||||
// Rote Zustandsmerkmale gehen vor jeder Versionsbewertung.
|
||||
if (status.checkedAt === null && status.reachable === null) {
|
||||
return result('unknown', 'not-checked');
|
||||
}
|
||||
if (status.reachable === false) {
|
||||
if (status.errorKind === 'not-nextcloud') {
|
||||
return result('red', 'invalid-response', { cycle: cycleNo });
|
||||
}
|
||||
return result('red', 'unreachable', { cycle: cycleNo });
|
||||
}
|
||||
if (status.maintenance === true) return result('red', 'maintenance', { cycle: cycleNo });
|
||||
if (status.needsDbUpgrade === true) return result('red', 'needs-db-upgrade', { cycle: cycleNo });
|
||||
|
||||
if (!triple) return result('unknown', 'version-unknown');
|
||||
if (!reference || reference.cycles.length === 0) {
|
||||
return result('unknown', 'no-reference', { cycle: cycleNo });
|
||||
}
|
||||
|
||||
const cycle = reference.cycles.find((c) => c.cycle === triple[0]);
|
||||
if (!cycle) {
|
||||
const numbers = reference.cycles.map((c) => c.cycle);
|
||||
const highest = Math.max(...numbers);
|
||||
const lowest = Math.min(...numbers);
|
||||
// D-E: neuer als jeder gelistete Zweig = frisch erschienen, noch nicht
|
||||
// bei endoflife.date; aelter als jeder gelistete = lange abgelaufen.
|
||||
if (triple[0] > highest) return result('green', 'current', { cycle: cycleNo });
|
||||
if (triple[0] < lowest) return result('red', 'eol-passed', { cycle: cycleNo });
|
||||
return result('unknown', 'no-reference', { cycle: cycleNo });
|
||||
}
|
||||
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const latest = parseVersionTriple(cycle.latest);
|
||||
const updateTo = latest && compareTriples(triple, latest) < 0 ? cycle.latest : null;
|
||||
|
||||
let eolDate: string | null = null;
|
||||
let daysToEol: number | null = null;
|
||||
if (cycle.eol === true) {
|
||||
return result('red', 'eol-passed', { cycle: cycleNo });
|
||||
}
|
||||
if (typeof cycle.eol === 'string') {
|
||||
eolDate = cycle.eol;
|
||||
daysToEol = dayDiff(today, cycle.eol);
|
||||
// Am Tag des Support-Endes ist die Version noch unterstuetzt (gelb),
|
||||
// erst der Tag danach ist rot.
|
||||
if (daysToEol < 0) return result('red', 'eol-passed', { cycle: cycleNo, eolDate });
|
||||
}
|
||||
|
||||
if (daysToEol !== null && daysToEol <= EOL_WARNING_DAYS) {
|
||||
return result('yellow', 'eol-soon', { cycle: cycleNo, eolDate, updateTo });
|
||||
}
|
||||
if (updateTo) {
|
||||
return result('yellow', 'update-available', { cycle: cycleNo, eolDate, updateTo });
|
||||
}
|
||||
return result('green', 'current', { cycle: cycleNo, eolDate });
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CACHE_TTL_MS,
|
||||
NextcloudReleaseService,
|
||||
parseEndOfLife,
|
||||
RETRY_BACKOFF_MS,
|
||||
} from './nextcloud-release.service';
|
||||
|
||||
const SAMPLE = [
|
||||
{ cycle: '35', eol: '2027-09-30', latest: '35.0.1' },
|
||||
{ cycle: '34', eol: '2027-06-30', latest: '34.0.4' },
|
||||
{ cycle: '10', eol: false, latest: '10.0.1' },
|
||||
];
|
||||
|
||||
function ok(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}
|
||||
|
||||
describe('parseEndOfLife', () => {
|
||||
it('liest gueltige Eintraege', () => {
|
||||
expect(parseEndOfLife(SAMPLE)).toEqual([
|
||||
{ cycle: 35, eol: '2027-09-30', latest: '35.0.1' },
|
||||
{ cycle: 34, eol: '2027-06-30', latest: '34.0.4' },
|
||||
{ cycle: 10, eol: false, latest: '10.0.1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ueberspringt Eintraege ohne numerischen Zweig oder gueltigen Patchstand', () => {
|
||||
expect(
|
||||
parseEndOfLife([
|
||||
{ cycle: 'x', eol: false, latest: '1.0.0' },
|
||||
{ cycle: '5', eol: false, latest: 'neu' },
|
||||
{ cycle: '6', eol: 'irgendwann', latest: '6.0.0' },
|
||||
{ cycle: '7', eol: true, latest: '7.0.0' },
|
||||
null,
|
||||
'text',
|
||||
]),
|
||||
).toEqual([{ cycle: 7, eol: true, latest: '7.0.0' }]);
|
||||
});
|
||||
|
||||
it('kein Array -> leer', () => {
|
||||
expect(parseEndOfLife({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudReleaseService', () => {
|
||||
let service: NextcloudReleaseService;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let nowMs: number;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new NextcloudReleaseService();
|
||||
fetchMock = vi.fn();
|
||||
nowMs = 1_000_000;
|
||||
service.fetchImpl = fetchMock as never;
|
||||
service.now = () => nowMs;
|
||||
});
|
||||
|
||||
it('erster Aufruf holt und wertet aus, zweiter innerhalb 12 h nicht', async () => {
|
||||
fetchMock.mockResolvedValue(ok(SAMPLE));
|
||||
const first = await service.getReference();
|
||||
expect(first?.cycles).toHaveLength(3);
|
||||
expect(first?.fetchedAt).toBe(new Date(nowMs).toISOString());
|
||||
nowMs += CACHE_TTL_MS - 1;
|
||||
await service.getReference();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('nach 12 h kommen die alten Daten sofort, im Hintergrund wird erneuert', async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
||||
await service.getReference();
|
||||
nowMs += CACHE_TTL_MS;
|
||||
fetchMock.mockResolvedValueOnce(ok([{ cycle: '36', eol: false, latest: '36.0.0' }]));
|
||||
const stale = await service.getReference();
|
||||
expect(stale?.cycles).toHaveLength(3);
|
||||
await service.refresh();
|
||||
const fresh = await service.getReference();
|
||||
expect(fresh?.cycles).toEqual([{ cycle: 36, eol: false, latest: '36.0.0' }]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Fehlschlag mit vorhandenen Daten behaelt sie', async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
||||
await service.getReference();
|
||||
nowMs += CACHE_TTL_MS;
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
await service.refresh();
|
||||
expect((await service.getReference())?.cycles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('Fehlschlag ohne Daten -> null; keine neue Anfrage innerhalb von 15 Minuten', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
expect(await service.getReference()).toBeNull();
|
||||
nowMs += RETRY_BACKOFF_MS - 1;
|
||||
expect(await service.getReference()).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
nowMs += 2;
|
||||
expect(await service.getReference()).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('HTTP-Fehler zaehlt als Fehlschlag', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('x', { status: 500 }));
|
||||
expect(await service.getReference()).toBeNull();
|
||||
});
|
||||
|
||||
it('Muell oder leeres Array behaelt die letzten guten Daten', async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
||||
await service.getReference();
|
||||
nowMs += CACHE_TTL_MS;
|
||||
fetchMock.mockResolvedValueOnce(ok([]));
|
||||
await service.refresh();
|
||||
nowMs += RETRY_BACKOFF_MS;
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 200 }));
|
||||
await service.refresh();
|
||||
expect((await service.getReference())?.cycles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('parallele Aufrufe teilen sich eine Anfrage', async () => {
|
||||
fetchMock.mockResolvedValue(ok(SAMPLE));
|
||||
const [a, b, c] = await Promise.all([
|
||||
service.getReference(),
|
||||
service.getReference(),
|
||||
service.getReference(),
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { fetch as undiciFetch } from 'undici';
|
||||
import type { NextcloudCycle, NextcloudReference } from './nextcloud-rating';
|
||||
|
||||
/** Quelle der Vergleichsdaten (L-04). */
|
||||
export const ENDOFLIFE_URL = 'https://endoflife.date/api/nextcloud.json';
|
||||
/** So lange gelten abgerufene Daten als frisch (12 Stunden). */
|
||||
export const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
/** Nach einem Fehlschlag wird fruehestens nach 15 Minuten neu versucht. */
|
||||
export const RETRY_BACKOFF_MS = 15 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
/**
|
||||
* Liest die Antwort von endoflife.date: nur Eintraege mit rein numerischem
|
||||
* Zweig, gepunktetem Patchstand und Support-Ende als Tag oder Wahrheitswert
|
||||
* kommen durch.
|
||||
*/
|
||||
export function parseEndOfLife(json: unknown): NextcloudCycle[] {
|
||||
if (!Array.isArray(json)) return [];
|
||||
const cycles: NextcloudCycle[] = [];
|
||||
for (const entry of json) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
const cycleRaw = typeof e.cycle === 'number' ? String(e.cycle) : e.cycle;
|
||||
if (typeof cycleRaw !== 'string' || !/^\d+$/.test(cycleRaw)) continue;
|
||||
if (typeof e.latest !== 'string' || !/^\d+\.\d+\.\d+(\.\d+)?$/.test(e.latest)) continue;
|
||||
let eol: string | boolean;
|
||||
if (typeof e.eol === 'boolean') eol = e.eol;
|
||||
else if (typeof e.eol === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.eol)) eol = e.eol;
|
||||
else continue;
|
||||
cycles.push({ cycle: Number(cycleRaw), eol, latest: e.latest });
|
||||
}
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwischenspeicher fuer die Versions-Vergleichsdaten (D-D): im Speicher,
|
||||
* 12 Stunden frisch. Veraltete Daten werden sofort geliefert und im
|
||||
* Hintergrund erneuert; nur ein leerer Speicher wird abgewartet. Nach einem
|
||||
* Fehlschlag gibt es 15 Minuten lang keinen neuen Versuch, gleichzeitige
|
||||
* Aufrufe teilen sich eine Anfrage. Ein Ausfall des Dienstes behaelt die
|
||||
* letzten guten Daten und stoert nie die Seite.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NextcloudReleaseService {
|
||||
private readonly logger = new Logger(NextcloudReleaseService.name);
|
||||
private data: NextcloudReference | null = null;
|
||||
private fetchedAtMs = 0;
|
||||
private lastFailureMs: number | null = null;
|
||||
private inflight: Promise<void> | null = null;
|
||||
|
||||
/** Pruefstellen: Tests ueberschreiben diese beiden Felder. */
|
||||
fetchImpl: typeof undiciFetch = undiciFetch;
|
||||
now: () => number = () => Date.now();
|
||||
|
||||
async getReference(): Promise<NextcloudReference | null> {
|
||||
if (!this.data) {
|
||||
await this.refresh();
|
||||
return this.data;
|
||||
}
|
||||
if (this.now() - this.fetchedAtMs >= CACHE_TTL_MS) {
|
||||
void this.refresh();
|
||||
}
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/** Ruft die Daten ab (geteilt, mit Fehlschlag-Pause); wirft nie. */
|
||||
refresh(): Promise<void> {
|
||||
if (this.inflight) return this.inflight;
|
||||
if (this.lastFailureMs !== null && this.now() - this.lastFailureMs < RETRY_BACKOFF_MS) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.inflight = this.load().finally(() => {
|
||||
this.inflight = null;
|
||||
});
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await this.fetchImpl(ENDOFLIFE_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'Tessera-Nextcloud-Status' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const text = await readCapped(response);
|
||||
const cycles = parseEndOfLife(JSON.parse(text));
|
||||
if (cycles.length === 0) throw new Error('keine verwertbaren Eintraege');
|
||||
this.fetchedAtMs = this.now();
|
||||
this.data = { cycles, fetchedAt: new Date(this.fetchedAtMs).toISOString() };
|
||||
this.lastFailureMs = null;
|
||||
} catch (err) {
|
||||
this.lastFailureMs = this.now();
|
||||
this.logger.warn(
|
||||
`Nextcloud-Vergleichsdaten nicht abrufbar: ${(err as Error).message}${this.data ? ' — letzten guten Daten bleiben erhalten' : ''}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCapped(response: Awaited<ReturnType<typeof undiciFetch>>): Promise<string> {
|
||||
if (!response.body) {
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text) > MAX_BYTES) throw new Error('Antwort zu gross');
|
||||
return text;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
let text = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_BYTES) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new Error('Antwort zu gross');
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchNextcloudStatus,
|
||||
MAX_STATUS_BYTES,
|
||||
normalizeCloudUrl,
|
||||
parseNextcloudStatus,
|
||||
} from './nextcloud-status-fetch';
|
||||
|
||||
const VALID = JSON.stringify({
|
||||
installed: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
version: '31.0.5.1',
|
||||
versionstring: '31.0.5',
|
||||
edition: '',
|
||||
productname: 'Nextcloud',
|
||||
extendedSupport: false,
|
||||
});
|
||||
|
||||
function res(body: string | null, status = 200, headers: Record<string, string> = {}) {
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
|
||||
function fetchOf(...responses: Array<Response | (() => Promise<Response>)>) {
|
||||
const fn = vi.fn();
|
||||
for (const r of responses) {
|
||||
if (typeof r === 'function') fn.mockImplementationOnce(r);
|
||||
else fn.mockResolvedValueOnce(r);
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe('normalizeCloudUrl', () => {
|
||||
it.each([
|
||||
[' https://cloud.kunde.de/ ', 'https://cloud.kunde.de'],
|
||||
['https://cloud.kunde.de/status.php', 'https://cloud.kunde.de'],
|
||||
['https://cloud.kunde.de/index.php', 'https://cloud.kunde.de'],
|
||||
['https://host/nextcloud/', 'https://host/nextcloud'],
|
||||
['https://host/nextcloud/index.php/?a=1#x', 'https://host/nextcloud'],
|
||||
['http://intern:8080', 'http://intern:8080'],
|
||||
])('%s -> %s', (input, expected) => {
|
||||
expect(normalizeCloudUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'ftp://host/x',
|
||||
'javascript:alert(1)',
|
||||
'https://user:pass@cloud.kunde.de',
|
||||
'',
|
||||
' ',
|
||||
'kein url',
|
||||
`https://host/${'a'.repeat(2100)}`,
|
||||
])('lehnt %s ab', (input) => {
|
||||
expect(normalizeCloudUrl(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNextcloudStatus', () => {
|
||||
it('liest ein gueltiges status.php', () => {
|
||||
expect(parseNextcloudStatus(VALID)).toEqual({
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '31.0.5',
|
||||
edition: null,
|
||||
productName: 'Nextcloud',
|
||||
});
|
||||
});
|
||||
|
||||
it('behaelt Wartungsmodus', () => {
|
||||
expect(
|
||||
parseNextcloudStatus(
|
||||
JSON.stringify({ installed: true, maintenance: true, versionstring: '31.0.5' }),
|
||||
)?.maintenance,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fehlendes needsDbUpgrade ist false, fehlender versionstring faellt auf version zurueck', () => {
|
||||
const parsed = parseNextcloudStatus(JSON.stringify({ installed: true, version: '31.0.5.1' }));
|
||||
expect(parsed?.needsDbUpgrade).toBe(false);
|
||||
expect(parsed?.versionString).toBe('31.0.5');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<html>Login</html>',
|
||||
'',
|
||||
'[]',
|
||||
'"text"',
|
||||
'42',
|
||||
JSON.stringify({ installed: false, version: '31.0.5.1' }),
|
||||
JSON.stringify({ version: '31.0.5.1' }),
|
||||
])('kein Nextcloud: %s', (input) => {
|
||||
expect(parseNextcloudStatus(input)).toBeNull();
|
||||
});
|
||||
|
||||
it('kuerzt lange Textfelder auf 64 Zeichen und verwirft ungueltige Versionstexte', () => {
|
||||
const parsed = parseNextcloudStatus(
|
||||
JSON.stringify({
|
||||
installed: true,
|
||||
versionstring: '<script>',
|
||||
edition: 'e'.repeat(200),
|
||||
productname: 'p'.repeat(200),
|
||||
}),
|
||||
);
|
||||
expect(parsed?.edition).toHaveLength(64);
|
||||
expect(parsed?.productName).toHaveLength(64);
|
||||
expect(parsed?.versionString).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchNextcloudStatus', () => {
|
||||
const BASE = 'https://cloud.kunde.de';
|
||||
|
||||
it('200 gueltig -> erreichbar mit Feldern, Anfrage ohne Zugangsdaten an <base>/status.php', async () => {
|
||||
const fetchImpl = fetchOf(res(VALID));
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
|
||||
expect(result).toMatchObject({
|
||||
reachable: true,
|
||||
versionString: '31.0.5',
|
||||
maintenance: false,
|
||||
errorKind: null,
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('https://cloud.kunde.de/status.php');
|
||||
const names = Object.keys(init.headers).map((h) => h.toLowerCase());
|
||||
expect(names).not.toContain('authorization');
|
||||
expect(names).not.toContain('cookie');
|
||||
expect(init.redirect).toBe('manual');
|
||||
});
|
||||
|
||||
it('200 Wartungsmodus -> erreichbar, maintenance true', async () => {
|
||||
const body = JSON.stringify({ installed: true, maintenance: true, versionstring: '31.0.5' });
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res(body)) as never });
|
||||
expect(result).toMatchObject({ reachable: true, maintenance: true });
|
||||
});
|
||||
|
||||
it('200 Muell -> not-nextcloud', async () => {
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res('<html>')) as never });
|
||||
expect(result).toMatchObject({ reachable: false, errorKind: 'not-nextcloud' });
|
||||
});
|
||||
|
||||
it('503 -> http-status mit Detail', async () => {
|
||||
const result = await fetchNextcloudStatus(BASE, {
|
||||
fetchImpl: fetchOf(res('down', 503)) as never,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
reachable: false,
|
||||
errorKind: 'http-status',
|
||||
errorDetail: 'HTTP 503',
|
||||
});
|
||||
});
|
||||
|
||||
it('haengende Anfrage -> timeout', async () => {
|
||||
const hang = () => new Promise<Response>(() => {});
|
||||
const result = await fetchNextcloudStatus(BASE, {
|
||||
fetchImpl: fetchOf(hang) as never,
|
||||
timeoutMs: 20,
|
||||
});
|
||||
expect(result).toMatchObject({ reachable: false, errorKind: 'timeout' });
|
||||
});
|
||||
|
||||
it('abgelehnte Anfrage mit cause.code -> network', async () => {
|
||||
const reject = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }),
|
||||
);
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(reject) as never });
|
||||
expect(result).toMatchObject({ errorKind: 'network', errorDetail: 'ENOTFOUND' });
|
||||
});
|
||||
|
||||
it('Zertifikatsfehler -> tls', async () => {
|
||||
const reject = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new TypeError('fetch failed'), { cause: { code: 'CERT_HAS_EXPIRED' } }),
|
||||
);
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(reject) as never });
|
||||
expect(result).toMatchObject({ errorKind: 'tls', errorDetail: 'CERT_HAS_EXPIRED' });
|
||||
});
|
||||
|
||||
it('301 mit relativer Adresse wird einmal gefolgt', async () => {
|
||||
const fetchImpl = fetchOf(res(null, 301, { location: '/nextcloud/status.php' }), res(VALID));
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(fetchImpl.mock.calls[1][0]).toBe('https://cloud.kunde.de/nextcloud/status.php');
|
||||
});
|
||||
|
||||
it('Weiterleitung auf ftp: -> redirect', async () => {
|
||||
const result = await fetchNextcloudStatus(BASE, {
|
||||
fetchImpl: fetchOf(res(null, 302, { location: 'ftp://x/status.php' })) as never,
|
||||
});
|
||||
expect(result).toMatchObject({ errorKind: 'redirect' });
|
||||
});
|
||||
|
||||
it('vier Weiterleitungen in Folge -> redirect', async () => {
|
||||
const hop = () => res(null, 302, { location: '/status.php' });
|
||||
const fetchImpl = fetchOf(hop(), hop(), hop(), hop(), res(VALID));
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
|
||||
expect(result).toMatchObject({ errorKind: 'redirect' });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('Antwort ueber 64 KiB -> too-large', async () => {
|
||||
const big = 'x'.repeat(MAX_STATUS_BYTES + 10);
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res(big)) as never });
|
||||
expect(result).toMatchObject({ errorKind: 'too-large' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { fetch as undiciFetch } from 'undici';
|
||||
|
||||
/**
|
||||
* nextcloud-status-fetch — Abruf und Auswertung von `<Adresse>/status.php`
|
||||
* (quick-261002-k67, L-03). Reine Funktionen plus ein Abruf mit einsetzbarem
|
||||
* `fetch`, damit Tests ohne Netz auskommen.
|
||||
*/
|
||||
|
||||
/** Zeitgrenze fuer die gesamte Anfrage (inkl. Weiterleitungen und Lesen). */
|
||||
export const STATUS_TIMEOUT_MS = 10_000;
|
||||
/** Hoechstzahl gefolgter Weiterleitungen. */
|
||||
export const MAX_REDIRECTS = 3;
|
||||
/** Groessendeckel der Antwort. */
|
||||
export const MAX_STATUS_BYTES = 64 * 1024;
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
const MAX_TEXT_FIELD = 64;
|
||||
const MAX_VERSION_FIELD = 32;
|
||||
const MAX_DETAIL = 120;
|
||||
|
||||
export type NextcloudErrorKind =
|
||||
| 'timeout'
|
||||
| 'network'
|
||||
| 'tls'
|
||||
| 'http-status'
|
||||
| 'not-nextcloud'
|
||||
| 'too-large'
|
||||
| 'redirect';
|
||||
|
||||
export interface ParsedNextcloudStatus {
|
||||
maintenance: boolean;
|
||||
needsDbUpgrade: boolean;
|
||||
versionString: string | null;
|
||||
edition: string | null;
|
||||
productName: string | null;
|
||||
}
|
||||
|
||||
export interface NextcloudCheckResult {
|
||||
reachable: boolean;
|
||||
maintenance: boolean | null;
|
||||
needsDbUpgrade: boolean | null;
|
||||
versionString: string | null;
|
||||
edition: string | null;
|
||||
productName: string | null;
|
||||
errorKind: NextcloudErrorKind | null;
|
||||
errorDetail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt eine eingegebene Cloud-Adresse in Normalform: nur http/https, ohne
|
||||
* Zugangsdaten, ohne Abfrageteil/Anker, ohne `/status.php`, `/index.php` und
|
||||
* ohne abschliessenden Schrägstrich (Unterpfade wie `/nextcloud` bleiben).
|
||||
* Ungueltiges ergibt `null`.
|
||||
*/
|
||||
export function normalizeCloudUrl(raw: string): string | null {
|
||||
const trimmed = (raw ?? '').trim();
|
||||
if (!trimmed || trimmed.length > MAX_URL_LENGTH) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
if (url.username || url.password) return null;
|
||||
if (!url.hostname) return null;
|
||||
let path = url.pathname.replace(/\/+$/, '');
|
||||
path = path.replace(/\/(status|index)\.php$/i, '').replace(/\/+$/, '');
|
||||
const normalized = `${url.protocol}//${url.host}${path}`;
|
||||
return normalized.length > MAX_URL_LENGTH ? null : normalized;
|
||||
}
|
||||
|
||||
function cleanVersion(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > MAX_VERSION_FIELD || !/^[0-9.]+$/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function cleanText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, MAX_TEXT_FIELD) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Antwort von `status.php`. Nur Felder aus einer festen Liste
|
||||
* werden uebernommen; alles, was kein JSON-Objekt mit `installed: true` ist,
|
||||
* ergibt `null` (nicht Nextcloud).
|
||||
*/
|
||||
export function parseNextcloudStatus(text: string): ParsedNextcloudStatus | null {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
|
||||
const obj = json as Record<string, unknown>;
|
||||
if (obj.installed !== true) return null;
|
||||
|
||||
let versionString = cleanVersion(obj.versionstring);
|
||||
if (!versionString) {
|
||||
const full = cleanVersion(obj.version);
|
||||
versionString = full ? full.split('.').slice(0, 3).join('.') : null;
|
||||
}
|
||||
|
||||
return {
|
||||
maintenance: obj.maintenance === true,
|
||||
needsDbUpgrade: obj.needsDbUpgrade === true,
|
||||
versionString,
|
||||
edition: cleanText(obj.edition),
|
||||
productName: cleanText(obj.productname),
|
||||
};
|
||||
}
|
||||
|
||||
const TLS_CODES = new Set([
|
||||
'CERT_HAS_EXPIRED',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
'CERT_NOT_YET_VALID',
|
||||
'CERT_UNTRUSTED',
|
||||
'CERT_REVOKED',
|
||||
'HOSTNAME_MISMATCH',
|
||||
]);
|
||||
|
||||
function failure(errorKind: NextcloudErrorKind, errorDetail: string | null): NextcloudCheckResult {
|
||||
return {
|
||||
reachable: false,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
productName: null,
|
||||
errorKind,
|
||||
errorDetail: errorDetail ? errorDetail.slice(0, MAX_DETAIL) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(err: unknown): string | null {
|
||||
const e = err as { code?: unknown; cause?: { code?: unknown } } | null;
|
||||
const code = e?.cause?.code ?? e?.code;
|
||||
return typeof code === 'string' && /^[A-Z0-9_]{2,80}$/.test(code) ? code : null;
|
||||
}
|
||||
|
||||
class TooLargeError extends Error {}
|
||||
|
||||
async function readCapped(
|
||||
response: { body: ReadableStream<Uint8Array> | null; text(): Promise<string> },
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
if (!response.body) {
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text) > maxBytes) throw new TooLargeError();
|
||||
return text;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
let text = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new TooLargeError();
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
}
|
||||
|
||||
function discard(response: { body?: { cancel(): Promise<void> } | null }): void {
|
||||
try {
|
||||
response.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
// schon verbraucht — nichts zu tun
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchStatusOptions {
|
||||
fetchImpl?: typeof undiciFetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt `<baseUrl>/status.php` ab und fasst das Ergebnis zusammen.
|
||||
*
|
||||
* SSRF-Hinweis (L-03, wie bei Proxmox T-DHH-02): die Adresse wird vom
|
||||
* Verwalter eingegeben, interne Adressen sind mit Absicht erlaubt (Clouds
|
||||
* stehen oft im Haus). Begrenzungen: nur GET auf `<Adresse>/status.php`,
|
||||
* keine Zugangsdaten oder Cookies, hoechstens 3 Weiterleitungen (nur
|
||||
* http/https), 10 Sekunden fuer die gesamte Anfrage, 64 KiB Antwort. Zum
|
||||
* Browser gelangen nur die ausgewerteten, auf eine feste Liste beschraenkten
|
||||
* Felder — nie ein Antworttext, und als Fehlerdetail nur eigene Kurzkennungen
|
||||
* (z. B. 'HTTP 503', 'ENOTFOUND'). Wer Verwalten darf, kann dadurch
|
||||
* erfahren, ob eine interne Adresse antwortet — bewusst akzeptiert.
|
||||
* Zertifikate werden geprueft (kein Abschalten, D-H).
|
||||
*/
|
||||
export async function fetchNextcloudStatus(
|
||||
baseUrl: string,
|
||||
opts: FetchStatusOptions = {},
|
||||
): Promise<NextcloudCheckResult> {
|
||||
const fetchImpl = opts.fetchImpl ?? undiciFetch;
|
||||
const timeoutMs = opts.timeoutMs ?? STATUS_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// Fuer haengende Server, die ein abgebrochenes fetch/read nicht beenden.
|
||||
const aborted = new Promise<'timeout'>((resolve) => {
|
||||
controller.signal.addEventListener('abort', () => resolve('timeout'));
|
||||
});
|
||||
|
||||
const run = async (): Promise<NextcloudCheckResult> => {
|
||||
let current = `${baseUrl}/status.php`;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
let response: Awaited<ReturnType<typeof undiciFetch>>;
|
||||
try {
|
||||
response = await fetchImpl(current, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'Tessera-Nextcloud-Status' },
|
||||
});
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return failure('timeout', null);
|
||||
const code = errorCode(err);
|
||||
if (code && TLS_CODES.has(code)) return failure('tls', code);
|
||||
return failure('network', code);
|
||||
}
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
discard(response);
|
||||
if (!location) return failure('redirect', 'Keine Weiterleitungsadresse');
|
||||
let next: URL;
|
||||
try {
|
||||
next = new URL(location, current);
|
||||
} catch {
|
||||
return failure('redirect', 'Ungueltige Weiterleitung');
|
||||
}
|
||||
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
||||
return failure('redirect', 'Weiterleitung nicht http/https');
|
||||
}
|
||||
if (hop === MAX_REDIRECTS) return failure('redirect', 'Zu viele Weiterleitungen');
|
||||
current = next.toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
discard(response);
|
||||
return failure('http-status', `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = await readCapped(response as never, MAX_STATUS_BYTES);
|
||||
} catch (err) {
|
||||
if (err instanceof TooLargeError) return failure('too-large', null);
|
||||
if (controller.signal.aborted) return failure('timeout', null);
|
||||
return failure('network', errorCode(err));
|
||||
}
|
||||
|
||||
const parsed = parseNextcloudStatus(text);
|
||||
if (!parsed) return failure('not-nextcloud', null);
|
||||
return {
|
||||
reachable: true,
|
||||
maintenance: parsed.maintenance,
|
||||
needsDbUpgrade: parsed.needsDbUpgrade,
|
||||
versionString: parsed.versionString,
|
||||
edition: parsed.edition,
|
||||
productName: parsed.productName,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
};
|
||||
}
|
||||
return failure('redirect', 'Zu viele Weiterleitungen');
|
||||
};
|
||||
|
||||
try {
|
||||
const outcome = await Promise.race([run(), aborted]);
|
||||
return outcome === 'timeout' ? failure('timeout', null) : outcome;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'reflect-metadata';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from '../module-registry/module.guard';
|
||||
import { NextcloudStatusController } from './nextcloud-status.controller';
|
||||
|
||||
const proto = NextcloudStatusController.prototype as unknown as Record<string, unknown>;
|
||||
|
||||
describe('NextcloudStatusController Metadaten', () => {
|
||||
it('Klasse traegt Modul-Slug und ModuleGuard', () => {
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudStatusController)).toBe(
|
||||
'nextcloud-status',
|
||||
);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard);
|
||||
});
|
||||
|
||||
it('list bleibt auf Benutzen-Ebene', () => {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.list as object)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto.list as object)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['create', 'checkOne'])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => {
|
||||
const fn = proto[name] as object;
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true);
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status');
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
import { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
|
||||
/**
|
||||
* `@UseModule('nextcloud-status')` auf Klassenebene — Aktivierung UND
|
||||
* Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich
|
||||
* aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query.
|
||||
*
|
||||
* Rechte (L-09): Lesen (`GET instances`, Logo-Abruf) steht jedem Benutzer mit
|
||||
* Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
|
||||
* `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der
|
||||
* Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein
|
||||
* Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren.
|
||||
*
|
||||
* Routenreihenfolge: statische Pfade (`instances/check`) stehen VOR allen
|
||||
* Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing).
|
||||
*/
|
||||
@Controller('modules/nextcloud-status')
|
||||
@UseModule('nextcloud-status')
|
||||
export class NextcloudStatusController {
|
||||
constructor(private readonly service: NextcloudStatusService) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('Kein Mandantenkontext');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
@Get('instances')
|
||||
async list(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.listForTenant(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Post('instances')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateNextcloudInstanceDto) {
|
||||
return this.service.createInstance(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Post('instances/:id/check')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.checkInstance(this.requireTenantId(req), id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { NextcloudReleaseService } from './nextcloud-release.service';
|
||||
import { NextcloudStatusController } from './nextcloud-status.controller';
|
||||
import { seedNextcloudStatusModule } from './nextcloud-status.seed';
|
||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
|
||||
/**
|
||||
* NestJS module for the Nextcloud-Status feature (quick-261002-k67).
|
||||
* Vorbild `ProxmoxModule`: seeds itself into the module registry on startup.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [NextcloudStatusController],
|
||||
providers: [NextcloudStatusService, NextcloudReleaseService],
|
||||
})
|
||||
export class NextcloudStatusModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(NextcloudStatusModule.name);
|
||||
|
||||
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await seedNextcloudStatusModule(this.moduleRegistryService);
|
||||
this.logger.log('Nextcloud-Status module seeded in registry');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed nextcloud-status module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
|
||||
/**
|
||||
* Seeds the nextcloud-status module into the module registry
|
||||
* (quick-261002-k67). Vorbild `proxmox.seed.ts`; Kategorie
|
||||
* `infrastructure`. Der Slug ist zugleich der Wert in `@UseModule` und in
|
||||
* `WIDGET_MODULE_SLUGS` (@tessera/shared).
|
||||
*/
|
||||
export async function seedNextcloudStatusModule(
|
||||
moduleRegistryService: ModuleRegistryService,
|
||||
): Promise<void> {
|
||||
await moduleRegistryService.seedModule({
|
||||
slug: 'nextcloud-status',
|
||||
name: 'Nextcloud-Status',
|
||||
version: '1.0.0',
|
||||
category: 'infrastructure',
|
||||
description: {
|
||||
de: 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick',
|
||||
en: 'Keep track of versions and availability of your Nextcloud clouds',
|
||||
},
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
forSystem: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./nextcloud-status-fetch')>();
|
||||
return { ...actual, fetchNextcloudStatus: vi.fn() };
|
||||
});
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { NextcloudReference } from './nextcloud-rating';
|
||||
import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service';
|
||||
import { fetchNextcloudStatus } from './nextcloud-status-fetch';
|
||||
|
||||
const REFERENCE: NextcloudReference = {
|
||||
fetchedAt: '2026-10-02T10:00:00.000Z',
|
||||
cycles: [{ cycle: 35, eol: '2099-09-30', latest: '35.0.1' }],
|
||||
};
|
||||
|
||||
function makeRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'i1',
|
||||
tenantId: 't1',
|
||||
customerName: 'Kunde A',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
logoUrl: null,
|
||||
logoMime: null,
|
||||
logoVersion: 0,
|
||||
lastCheckedAt: new Date('2026-10-02T11:00:00Z'),
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NextcloudStatusService', () => {
|
||||
let prisma: { nextcloudInstance: Record<string, ReturnType<typeof vi.fn>> };
|
||||
let release: { getReference: ReturnType<typeof vi.fn> };
|
||||
let service: NextcloudStatusService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchNextcloudStatus).mockReset();
|
||||
vi.mocked(forTenant).mockClear();
|
||||
prisma = {
|
||||
nextcloudInstance: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
release = { getReference: vi.fn().mockResolvedValue(REFERENCE) };
|
||||
service = new NextcloudStatusService(prisma as never, release as never);
|
||||
});
|
||||
|
||||
it('listForTenant waehlt keine Logo-Bytes und liefert Bewertung und neueste Version', async () => {
|
||||
prisma.nextcloudInstance.findMany.mockResolvedValue([
|
||||
makeRow(),
|
||||
makeRow({ id: 'i2', logoMime: 'image/png', logoVersion: 3 }),
|
||||
]);
|
||||
const result = await service.listForTenant('t1');
|
||||
const args = prisma.nextcloudInstance.findMany.mock.calls[0][0];
|
||||
expect(args.select).toBe(PUBLIC_SELECT);
|
||||
expect(args.select).not.toHaveProperty('logoData');
|
||||
expect(args.where).toEqual({ tenantId: 't1' });
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(result.reference).toEqual({ newestVersion: '35.0.1', fetchedAt: REFERENCE.fetchedAt });
|
||||
expect(result.instances).toHaveLength(2);
|
||||
expect(result.instances[0].rating).toMatchObject({ level: 'green', reason: 'current' });
|
||||
expect(result.instances[0].hasUploadedLogo).toBe(false);
|
||||
expect(result.instances[1]).toMatchObject({ hasUploadedLogo: true, logoVersion: 3 });
|
||||
expect(JSON.stringify(result)).not.toContain('logoData');
|
||||
});
|
||||
|
||||
it('listForTenant ohne Vergleichsdaten bewertet grau', async () => {
|
||||
release.getReference.mockResolvedValue(null);
|
||||
prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow()]);
|
||||
const result = await service.listForTenant('t1');
|
||||
expect(result.reference).toEqual({ newestVersion: null, fetchedAt: null });
|
||||
expect(result.instances[0].rating.level).toBe('unknown');
|
||||
});
|
||||
|
||||
it('createInstance normalisiert die Adresse, legt an und prueft sofort', async () => {
|
||||
prisma.nextcloudInstance.create.mockResolvedValue({ id: 'i1' });
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
id: 'i1',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
});
|
||||
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
productName: 'Nextcloud',
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
});
|
||||
prisma.nextcloudInstance.update.mockResolvedValue(makeRow());
|
||||
const view = await service.createInstance('t1', {
|
||||
customerName: ' Kunde A ',
|
||||
baseUrl: 'https://cloud.a.de/status.php/',
|
||||
});
|
||||
expect(prisma.nextcloudInstance.create.mock.calls[0][0].data).toEqual({
|
||||
tenantId: 't1',
|
||||
customerName: 'Kunde A',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
logoUrl: null,
|
||||
});
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledWith('https://cloud.a.de');
|
||||
expect(view.id).toBe('i1');
|
||||
});
|
||||
|
||||
it('createInstance mit ungueltiger Adresse -> BadRequest mit deutscher Meldung', async () => {
|
||||
await expect(
|
||||
service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
await expect(
|
||||
service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }),
|
||||
).rejects.toThrow('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.');
|
||||
expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checkInstance schreibt alle Statusspalten', async () => {
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
id: 'i1',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
});
|
||||
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
|
||||
reachable: false,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
productName: null,
|
||||
errorKind: 'http-status',
|
||||
errorDetail: 'HTTP 503',
|
||||
});
|
||||
prisma.nextcloudInstance.update.mockResolvedValue(
|
||||
makeRow({
|
||||
reachable: false,
|
||||
versionString: null,
|
||||
errorKind: 'http-status',
|
||||
errorDetail: 'HTTP 503',
|
||||
}),
|
||||
);
|
||||
const view = await service.checkInstance('t1', 'i1');
|
||||
expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
|
||||
id: 'i1',
|
||||
tenantId: 't1',
|
||||
});
|
||||
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
|
||||
expect(data).toMatchObject({
|
||||
reachable: false,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
errorKind: 'http-status',
|
||||
errorDetail: 'HTTP 503',
|
||||
});
|
||||
expect(data.lastCheckedAt).toBeInstanceOf(Date);
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT);
|
||||
expect(view.rating).toMatchObject({ level: 'red', reason: 'unreachable' });
|
||||
});
|
||||
|
||||
it('checkInstance fuer unbekannte Kennung -> NotFound, kein Abruf', async () => {
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
|
||||
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
||||
import {
|
||||
type NextcloudRating,
|
||||
type NextcloudReference,
|
||||
newestVersion,
|
||||
rateNextcloud,
|
||||
} from './nextcloud-rating';
|
||||
import { NextcloudReleaseService } from './nextcloud-release.service';
|
||||
import { fetchNextcloudStatus, normalizeCloudUrl } from './nextcloud-status-fetch';
|
||||
|
||||
/**
|
||||
* Alle Spalten ausser den Logo-Bytes (T-k67-07): Listen- und
|
||||
* Pruefabfragen holen `logoData` nie aus der Datenbank, nur der
|
||||
* Logo-Abruf. `logoMime` ist genau dann gesetzt, wenn ein Upload vorliegt.
|
||||
*/
|
||||
export const PUBLIC_SELECT = {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
customerName: true,
|
||||
baseUrl: true,
|
||||
logoUrl: true,
|
||||
logoMime: true,
|
||||
logoVersion: true,
|
||||
lastCheckedAt: true,
|
||||
reachable: true,
|
||||
maintenance: true,
|
||||
needsDbUpgrade: true,
|
||||
versionString: true,
|
||||
edition: true,
|
||||
errorKind: true,
|
||||
errorDetail: true,
|
||||
} as const;
|
||||
|
||||
type PublicRow = {
|
||||
id: string;
|
||||
customerName: string;
|
||||
baseUrl: string;
|
||||
logoUrl: string | null;
|
||||
logoMime: string | null;
|
||||
logoVersion: number;
|
||||
lastCheckedAt: Date | null;
|
||||
reachable: boolean | null;
|
||||
maintenance: boolean | null;
|
||||
needsDbUpgrade: boolean | null;
|
||||
versionString: string | null;
|
||||
edition: string | null;
|
||||
errorKind: string | null;
|
||||
errorDetail: string | null;
|
||||
};
|
||||
|
||||
export interface NextcloudInstanceView {
|
||||
id: string;
|
||||
customerName: string;
|
||||
baseUrl: string;
|
||||
logoUrl: string | null;
|
||||
hasUploadedLogo: boolean;
|
||||
logoVersion: number;
|
||||
status: {
|
||||
checkedAt: string | null;
|
||||
reachable: boolean | null;
|
||||
maintenance: boolean | null;
|
||||
needsDbUpgrade: boolean | null;
|
||||
versionString: string | null;
|
||||
edition: string | null;
|
||||
errorKind: string | null;
|
||||
errorDetail: string | null;
|
||||
};
|
||||
rating: NextcloudRating;
|
||||
}
|
||||
|
||||
export interface NextcloudListView {
|
||||
instances: NextcloudInstanceView[];
|
||||
reference: { newestVersion: string | null; fetchedAt: string | null };
|
||||
}
|
||||
|
||||
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
|
||||
|
||||
@Injectable()
|
||||
export class NextcloudStatusService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly release: NextcloudReleaseService,
|
||||
) {}
|
||||
|
||||
private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView {
|
||||
const status = {
|
||||
checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
|
||||
reachable: row.reachable,
|
||||
maintenance: row.maintenance,
|
||||
needsDbUpgrade: row.needsDbUpgrade,
|
||||
versionString: row.versionString,
|
||||
edition: row.edition,
|
||||
errorKind: row.errorKind,
|
||||
errorDetail: row.errorDetail,
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
customerName: row.customerName,
|
||||
baseUrl: row.baseUrl,
|
||||
logoUrl: row.logoUrl,
|
||||
hasUploadedLogo: row.logoMime !== null,
|
||||
logoVersion: row.logoVersion,
|
||||
status,
|
||||
rating: rateNextcloud(status, reference, new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Alle Clouds des Mandanten samt Bewertung zum Lesezeitpunkt (D-B). */
|
||||
async listForTenant(tenantId: string): Promise<NextcloudListView> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const [rows, reference] = await Promise.all([
|
||||
tenantPrisma.nextcloudInstance.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { customerName: 'asc' },
|
||||
select: PUBLIC_SELECT,
|
||||
}),
|
||||
this.release.getReference(),
|
||||
]);
|
||||
return {
|
||||
instances: rows.map((row) => this.toView(row as PublicRow, reference)),
|
||||
reference: {
|
||||
newestVersion: newestVersion(reference),
|
||||
fetchedAt: reference?.fetchedAt ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Legt eine Cloud an und fuehrt sofort die erste Pruefung aus. */
|
||||
async createInstance(
|
||||
tenantId: string,
|
||||
dto: CreateNextcloudInstanceDto,
|
||||
): Promise<NextcloudInstanceView> {
|
||||
const baseUrl = normalizeCloudUrl(dto.baseUrl);
|
||||
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const created = await tenantPrisma.nextcloudInstance.create({
|
||||
data: {
|
||||
tenantId,
|
||||
customerName: dto.customerName.trim(),
|
||||
baseUrl,
|
||||
logoUrl: dto.logoUrl ? dto.logoUrl : null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return this.checkInstance(tenantId, created.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und
|
||||
* schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404.
|
||||
*/
|
||||
async checkInstance(tenantId: string, id: string): Promise<NextcloudInstanceView> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true, baseUrl: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
|
||||
const result = await fetchNextcloudStatus(existing.baseUrl);
|
||||
const updated = await tenantPrisma.nextcloudInstance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
reachable: result.reachable,
|
||||
maintenance: result.maintenance,
|
||||
needsDbUpgrade: result.needsDbUpgrade,
|
||||
versionString: result.versionString,
|
||||
edition: result.edition,
|
||||
productName: result.productName,
|
||||
errorKind: result.errorKind,
|
||||
errorDetail: result.errorDetail,
|
||||
lastCheckedAt: new Date(),
|
||||
},
|
||||
select: PUBLIC_SELECT,
|
||||
});
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user