feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung
- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten - Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden - Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt - Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { DkvController } from '../dkv/dkv.controller';
|
||||
import { ModuleGrantsController } from '../groups/module-grants.controller';
|
||||
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
|
||||
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
|
||||
import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
|
||||
import { ProxmoxController } from '../proxmox/proxmox.controller';
|
||||
import { TendersController } from '../tenders/tenders.controller';
|
||||
import { ModuleRegistryController } from './module-registry.controller';
|
||||
@@ -70,6 +71,19 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])(
|
||||
'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status',
|
||||
(name) => {
|
||||
expectManage(NextcloudStatusController, name, 'nextcloud-status');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['list', 'logo'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
const fn = handler(NextcloudStatusController, name);
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
|
||||
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
|
||||
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { checkLogoUpload, NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
|
||||
|
||||
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
|
||||
const GIF = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
const WEBP = [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50];
|
||||
|
||||
describe('checkLogoUpload', () => {
|
||||
it.each([
|
||||
['PNG', PNG, 'image/png'],
|
||||
['JPEG', JPEG, 'image/jpeg'],
|
||||
['GIF', GIF, 'image/gif'],
|
||||
['WebP', WEBP, 'image/webp'],
|
||||
])('erkennt %s', (_name, bytes, mime) => {
|
||||
expect(checkLogoUpload(Buffer.from([...bytes, 1, 2, 3]))).toBe(mime);
|
||||
});
|
||||
|
||||
it('lehnt SVG-Text, PDF, leere Dateien und umbenanntes HTML ab', () => {
|
||||
expect(
|
||||
checkLogoUpload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
||||
).toBeNull();
|
||||
expect(checkLogoUpload(Buffer.from('%PDF-1.7 ...'))).toBeNull();
|
||||
expect(checkLogoUpload(Buffer.alloc(0))).toBeNull();
|
||||
expect(checkLogoUpload(Buffer.from('<html><script>alert(1)</script></html>'))).toBeNull();
|
||||
});
|
||||
|
||||
it('genau 1 MiB ist erlaubt, ein Byte mehr nicht', () => {
|
||||
const ok = Buffer.concat([
|
||||
Buffer.from(PNG),
|
||||
Buffer.alloc(NEXTCLOUD_LOGO_MAX_BYTES - PNG.length),
|
||||
]);
|
||||
expect(ok.length).toBe(NEXTCLOUD_LOGO_MAX_BYTES);
|
||||
expect(checkLogoUpload(ok)).toBe('image/png');
|
||||
expect(checkLogoUpload(Buffer.concat([ok, Buffer.from([0])]))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type DashboardImageMime, detectImageMime } from '../dashboard/dashboard-image-rules';
|
||||
|
||||
/**
|
||||
* Regeln fuer hochgeladene Cloud-Logos (quick-261002-k67, L-02, D-A).
|
||||
* Kein Nest, kein Prisma — direkt an den Bytes testbar.
|
||||
*
|
||||
* Erlaubt sind PNG, JPEG, GIF und WebP bis 1 MiB. KEIN SVG: ein SVG kann
|
||||
* Skript tragen und wuerde, direkt im Tab geoeffnet, unter der Adresse von
|
||||
* Tessera laufen. Der Typ kommt aus den Magic Bytes, nie aus dem vom Browser
|
||||
* behaupteten `mimetype` oder der Dateiendung (Muster T-PI9-01); der erkannte
|
||||
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-k67-02).
|
||||
*/
|
||||
|
||||
/** Hoechstgroesse: 1 MiB (multer `limits.fileSize` an der Route + zweites Netz im Dienst). */
|
||||
export const NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/** Erkannter Bildtyp oder `null`, wenn leer, zu gross oder kein erlaubtes Bildformat. */
|
||||
export function checkLogoUpload(buffer: Uint8Array): DashboardImageMime | null {
|
||||
if (buffer.length === 0 || buffer.length > NEXTCLOUD_LOGO_MAX_BYTES) return null;
|
||||
return detectImageMime(buffer);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NEXTCLOUD_CRON,
|
||||
NEXTCLOUD_JOB_NAME,
|
||||
NextcloudStatusSchedulerService,
|
||||
} from './nextcloud-status-scheduler.service';
|
||||
|
||||
function makeFakeRegistry() {
|
||||
const jobs = new Map<string, any>();
|
||||
return {
|
||||
__jobs: jobs,
|
||||
addCronJob: vi.fn((name: string, job: any) => {
|
||||
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
|
||||
jobs.set(name, job);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: string[] = []) {
|
||||
const registry = makeFakeRegistry();
|
||||
const calls: Array<[string, string]> = [];
|
||||
const service = {
|
||||
loadAllInstancesForScheduler: vi.fn(async () => rows),
|
||||
checkInstance: vi.fn(async (tenantId: string, id: string) => {
|
||||
calls.push([tenantId, id]);
|
||||
if (failFor.includes(id)) throw new Error(`boom ${id}`);
|
||||
}),
|
||||
};
|
||||
const release = { refresh: vi.fn(async () => undefined) };
|
||||
const scheduler = new NextcloudStatusSchedulerService(
|
||||
registry as any,
|
||||
service as any,
|
||||
release as any,
|
||||
);
|
||||
const logger = (scheduler as any).logger;
|
||||
const logSpy = vi.spyOn(logger, 'log').mockImplementation(() => undefined);
|
||||
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
||||
return { registry, service, release, scheduler, calls, logSpy, errorSpy, warnSpy };
|
||||
}
|
||||
|
||||
describe('NextcloudStatusSchedulerService', () => {
|
||||
it('registriert genau einen stuendlichen Auftrag ohne Datenbankzugriff und startet ihn', async () => {
|
||||
const { registry, service, release, scheduler } = makeScheduler();
|
||||
await scheduler.onApplicationBootstrap();
|
||||
expect(registry.addCronJob).toHaveBeenCalledTimes(1);
|
||||
expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true);
|
||||
const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME);
|
||||
expect(job.cronTime.source).toBe(NEXTCLOUD_CRON);
|
||||
expect(NEXTCLOUD_CRON).toBe('0 * * * *');
|
||||
expect(job.isActive ?? job.running).toBeTruthy();
|
||||
expect(service.loadAllInstancesForScheduler).not.toHaveBeenCalled();
|
||||
expect(release.refresh).toHaveBeenCalledTimes(1);
|
||||
job.stop();
|
||||
});
|
||||
|
||||
it('ein Fehler beim Start wird protokolliert und nicht weitergeworfen', async () => {
|
||||
const { registry, scheduler, errorSpy } = makeScheduler();
|
||||
registry.addCronJob.mockImplementation(() => {
|
||||
throw new Error('registry kaputt');
|
||||
});
|
||||
await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tick prueft jede Cloud an ihren eigenen Mandanten gebunden', async () => {
|
||||
const rows = [
|
||||
{ id: 'a1', tenantId: 'tA' },
|
||||
{ id: 'b1', tenantId: 'tB' },
|
||||
{ id: 'a2', tenantId: 'tA' },
|
||||
];
|
||||
const { scheduler, calls } = makeScheduler(rows);
|
||||
await scheduler.tick();
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls).toContainEqual(['tA', 'a1']);
|
||||
expect(calls).toContainEqual(['tA', 'a2']);
|
||||
expect(calls).toContainEqual(['tB', 'b1']);
|
||||
});
|
||||
|
||||
it('eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
|
||||
const rows = [
|
||||
{ id: 'x', tenantId: 't' },
|
||||
{ id: 'y', tenantId: 't' },
|
||||
{ id: 'z', tenantId: 't' },
|
||||
];
|
||||
const { scheduler, calls, errorSpy } = makeScheduler(rows, ['x']);
|
||||
await scheduler.tick();
|
||||
expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y', 'z']);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ein Durchlauf, waehrend der vorige noch laeuft, wird uebersprungen', async () => {
|
||||
const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]);
|
||||
let release!: () => void;
|
||||
service.checkInstance.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const first = scheduler.tick();
|
||||
await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1));
|
||||
await scheduler.tick();
|
||||
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
release();
|
||||
await first;
|
||||
await scheduler.tick();
|
||||
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { NextcloudReleaseService } from './nextcloud-release.service';
|
||||
import {
|
||||
CHECK_CONCURRENCY,
|
||||
NextcloudStatusService,
|
||||
runWithConcurrency,
|
||||
} from './nextcloud-status.service';
|
||||
|
||||
/**
|
||||
* CronJob constructor — resolved at runtime via require() because `cron` is
|
||||
* a transitive dependency of @nestjs/schedule (not a direct api dep under
|
||||
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
|
||||
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
|
||||
* it as a peer dep. Reuses the exact ProxmoxSchedulerService resolution
|
||||
* workaround verbatim.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
require('cron').CronJob as new (
|
||||
cronTime: string,
|
||||
onTick: () => void,
|
||||
) => { start(): void };
|
||||
|
||||
/** Name des Auftrags in der Registry. */
|
||||
export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll';
|
||||
/** Jede volle Stunde (L-08). */
|
||||
export const NEXTCLOUD_CRON = '0 * * * *';
|
||||
|
||||
/**
|
||||
* NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds
|
||||
* (quick-261002-k67, L-08, D-C).
|
||||
*
|
||||
* Lebenszyklus: `OnApplicationBootstrap`, NICHT `OnModuleInit` — die
|
||||
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
|
||||
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis zum
|
||||
* zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
|
||||
*
|
||||
* Ein einziger globaler Auftrag statt je einem je Mandant: das Intervall ist
|
||||
* fest (stuendlich), es gibt keine Einstellung je Zeile. Der Auftrag wird
|
||||
* beim Start OHNE Datenbankzugriff registriert — eine frische Datenbank kann
|
||||
* daher nie ohne Auftrag enden, auch wenn beim Start noch keine Cloud
|
||||
* eingetragen ist. Jeder Durchlauf liest Kennung und Mandant aller Clouds
|
||||
* (ein einziger Systemkontext-Aufruf) und prueft dann jede Cloud an ihren
|
||||
* eigenen Mandanten gebunden, mit hoechstens vier gleichzeitig. Ein
|
||||
* Ueberlappungsschutz ueberspringt einen Durchlauf, solange der vorige laeuft.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(NextcloudStatusSchedulerService.name);
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
private readonly service: NextcloudStatusService,
|
||||
private readonly release: NextcloudReleaseService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registriert und startet den Auftrag und stoesst das Aufwaermen der
|
||||
* Vergleichsdaten an (ohne zu warten). Ein Fehler wird gefangen und
|
||||
* protokolliert, nie weitergeworfen — die Anwendung startet trotzdem.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
const job = new CronJobClass(NEXTCLOUD_CRON, () => {
|
||||
this.tick().catch((err) =>
|
||||
this.logger.error(`Nextcloud poll tick failed: ${(err as Error).message}`),
|
||||
);
|
||||
});
|
||||
// Cast noetig — dasselbe Muster wie ProxmoxSchedulerService.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
|
||||
this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any);
|
||||
job.start();
|
||||
this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`);
|
||||
void this.release.refresh().catch(() => undefined);
|
||||
} catch (err) {
|
||||
this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Durchlauf ueber alle Clouds aller Mandanten. */
|
||||
async tick(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.logger.warn('Nextcloud poll tick skipped — previous run still active');
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
const rows = await this.service.loadAllInstancesForScheduler();
|
||||
// Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt.
|
||||
const byTenant = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const ids = byTenant.get(row.tenantId) ?? [];
|
||||
ids.push(row.id);
|
||||
byTenant.set(row.tenantId, ids);
|
||||
}
|
||||
const work: { tenantId: string; id: string }[] = [];
|
||||
for (const [tenantId, ids] of byTenant) {
|
||||
for (const id of ids) work.push({ tenantId, id });
|
||||
}
|
||||
await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => {
|
||||
try {
|
||||
await this.service.checkInstance(tenantId, id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,37 @@ describe('NextcloudStatusController Metadaten', () => {
|
||||
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(['list', 'logo'])('%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name] as object)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['create', 'checkOne'])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => {
|
||||
it.each([
|
||||
'create',
|
||||
'update',
|
||||
'remove',
|
||||
'checkAll',
|
||||
'checkOne',
|
||||
'uploadLogo',
|
||||
'removeLogo',
|
||||
])('%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();
|
||||
});
|
||||
|
||||
it('die statische Route POST instances/check steht vor jedem Handler mit :id (kein 404-Shadowing)', () => {
|
||||
const names = Object.getOwnPropertyNames(NextcloudStatusController.prototype).filter(
|
||||
(n) => n !== 'constructor' && typeof proto[n] === 'function',
|
||||
);
|
||||
const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string;
|
||||
expect(pathOf('checkAll')).toBe('instances/check');
|
||||
const checkIndex = names.indexOf('checkAll');
|
||||
const idHandlers = names.filter((n) => (pathOf(n) ?? '').includes(':id'));
|
||||
expect(idHandlers.length).toBeGreaterThan(0);
|
||||
for (const name of idHandlers) {
|
||||
expect(checkIndex, `checkAll vor ${name}`).toBeLessThan(names.indexOf(name));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
import { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
||||
import {
|
||||
CreateNextcloudInstanceDto,
|
||||
UpdateNextcloudInstanceDto,
|
||||
} from './dto/nextcloud-instance.dto';
|
||||
import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
|
||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
|
||||
/**
|
||||
@@ -9,14 +28,15 @@ import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
* 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
|
||||
* Rechte (L-09): Lesen (`GET instances`, `GET instances/:id/logo`) 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).
|
||||
* Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing,
|
||||
* T-k67-08; die Reihenfolge ist im Controller-Spec festgeschrieben).
|
||||
*/
|
||||
@Controller('modules/nextcloud-status')
|
||||
@UseModule('nextcloud-status')
|
||||
@@ -42,9 +62,73 @@ export class NextcloudStatusController {
|
||||
return this.service.createInstance(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
/** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */
|
||||
@Post('instances/check')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async checkAll(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.checkAllForTenant(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Put('instances/:id')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateNextcloudInstanceDto,
|
||||
) {
|
||||
return this.service.updateInstance(this.requireTenantId(req), id, dto);
|
||||
}
|
||||
|
||||
@Delete('instances/:id')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const deleted = await this.service.deleteInstance(this.requireTenantId(req), id);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
@Post('instances/:id/check')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.checkInstance(this.requireTenantId(req), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo-Abruf fuer jeden Benutzer mit Modulzugriff (die Kachel laedt es per
|
||||
* <img>). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private
|
||||
* Zwischenspeicherung (Adresse traegt clientseitig `?v=<logoVersion>`),
|
||||
* `nosniff` und eine Sandbox-CSP — Muster `favorites.controller.ts` `getIcon`
|
||||
* (T-k67-02).
|
||||
*/
|
||||
@Get('instances/:id/logo')
|
||||
async logo(@Req() req: AuthenticatedRequest, @Param('id') id: string, @Res() res: Response) {
|
||||
const { data, mime } = await this.service.getLogo(this.requireTenantId(req), id);
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||
res.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo hochladen: Groessengrenze JE ROUTE (multers `LIMIT_FILE_SIZE` wird von
|
||||
* Nest auf 413 abgebildet); Typ und Besitz pruefen im Dienst.
|
||||
*/
|
||||
@Post('instances/:id/logo')
|
||||
@ModuleManage('nextcloud-status')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } }),
|
||||
)
|
||||
async uploadLogo(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file?: UploadedFileLike,
|
||||
) {
|
||||
return this.service.uploadLogo(this.requireTenantId(req), id, file);
|
||||
}
|
||||
|
||||
@Delete('instances/:id/logo')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async removeLogo(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.removeLogo(this.requireTenantId(req), id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NextcloudReleaseService } from './nextcloud-release.service';
|
||||
import { NextcloudStatusController } from './nextcloud-status.controller';
|
||||
import { seedNextcloudStatusModule } from './nextcloud-status.seed';
|
||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
import { NextcloudStatusSchedulerService } from './nextcloud-status-scheduler.service';
|
||||
|
||||
/**
|
||||
* NestJS module for the Nextcloud-Status feature (quick-261002-k67).
|
||||
@@ -13,7 +14,7 @@ import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [NextcloudStatusController],
|
||||
providers: [NextcloudStatusService, NextcloudReleaseService],
|
||||
providers: [NextcloudStatusService, NextcloudReleaseService, NextcloudStatusSchedulerService],
|
||||
})
|
||||
export class NextcloudStatusModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(NextcloudStatusModule.name);
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
|
||||
return { ...actual, fetchNextcloudStatus: vi.fn() };
|
||||
});
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, 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';
|
||||
@@ -177,4 +177,199 @@ describe('NextcloudStatusService', () => {
|
||||
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
describe('Schreibwege (Aufgabe 2)', () => {
|
||||
const OK_RESULT = {
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
productName: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.nextcloudInstance.delete = vi.fn();
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
id: 'i1',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
});
|
||||
prisma.nextcloudInstance.update.mockResolvedValue(makeRow());
|
||||
vi.mocked(fetchNextcloudStatus).mockResolvedValue(OK_RESULT);
|
||||
});
|
||||
|
||||
it('updateInstance: nur Name -> kein Neuabruf', async () => {
|
||||
await service.updateInstance('t1', 'i1', { customerName: ' Neu ' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
customerName: 'Neu',
|
||||
});
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateInstance: neue Adresse wird normalisiert und neu geprueft, unveraenderte nicht', async () => {
|
||||
await service.updateInstance('t1', 'i1', { baseUrl: 'https://neu.example.de/index.php/' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
baseUrl: 'https://neu.example.de',
|
||||
});
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(fetchNextcloudStatus).mockClear();
|
||||
await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de/' });
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
|
||||
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updateInstance: nicht leere Logo-Adresse ersetzt den Upload, leere entfernt nur die Adresse', async () => {
|
||||
await service.updateInstance('t1', 'i1', { logoUrl: 'https://logo.example.de/a.png' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
logoUrl: 'https://logo.example.de/a.png',
|
||||
logoData: null,
|
||||
logoMime: null,
|
||||
logoVersion: { increment: 1 },
|
||||
});
|
||||
await service.updateInstance('t1', 'i1', { logoUrl: '' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[1][0].data).toEqual({
|
||||
logoUrl: null,
|
||||
logoVersion: { increment: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('uploadLogo speichert Bytes und erkannten Typ, entfernt die Adresse und erhoeht die Version', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]);
|
||||
await service.uploadLogo('t1', 'i1', {
|
||||
buffer: png,
|
||||
originalname: 'x.exe',
|
||||
mimetype: 'text/html',
|
||||
size: png.length,
|
||||
});
|
||||
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
|
||||
expect(Buffer.from(data.logoData)).toEqual(png);
|
||||
expect(data).toMatchObject({
|
||||
logoMime: 'image/png',
|
||||
logoUrl: null,
|
||||
logoVersion: { increment: 1 },
|
||||
});
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT);
|
||||
});
|
||||
|
||||
it('uploadLogo lehnt Nicht-Bilder, fehlende Dateien und fremde Kennungen ab', async () => {
|
||||
const html = Buffer.from('<html></html>');
|
||||
await expect(
|
||||
service.uploadLogo('t1', 'i1', {
|
||||
buffer: html,
|
||||
originalname: 'a.png',
|
||||
mimetype: 'image/png',
|
||||
size: html.length,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
|
||||
);
|
||||
await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException);
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
await expect(
|
||||
service.uploadLogo('t1', 'fremd', {
|
||||
buffer: png,
|
||||
originalname: 'a.png',
|
||||
mimetype: 'image/png',
|
||||
size: png.length,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.nextcloudInstance.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeLogo loescht Bytes und Typ und erhoeht die Version', async () => {
|
||||
await service.removeLogo('t1', 'i1');
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
logoData: null,
|
||||
logoMime: null,
|
||||
logoVersion: { increment: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('getLogo liefert Bytes und Typ, ohne Logo NotFound', async () => {
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
logoData: new Uint8Array([1, 2, 3]),
|
||||
logoMime: 'image/png',
|
||||
});
|
||||
const logo = await service.getLogo('t1', 'i1');
|
||||
expect(logo.mime).toBe('image/png');
|
||||
expect([...logo.data]).toEqual([1, 2, 3]);
|
||||
expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
|
||||
id: 'i1',
|
||||
tenantId: 't1',
|
||||
});
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({ logoData: null, logoMime: null });
|
||||
await expect(service.getLogo('t1', 'i1')).rejects.toThrow(NotFoundException);
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
|
||||
await expect(service.getLogo('t1', 'x')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('deleteInstance loescht die Zeile; fremde Kennung -> NotFound', async () => {
|
||||
expect(await service.deleteInstance('t1', 'i1')).toBe(true);
|
||||
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledWith({ where: { id: 'i1' } });
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
|
||||
await expect(service.deleteInstance('t1', 'fremd')).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('checkAllForTenant prueft alle mit hoechstens vier gleichzeitig und liefert die frische Liste', async () => {
|
||||
const ids = Array.from({ length: 10 }, (_, i) => `i${i}`);
|
||||
prisma.nextcloudInstance.findMany.mockImplementation(
|
||||
async (args: { select: Record<string, boolean> }) =>
|
||||
args.select.id && Object.keys(args.select).length === 1
|
||||
? ids.map((id) => ({ id }))
|
||||
: [makeRow()],
|
||||
);
|
||||
prisma.nextcloudInstance.findFirst.mockImplementation(
|
||||
async ({ where }: { where: { id: string } }) => ({
|
||||
id: where.id,
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
}),
|
||||
);
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
vi.mocked(fetchNextcloudStatus).mockImplementation(async () => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
inFlight--;
|
||||
return OK_RESULT;
|
||||
});
|
||||
const result = await service.checkAllForTenant('t1');
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10);
|
||||
expect(peak).toBeLessThanOrEqual(4);
|
||||
expect(peak).toBeGreaterThan(1);
|
||||
expect(result.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('checkAllForTenant: eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
|
||||
prisma.nextcloudInstance.findMany.mockImplementation(
|
||||
async (args: { select: Record<string, boolean> }) =>
|
||||
Object.keys(args.select).length === 1 ? [{ id: 'a' }, { id: 'b' }] : [],
|
||||
);
|
||||
prisma.nextcloudInstance.findFirst.mockImplementation(
|
||||
async ({ where }: { where: { id: string } }) =>
|
||||
where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' },
|
||||
);
|
||||
await service.checkAllForTenant('t1');
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loadAllInstancesForScheduler waehlt nur Kennung und Mandant ueber forSystem', async () => {
|
||||
prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]);
|
||||
const rows = await service.loadAllInstancesForScheduler();
|
||||
expect(forSystem).toHaveBeenCalledWith(prisma);
|
||||
expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({
|
||||
select: { id: true, tenantId: true },
|
||||
});
|
||||
expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type {
|
||||
CreateNextcloudInstanceDto,
|
||||
UpdateNextcloudInstanceDto,
|
||||
} from './dto/nextcloud-instance.dto';
|
||||
import { checkLogoUpload } from './nextcloud-logo-rules';
|
||||
import {
|
||||
type NextcloudRating,
|
||||
type NextcloudReference,
|
||||
@@ -76,10 +81,34 @@ export interface NextcloudListView {
|
||||
reference: { newestVersion: string | null; fetchedAt: string | null };
|
||||
}
|
||||
|
||||
/** Hoechstzahl gleichzeitiger Pruefungen (Sammelpruefung und stuendlicher Durchlauf). */
|
||||
export const CHECK_CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* Arbeitet `items` mit hoechstens `limit` gleichzeitig ab. `fn` darf werfen —
|
||||
* der Aufrufer faengt je Eintrag selbst, ein Fehler stoppt die anderen nicht.
|
||||
*/
|
||||
export async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (next < items.length) {
|
||||
const item = items[next++];
|
||||
await fn(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
|
||||
|
||||
@Injectable()
|
||||
export class NextcloudStatusService {
|
||||
private readonly logger = new Logger(NextcloudStatusService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly release: NextcloudReleaseService,
|
||||
@@ -178,4 +207,172 @@ export class NextcloudStatusService {
|
||||
});
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
/**
|
||||
* Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird
|
||||
* normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine
|
||||
* nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere
|
||||
* entfernt nur die Adresse (D-A).
|
||||
*/
|
||||
async updateInstance(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateNextcloudInstanceDto,
|
||||
): 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 data: Record<string, unknown> = {};
|
||||
if (dto.customerName !== undefined) data.customerName = dto.customerName.trim();
|
||||
|
||||
let urlChanged = false;
|
||||
if (dto.baseUrl !== undefined) {
|
||||
const baseUrl = normalizeCloudUrl(dto.baseUrl);
|
||||
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
|
||||
if (baseUrl !== existing.baseUrl) {
|
||||
data.baseUrl = baseUrl;
|
||||
urlChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.logoUrl !== undefined) {
|
||||
if (dto.logoUrl) {
|
||||
data.logoUrl = dto.logoUrl;
|
||||
data.logoData = null;
|
||||
data.logoMime = null;
|
||||
} else {
|
||||
data.logoUrl = null;
|
||||
}
|
||||
data.logoVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
const updated = await tenantPrisma.nextcloudInstance.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: PUBLIC_SELECT,
|
||||
});
|
||||
if (urlChanged) return this.checkInstance(tenantId, id);
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
|
||||
/** Loescht eine Cloud. Fremde oder unbekannte Kennung: 404. */
|
||||
async deleteInstance(tenantId: string, id: string): Promise<boolean> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
await tenantPrisma.nextcloudInstance.delete({ where: { id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert ein hochgeladenes Logo. Der Typ kommt aus den Magic Bytes
|
||||
* (`checkLogoUpload`), nie aus dem Mimetype des Browsers; eine vorhandene
|
||||
* Logo-Adresse wird entfernt (Upload und Adresse schliessen sich aus).
|
||||
*/
|
||||
async uploadLogo(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
file: UploadedFileLike | undefined,
|
||||
): Promise<NextcloudInstanceView> {
|
||||
const mime = file ? checkLogoUpload(file.buffer) : null;
|
||||
if (!file || !mime) {
|
||||
throw new BadRequestException(
|
||||
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
|
||||
);
|
||||
}
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
const updated = await tenantPrisma.nextcloudInstance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
logoData: new Uint8Array(file.buffer),
|
||||
logoMime: mime,
|
||||
logoUrl: null,
|
||||
logoVersion: { increment: 1 },
|
||||
},
|
||||
select: PUBLIC_SELECT,
|
||||
});
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
|
||||
/** Liefert die Bytes des hochgeladenen Logos — die einzige Abfrage, die `logoData` auswaehlt. */
|
||||
async getLogo(tenantId: string, id: string): Promise<{ data: Buffer; mime: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const row = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { logoData: true, logoMime: true },
|
||||
});
|
||||
if (!row?.logoData || !row.logoMime) throw new NotFoundException('Kein Logo vorhanden');
|
||||
return { data: Buffer.from(row.logoData), mime: row.logoMime };
|
||||
}
|
||||
|
||||
/** Entfernt ein hochgeladenes Logo (die Kachel faellt auf Initialen zurueck). */
|
||||
async removeLogo(tenantId: string, id: string): Promise<NextcloudInstanceView> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
const updated = await tenantPrisma.nextcloudInstance.update({
|
||||
where: { id },
|
||||
data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } },
|
||||
select: PUBLIC_SELECT,
|
||||
});
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
|
||||
/** Kennungen aller Clouds des Mandanten. */
|
||||
async listInstanceIdsForTenant(tenantId: string): Promise<string[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const rows = await tenantPrisma.nextcloudInstance.findMany({
|
||||
where: { tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((r: { id: string }) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Jetzt pruefen" fuer die ganze Liste: alle Clouds des Mandanten mit
|
||||
* hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte
|
||||
* Cloud stoppt die anderen nicht.
|
||||
*/
|
||||
async checkAllForTenant(tenantId: string): Promise<NextcloudListView> {
|
||||
const ids = await this.listInstanceIdsForTenant(tenantId);
|
||||
await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => {
|
||||
try {
|
||||
await this.checkInstance(tenantId, id);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Nextcloud-Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return this.listForTenant(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startpfad des stuendlichen Planers — der EINZIGE Systemkontext-Aufruf
|
||||
* dieses Moduls (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`;
|
||||
* Leserecht ueber `system_read_policy ... FOR SELECT` der Migration
|
||||
* 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder
|
||||
* Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen
|
||||
* Mandanten gebunden (`checkInstance`).
|
||||
*/
|
||||
async loadAllInstancesForScheduler(): Promise<{ id: string; tenantId: string }[]> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
return systemPrisma.nextcloudInstance.findMany({
|
||||
select: { id: true, tenantId: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,10 +189,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
||||
* gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist
|
||||
* `system_read_policy ... FOR SELECT` auf "Reminder" (Migration
|
||||
* 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe.
|
||||
*
|
||||
* quick-261002-k67 (Aufgabe 2): eine siebte Datei kommt hinzu —
|
||||
* `nextcloud-status/nextcloud-status.service.ts`, EIN Aufruf:
|
||||
* `loadAllInstancesForScheduler()` liest fuer den stuendlichen Planer nur
|
||||
* Kennung und Mandant ALLER Clouds (`select: { id, tenantId }`, nie Logo-Bytes).
|
||||
* Geprueft und geschrieben wird danach je Cloud gebunden ueber
|
||||
* `forTenant(prisma, tenantId)`. Die passende Regel ist
|
||||
* `system_read_policy ... FOR SELECT` auf "NextcloudInstance" (Migration
|
||||
* 20261002150000). Summe neu: 7 Dateien, 8 Aufrufe.
|
||||
*/
|
||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1],
|
||||
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
||||
['apps/api/src/reminders/reminder-mail.scheduler.ts', 1],
|
||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { NextcloudInstance } from '@/lib/nextcloud-status-api';
|
||||
import de from '@/messages/de.json';
|
||||
import { CloudForm } from './CloudForm';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const api = {
|
||||
createInstance: vi.fn(),
|
||||
updateInstance: vi.fn(),
|
||||
deleteInstance: vi.fn(),
|
||||
uploadLogo: vi.fn(),
|
||||
removeLogo: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
|
||||
return {
|
||||
...actual,
|
||||
createInstance: (...a: unknown[]) => api.createInstance(...a),
|
||||
updateInstance: (...a: unknown[]) => api.updateInstance(...a),
|
||||
deleteInstance: (...a: unknown[]) => api.deleteInstance(...a),
|
||||
uploadLogo: (...a: unknown[]) => api.uploadLogo(...a),
|
||||
removeLogo: (...a: unknown[]) => api.removeLogo(...a),
|
||||
};
|
||||
});
|
||||
|
||||
const EXISTING: NextcloudInstance = {
|
||||
id: 'i1',
|
||||
customerName: 'Kunde A',
|
||||
baseUrl: 'https://a.example.de',
|
||||
logoUrl: null,
|
||||
hasUploadedLogo: false,
|
||||
logoVersion: 0,
|
||||
status: {
|
||||
checkedAt: null,
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
},
|
||||
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
|
||||
};
|
||||
|
||||
function setup(instance: NextcloudInstance | null = null) {
|
||||
const props = { onClose: vi.fn(), onSaved: vi.fn(), onDeleted: vi.fn() };
|
||||
render(<CloudForm instance={instance} {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('CloudForm', () => {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(api)) fn.mockReset();
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('verlangt Kundenname und Adresse', async () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Bitte füllen Sie Kundenname und Adresse aus.',
|
||||
);
|
||||
expect(api.createInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('legt eine Cloud an', async () => {
|
||||
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
|
||||
const props = setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: ' Neu GmbH ' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||
target: { value: 'https://cloud.neu.de' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() => expect(props.onSaved).toHaveBeenCalled());
|
||||
expect(api.createInstance).toHaveBeenCalledWith({
|
||||
customerName: 'Neu GmbH',
|
||||
baseUrl: 'https://cloud.neu.de',
|
||||
});
|
||||
expect(api.uploadLogo).not.toHaveBeenCalled();
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lädt nach dem Anlegen das gewählte Logo hoch', async () => {
|
||||
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
|
||||
api.uploadLogo.mockResolvedValue({ ...EXISTING, id: 'neu', hasUploadedLogo: true });
|
||||
const props = setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||
target: { value: 'https://cloud.neu.de' },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() => expect(api.uploadLogo).toHaveBeenCalledWith('neu', file));
|
||||
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ hasUploadedLogo: true }));
|
||||
});
|
||||
|
||||
it('sendet eine https-Bildadresse und weist darauf hin', async () => {
|
||||
api.createInstance.mockResolvedValue(EXISTING);
|
||||
setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||
target: { value: 'https://cloud.neu.de' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="radio"]' }),
|
||||
);
|
||||
expect(screen.getByText(/muss mit https:\/\/ beginnen/)).toBeInTheDocument();
|
||||
fireEvent.change(
|
||||
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="url"]' }),
|
||||
{
|
||||
target: { value: 'https://logo.example.de/a.png' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() =>
|
||||
expect(api.createInstance).toHaveBeenCalledWith({
|
||||
customerName: 'Neu',
|
||||
baseUrl: 'https://cloud.neu.de',
|
||||
logoUrl: 'https://logo.example.de/a.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lehnt eine zu große Datei sofort ab', async () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
|
||||
const big = new File([new Uint8Array(1024 * 1024 + 1)], 'groß.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
|
||||
target: { files: [big] },
|
||||
});
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
|
||||
});
|
||||
|
||||
it('ändert eine vorhandene Cloud', async () => {
|
||||
api.updateInstance.mockResolvedValue(EXISTING);
|
||||
const props = setup(EXISTING);
|
||||
expect(screen.getByLabelText('Kundenname')).toHaveValue('Kunde A');
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Kunde B' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() =>
|
||||
expect(api.updateInstance).toHaveBeenCalledWith('i1', {
|
||||
customerName: 'Kunde B',
|
||||
baseUrl: 'https://a.example.de',
|
||||
}),
|
||||
);
|
||||
expect(props.onSaved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('entfernt ein hochgeladenes Logo über „Kein Logo“', async () => {
|
||||
const withLogo = { ...EXISTING, hasUploadedLogo: true, logoVersion: 2 };
|
||||
api.updateInstance.mockResolvedValue(withLogo);
|
||||
api.removeLogo.mockResolvedValue(EXISTING);
|
||||
setup(withLogo);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Logo entfernen' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() => expect(api.removeLogo).toHaveBeenCalledWith('i1'));
|
||||
});
|
||||
|
||||
it('Entfernen fragt erst nach und ruft dann deleteInstance', async () => {
|
||||
api.deleteInstance.mockResolvedValue(undefined);
|
||||
const props = setup(EXISTING);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cloud entfernen' }));
|
||||
expect(
|
||||
screen.getByText('Möchten Sie die Cloud „Kunde A“ wirklich entfernen?'),
|
||||
).toBeInTheDocument();
|
||||
expect(api.deleteInstance).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Endgültig entfernen' }));
|
||||
await waitFor(() => expect(api.deleteInstance).toHaveBeenCalledWith('i1'));
|
||||
expect(props.onDeleted).toHaveBeenCalledWith('i1');
|
||||
});
|
||||
|
||||
it('zeigt die Fehlermeldung der API im Formular', async () => {
|
||||
api.createInstance.mockRejectedValue(
|
||||
new Error('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'),
|
||||
);
|
||||
const props = setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { target: { value: 'ftp://x' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Bitte geben Sie eine gültige Adresse',
|
||||
);
|
||||
expect(props.onSaved).not.toHaveBeenCalled();
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
logoSrc,
|
||||
type NextcloudInstance,
|
||||
removeLogo,
|
||||
updateInstance,
|
||||
uploadLogo,
|
||||
} from '@/lib/nextcloud-status-api';
|
||||
|
||||
type LogoMode = 'none' | 'upload' | 'url';
|
||||
|
||||
/** Obergrenze fuer ein Logo (wie die API: 1 MiB). */
|
||||
const LOGO_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
interface CloudFormProps {
|
||||
/** `null` = neue Cloud anlegen, sonst bearbeiten. */
|
||||
instance: NextcloudInstance | null;
|
||||
onClose: () => void;
|
||||
/** Liefert die gespeicherte Cloud (mit frischer Pruefung und Bewertung). */
|
||||
onSaved: (instance: NextcloudInstance) => void;
|
||||
onDeleted: (id: string) => void;
|
||||
}
|
||||
|
||||
function initialMode(instance: NextcloudInstance | null): LogoMode {
|
||||
if (instance?.hasUploadedLogo) return 'upload';
|
||||
if (instance?.logoUrl) return 'url';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular „Cloud hinzufuegen / bearbeiten“ (quick-261002-k67, L-02, L-09) —
|
||||
* Aufbau wie `CustomModuleFormModal`. Nur fuer Verwalter erreichbar (die Seite
|
||||
* zeigt die Zugaenge nicht fuer Benutzer ohne Verwalten); bindend ist allein
|
||||
* der ModuleGuard der API. Das Logo ist entweder ein Upload (die API prueft den
|
||||
* Typ an den Bytes) oder eine https-Bildadresse, die der Browser selbst laedt.
|
||||
* Anlegen wartet auf die erste Pruefung („Wird geprueft …“).
|
||||
*/
|
||||
export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormProps) {
|
||||
const t = useTranslations('nextcloudStatus.form');
|
||||
const [customerName, setCustomerName] = useState(instance?.customerName ?? '');
|
||||
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? '');
|
||||
const [mode, setMode] = useState<LogoMode>(initialMode(instance));
|
||||
const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? '');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const currentLogo =
|
||||
instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null;
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files?.[0] ?? null;
|
||||
if (picked && picked.size > LOGO_MAX_BYTES) {
|
||||
setError(t('logoFileTooLarge'));
|
||||
setFile(null);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setFile(picked);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const name = customerName.trim();
|
||||
const url = baseUrl.trim();
|
||||
if (!name || !url) {
|
||||
setError(t('required'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
let saved: NextcloudInstance;
|
||||
if (instance) {
|
||||
const payload: { customerName: string; baseUrl: string; logoUrl?: string } = {
|
||||
customerName: name,
|
||||
baseUrl: url,
|
||||
};
|
||||
if (mode === 'url') payload.logoUrl = logoUrl.trim();
|
||||
else if (mode === 'none' && instance.logoUrl) payload.logoUrl = '';
|
||||
saved = await updateInstance(instance.id, payload);
|
||||
if (mode === 'none' && instance.hasUploadedLogo) saved = await removeLogo(instance.id);
|
||||
if (mode === 'upload' && file) saved = await uploadLogo(instance.id, file);
|
||||
} else {
|
||||
saved = await createInstance({
|
||||
customerName: name,
|
||||
baseUrl: url,
|
||||
...(mode === 'url' && logoUrl.trim() ? { logoUrl: logoUrl.trim() } : {}),
|
||||
});
|
||||
if (mode === 'upload' && file) saved = await uploadLogo(saved.id, file);
|
||||
}
|
||||
onSaved(saved);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t('saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!instance) return;
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteInstance(instance.id);
|
||||
onDeleted(instance.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t('deleteError'));
|
||||
setConfirmDelete(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const input = 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cloud-form-title"
|
||||
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
|
||||
>
|
||||
<h2 id="cloud-form-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||
{instance ? t('editTitle') : t('addTitle')}
|
||||
</h2>
|
||||
|
||||
{confirmDelete && instance ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground">
|
||||
{t('deleteConfirm', { name: instance.customerName })}
|
||||
</p>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('deleteConfirmYes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="cloud-customer-name" className="text-sm font-medium text-foreground">
|
||||
{t('customerName')}
|
||||
</label>
|
||||
<input
|
||||
id="cloud-customer-name"
|
||||
type="text"
|
||||
required
|
||||
maxLength={120}
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="cloud-base-url" className="text-sm font-medium text-foreground">
|
||||
{t('baseUrl')}
|
||||
</label>
|
||||
<input
|
||||
id="cloud-base-url"
|
||||
type="text"
|
||||
required
|
||||
maxLength={2048}
|
||||
placeholder={t('baseUrlPlaceholder')}
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
className={input}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('baseUrlHint')}</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-foreground">{t('logo')}</legend>
|
||||
{currentLogo && mode !== 'none' && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{/* biome-ignore lint/performance/noImgElement: Vorschau einer externen oder hochgeladenen Adresse */}
|
||||
<img
|
||||
src={currentLogo}
|
||||
alt={t('logoCurrent')}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-10 w-10 rounded-md bg-well object-contain"
|
||||
/>
|
||||
<button type="button" onClick={() => setMode('none')} className="btn btn-subtle">
|
||||
{t('logoRemove')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{(['none', 'upload', 'url'] as const).map((value) => (
|
||||
<label key={value} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloud-logo-mode"
|
||||
value={value}
|
||||
checked={mode === value}
|
||||
onChange={() => setMode(value)}
|
||||
/>
|
||||
{t(
|
||||
value === 'none' ? 'logoNone' : value === 'upload' ? 'logoUpload' : 'logoUrl',
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{mode === 'upload' && (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="file"
|
||||
aria-label={t('logoUpload')}
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={handleFile}
|
||||
className="block w-full text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('logoFileHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'url' && (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="url"
|
||||
aria-label={t('logoUrl')}
|
||||
maxLength={2048}
|
||||
placeholder={t('logoUrlPlaceholder')}
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
className={input}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('logoUrlHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||
<div>
|
||||
{instance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="btn btn-primary">
|
||||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,12 @@ interface CloudTileProps {
|
||||
instance: NextcloudInstance;
|
||||
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
|
||||
now?: number;
|
||||
/** Verwalten-Ebene: zeigt die Knoepfe Pruefen und Bearbeiten (nur Anzeige, bindend ist die API). */
|
||||
canManage?: boolean;
|
||||
/** Eine Pruefung dieser Kachel laeuft (Drehsymbol, Knopf gesperrt). */
|
||||
checking?: boolean;
|
||||
onCheck?: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +32,14 @@ interface CloudTileProps {
|
||||
* 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) {
|
||||
export function CloudTile({
|
||||
instance,
|
||||
now = Date.now(),
|
||||
canManage = false,
|
||||
checking = false,
|
||||
onCheck,
|
||||
onEdit,
|
||||
}: CloudTileProps) {
|
||||
const t = useTranslations('nextcloudStatus');
|
||||
const locale = useLocale();
|
||||
const [logoFailed, setLogoFailed] = useState(false);
|
||||
@@ -79,6 +92,58 @@ export function CloudTile({ instance, now = Date.now() }: CloudTileProps) {
|
||||
{instance.baseUrl}
|
||||
</a>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={checking}
|
||||
aria-label={t('cardActions.refresh')}
|
||||
title={t('cardActions.refresh')}
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={checking ? 'animate-spin motion-reduce:animate-none' : ''}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||
<polyline points="21 3 21 8 16 8" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={t('cardActions.edit')}
|
||||
title={t('cardActions.edit')}
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
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';
|
||||
@@ -15,12 +22,29 @@ function render(ui: ReactElement) {
|
||||
}
|
||||
|
||||
const mockListInstances = vi.fn();
|
||||
const mockCheckAll = vi.fn();
|
||||
const mockCheckOne = vi.fn();
|
||||
|
||||
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
|
||||
return { ...actual, listInstances: (...args: unknown[]) => mockListInstances(...args) };
|
||||
return {
|
||||
...actual,
|
||||
listInstances: (...args: unknown[]) => mockListInstances(...args),
|
||||
checkAll: (...args: unknown[]) => mockCheckAll(...args),
|
||||
checkOne: (...args: unknown[]) => mockCheckOne(...args),
|
||||
};
|
||||
});
|
||||
|
||||
let mockCanManage: boolean | null = false;
|
||||
vi.mock('@/lib/use-module-capability', () => ({
|
||||
useCanManageModule: () => mockCanManage,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
|
||||
selector({ user: { id: 'u1' } }),
|
||||
}));
|
||||
|
||||
function makeInstance(
|
||||
over: Partial<NextcloudInstance> & { id: string; customerName: string },
|
||||
): NextcloudInstance {
|
||||
@@ -101,6 +125,10 @@ const LIST: NextcloudList = {
|
||||
describe('NextcloudStatusPage', () => {
|
||||
beforeEach(() => {
|
||||
mockListInstances.mockReset();
|
||||
mockCheckAll.mockReset();
|
||||
mockCheckOne.mockReset();
|
||||
mockCanManage = false;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
@@ -167,4 +195,94 @@ describe('NextcloudStatusPage', () => {
|
||||
const tile = await screen.findByTestId('cloud-tile');
|
||||
expect(within(tile).getByText('KG')).toBeInTheDocument();
|
||||
});
|
||||
describe('Rechte und Sortierung', () => {
|
||||
const tileNames = () =>
|
||||
screen.getAllByTestId('cloud-tile').map((t) => t.getAttribute('aria-label')?.split(':')[0]);
|
||||
|
||||
it('Benutzen-Ebene: Kacheln und Sortierung ja, Verwalten-Knöpfe nein', async () => {
|
||||
mockCanManage = false;
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
render(<NextcloudStatusPage />);
|
||||
await screen.findAllByTestId('cloud-tile');
|
||||
expect(screen.getByLabelText('Sortieren nach')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Cloud hinzufügen' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Jetzt prüfen' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Cloud bearbeiten' })).toBeNull();
|
||||
});
|
||||
|
||||
it('Verwalter sehen alle Knöpfe', async () => {
|
||||
mockCanManage = true;
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
render(<NextcloudStatusPage />);
|
||||
await screen.findAllByTestId('cloud-tile');
|
||||
expect(screen.getByRole('button', { name: 'Cloud hinzufügen' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Jetzt prüfen' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toHaveLength(4);
|
||||
expect(screen.getAllByRole('button', { name: 'Cloud bearbeiten' })).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('Status-Sortierung stellt Rot nach vorn und merkt die Auswahl', async () => {
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
render(<NextcloudStatusPage />);
|
||||
await screen.findAllByTestId('cloud-tile');
|
||||
expect(tileNames()).toEqual(['Kunde Gelb', 'Kunde Grau', 'Kunde Grün', 'Kunde Rot']);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Sortieren nach'), { target: { value: 'status' } });
|
||||
expect(tileNames()).toEqual(['Kunde Rot', 'Kunde Gelb', 'Kunde Grau', 'Kunde Grün']);
|
||||
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
|
||||
});
|
||||
|
||||
it('wendet eine gemerkte Sortierung beim Laden an', async () => {
|
||||
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'status');
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
render(<NextcloudStatusPage />);
|
||||
await screen.findAllByTestId('cloud-tile');
|
||||
await waitFor(() => expect(tileNames()[0]).toBe('Kunde Rot'));
|
||||
expect((screen.getByLabelText('Sortieren nach') as HTMLSelectElement).value).toBe('status');
|
||||
});
|
||||
|
||||
it('„Jetzt prüfen“ ruft checkAll und ersetzt die Liste', async () => {
|
||||
mockCanManage = true;
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
mockCheckAll.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
|
||||
render(<NextcloudStatusPage />);
|
||||
await screen.findAllByTestId('cloud-tile');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Jetzt prüfen' }));
|
||||
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(1));
|
||||
expect(mockCheckAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Kachel-Prüfung ersetzt nur diese Kachel', async () => {
|
||||
mockCanManage = true;
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
mockCheckOne.mockResolvedValue({
|
||||
...LIST.instances[2],
|
||||
customerName: 'Kunde Rot',
|
||||
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
|
||||
});
|
||||
render(<NextcloudStatusPage />);
|
||||
const tiles = await screen.findAllByTestId('cloud-tile');
|
||||
const red = tiles.find((t) =>
|
||||
t.getAttribute('aria-label')?.startsWith('Kunde Rot'),
|
||||
) as HTMLElement;
|
||||
fireEvent.click(within(red).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }));
|
||||
await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c'));
|
||||
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(4));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByTestId('cloud-tile').find((t) => t.textContent?.includes('Kunde Rot')),
|
||||
).toHaveAttribute('data-level', 'green'),
|
||||
);
|
||||
});
|
||||
|
||||
it('„Cloud hinzufügen“ öffnet das Formular', async () => {
|
||||
mockCanManage = true;
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
render(<NextcloudStatusPage />);
|
||||
await screen.findAllByTestId('cloud-tile');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cloud hinzufügen' }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import {
|
||||
readSortPreference,
|
||||
SORT_KEYS,
|
||||
type SortKey,
|
||||
sortClouds,
|
||||
writeSortPreference,
|
||||
} from '@/components/nextcloud-status/sort-clouds';
|
||||
import { WELL } from '@/components/proxmox/status-styles';
|
||||
import { listInstances, type NextcloudList } from '@/lib/nextcloud-status-api';
|
||||
import {
|
||||
checkAll,
|
||||
checkOne,
|
||||
listInstances,
|
||||
type NextcloudInstance,
|
||||
type NextcloudList,
|
||||
} from '@/lib/nextcloud-status-api';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||
import { CloudForm } from './components/CloudForm';
|
||||
import { CloudTile } from './components/CloudTile';
|
||||
|
||||
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
||||
@@ -25,16 +41,50 @@ function SkeletonTile() {
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon({ spinning }: { spinning: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={spinning ? 'animate-spin motion-reduce:animate-none' : ''}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||
<polyline points="21 3 21 8 16 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Sortierung (L-07) steht jedem Benutzer offen und wird je Benutzer im
|
||||
* Browser gemerkt. Hinzufuegen, Bearbeiten, Pruefen („Jetzt pruefen“, je
|
||||
* Kachel) nur mit Verwalten (`useCanManageModule`, L-09); die Knoepfe sind
|
||||
* reine Anzeige — bindend ist `@ModuleManage` an der API.
|
||||
*/
|
||||
export default function NextcloudStatusPage() {
|
||||
const t = useTranslations('nextcloudStatus');
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const canManage = useCanManageModule('nextcloud-status') === true;
|
||||
|
||||
const [data, setData] = useState<NextcloudList | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [checkingAll, setCheckingAll] = useState(false);
|
||||
const [checkingIds, setCheckingIds] = useState<ReadonlySet<string>>(new Set());
|
||||
// `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten.
|
||||
const [editing, setEditing] = useState<NextcloudInstance | null | undefined>(undefined);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
listInstances()
|
||||
@@ -54,11 +104,82 @@ export default function NextcloudStatusPage() {
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Gemerkte Sortierung dieses Benutzers anwenden, sobald seine Kennung feststeht.
|
||||
useEffect(() => {
|
||||
if (userId) setSortKey(readSortPreference(userId));
|
||||
}, [userId]);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
setSortKey(key);
|
||||
if (userId) writeSortPreference(userId, key);
|
||||
};
|
||||
|
||||
const handleCheckAll = async () => {
|
||||
setCheckingAll(true);
|
||||
try {
|
||||
const list = await checkAll();
|
||||
setData(list);
|
||||
setNow(Date.now());
|
||||
} catch {
|
||||
setError(t('loadError'));
|
||||
} finally {
|
||||
setCheckingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckOne = async (id: string) => {
|
||||
setCheckingIds((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
const updated = await checkOne(id);
|
||||
setData((prev) =>
|
||||
prev
|
||||
? { ...prev, instances: prev.instances.map((i) => (i.id === id ? updated : i)) }
|
||||
: prev,
|
||||
);
|
||||
setNow(Date.now());
|
||||
} catch {
|
||||
/* die Kachel behaelt ihren bisherigen Stand */
|
||||
} finally {
|
||||
setCheckingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die
|
||||
// neueste Version kommen so wieder aus einer Quelle.
|
||||
const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]);
|
||||
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')} />
|
||||
<PageHeader
|
||||
moduleSlug="nextcloud-status"
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
actions={
|
||||
canManage ? (
|
||||
<>
|
||||
<button type="button" onClick={() => setEditing(null)} className="btn btn-subtle">
|
||||
{t('actions.add')}
|
||||
</button>
|
||||
{data !== null && data.instances.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCheckAll}
|
||||
disabled={checkingAll}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<RefreshIcon spinning={checkingAll} />
|
||||
{checkingAll ? t('actions.checking') : t('actions.checkAll')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
|
||||
@@ -79,26 +200,62 @@ export default function NextcloudStatusPage() {
|
||||
)}
|
||||
|
||||
{!error && data !== null && (
|
||||
<p className="text-sm text-muted-foreground" data-testid="newest-version">
|
||||
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground" data-testid="newest-version">
|
||||
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
|
||||
</p>
|
||||
{data.instances.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{t('sort.label')}
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => handleSort(e.target.value as SortKey)}
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
||||
>
|
||||
{SORT_KEYS.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{t(`sort.${key}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
{canManage && <p className="mt-1 text-sm text-muted-foreground">{t('emptyManage')}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && data !== null && data.instances.length > 0 && (
|
||||
{!error && data !== null && sorted.length > 0 && (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.instances.map((instance) => (
|
||||
{sorted.map((instance) => (
|
||||
<li key={instance.id} className="min-w-0">
|
||||
<CloudTile instance={instance} now={now} />
|
||||
<CloudTile
|
||||
key={`${instance.id}:${instance.logoVersion}`}
|
||||
instance={instance}
|
||||
now={now}
|
||||
canManage={canManage}
|
||||
checking={checkingAll || checkingIds.has(instance.id)}
|
||||
onCheck={() => handleCheckOne(instance.id)}
|
||||
onEdit={() => setEditing(instance)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{editing !== undefined && (
|
||||
<CloudForm
|
||||
instance={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={() => reload()}
|
||||
onDeleted={() => reload()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
|
||||
import { readSortPreference, sortClouds, writeSortPreference } from './sort-clouds';
|
||||
|
||||
function make(
|
||||
name: string,
|
||||
level: RatingLevel = 'green',
|
||||
version: string | null = '35.0.1',
|
||||
eolDate: string | null = null,
|
||||
): NextcloudInstance {
|
||||
return {
|
||||
id: name,
|
||||
customerName: name,
|
||||
baseUrl: `https://${name}.de`,
|
||||
logoUrl: null,
|
||||
hasUploadedLogo: false,
|
||||
logoVersion: 0,
|
||||
status: {
|
||||
checkedAt: null,
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: version,
|
||||
edition: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
},
|
||||
rating: { level, reason: 'current', updateTo: null, eolDate, cycle: null },
|
||||
};
|
||||
}
|
||||
|
||||
const names = (list: NextcloudInstance[]) => list.map((i) => i.customerName);
|
||||
|
||||
describe('sortClouds', () => {
|
||||
it('name: A-Z mit deutscher Sortierung, ohne Beachtung der Gross-/Kleinschreibung', () => {
|
||||
const list = [make('zebra'), make('Ärger'), make('Anton'), make('berta')];
|
||||
expect(names(sortClouds(list, 'name'))).toEqual(['Anton', 'Ärger', 'berta', 'zebra']);
|
||||
});
|
||||
|
||||
it('status: rot, gelb, grau, gruen, bei Gleichstand nach Name', () => {
|
||||
const list = [
|
||||
make('g2', 'green'),
|
||||
make('r2', 'red'),
|
||||
make('u1', 'unknown'),
|
||||
make('y1', 'yellow'),
|
||||
make('r1', 'red'),
|
||||
make('g1', 'green'),
|
||||
];
|
||||
expect(names(sortClouds(list, 'status'))).toEqual(['r1', 'r2', 'y1', 'u1', 'g1', 'g2']);
|
||||
});
|
||||
|
||||
it('version: aelteste zuerst, ohne Version zuletzt', () => {
|
||||
const list = [
|
||||
make('a', 'green', '34.0.10'),
|
||||
make('b', 'green', null),
|
||||
make('c', 'green', '33.0.9'),
|
||||
make('d', 'green', '34.0.2'),
|
||||
];
|
||||
expect(names(sortClouds(list, 'version'))).toEqual(['c', 'd', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('eol: frueheste zuerst, ohne Datum zuletzt', () => {
|
||||
const list = [
|
||||
make('a', 'green', '1.0.0', '2027-06-30'),
|
||||
make('b'),
|
||||
make('c', 'green', '1.0.0', '2026-09-30'),
|
||||
];
|
||||
expect(names(sortClouds(list, 'eol'))).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('veraendert die Eingabe nicht', () => {
|
||||
const list = [make('b'), make('a')];
|
||||
const copy = [...list];
|
||||
sortClouds(list, 'name');
|
||||
expect(list).toEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sortier-Vorliebe', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('merkt die Auswahl je Benutzer unter eigenem Schluessel', () => {
|
||||
writeSortPreference('u1', 'status');
|
||||
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
|
||||
expect(readSortPreference('u1')).toBe('status');
|
||||
expect(readSortPreference('u2')).toBe('name');
|
||||
});
|
||||
|
||||
it('ein unbekannter gespeicherter Wert ergibt name', () => {
|
||||
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'quatsch');
|
||||
expect(readSortPreference('u1')).toBe('name');
|
||||
});
|
||||
|
||||
it('ein gesperrter Speicher wirft nie', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('gesperrt');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('gesperrt');
|
||||
});
|
||||
expect(readSortPreference('u1')).toBe('name');
|
||||
expect(() => writeSortPreference('u1', 'eol')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
|
||||
|
||||
/**
|
||||
* Sortierung der Cloud-Kacheln (quick-261002-k67, L-07, D-G) — reine
|
||||
* Funktionen, kein React.
|
||||
*/
|
||||
export type SortKey = 'name' | 'status' | 'version' | 'eol';
|
||||
|
||||
export const SORT_KEYS: readonly SortKey[] = ['name', 'status', 'version', 'eol'];
|
||||
|
||||
/** Rot zuerst, dann Gelb, Grau, Gruen. */
|
||||
const LEVEL_ORDER: Record<RatingLevel, number> = { red: 0, yellow: 1, unknown: 2, green: 3 };
|
||||
|
||||
function byName(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||
return a.customerName.localeCompare(b.customerName, 'de', { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function versionTriple(raw: string | null): [number, number, number] | null {
|
||||
if (!raw) return null;
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
}
|
||||
|
||||
function compareVersions(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||
const va = versionTriple(a.status.versionString);
|
||||
const vb = versionTriple(b.status.versionString);
|
||||
if (!va && !vb) return 0;
|
||||
if (!va) return 1; // unbekannt zuletzt
|
||||
if (!vb) return -1;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (va[i] !== vb[i]) return va[i] - vb[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareEol(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||
const ea = a.rating.eolDate;
|
||||
const eb = b.rating.eolDate;
|
||||
if (!ea && !eb) return 0;
|
||||
if (!ea) return 1; // unbekannt zuletzt
|
||||
if (!eb) return -1;
|
||||
return ea < eb ? -1 : ea > eb ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Liefert eine neue, sortierte Liste; die Eingabe bleibt unveraendert. Gleiche Werte nach Kundenname. */
|
||||
export function sortClouds(items: NextcloudInstance[], key: SortKey): NextcloudInstance[] {
|
||||
const compare = (a: NextcloudInstance, b: NextcloudInstance): number => {
|
||||
switch (key) {
|
||||
case 'status':
|
||||
return LEVEL_ORDER[a.rating.level] - LEVEL_ORDER[b.rating.level];
|
||||
case 'version':
|
||||
return compareVersions(a, b);
|
||||
case 'eol':
|
||||
return compareEol(a, b);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
return [...items].sort((a, b) => compare(a, b) || byName(a, b));
|
||||
}
|
||||
|
||||
function storageKey(userId: string): string {
|
||||
return `tessera:nextcloud-status:sort:${userId}`;
|
||||
}
|
||||
|
||||
/** Gemerkte Sortierung dieses Benutzers; unbekannter Wert oder gesperrter Speicher ergeben 'name'. */
|
||||
export function readSortPreference(userId: string): SortKey {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey(userId));
|
||||
return (SORT_KEYS as readonly string[]).includes(stored ?? '') ? (stored as SortKey) : 'name';
|
||||
} catch {
|
||||
return 'name';
|
||||
}
|
||||
}
|
||||
|
||||
/** Merkt die Sortierung; ein gesperrter Speicher wird still uebergangen. */
|
||||
export function writeSortPreference(userId: string, key: SortKey): void {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(userId), key);
|
||||
} catch {
|
||||
/* Speicher gesperrt oder voll — die Auswahl gilt dann nur fuer diese Sitzung */
|
||||
}
|
||||
}
|
||||
@@ -84,3 +84,93 @@ export function logoSrc(
|
||||
}
|
||||
return instance.logoUrl ?? null;
|
||||
}
|
||||
|
||||
/** Liest die NestJS-Fehlermeldung aus dem Antwortkoerper, faellt sonst auf einen Standardtext zurueck. */
|
||||
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (typeof body?.message === 'string') return body.message;
|
||||
if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]);
|
||||
} catch {
|
||||
/* Antwort war kein JSON — Standardtext bleibt */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface NextcloudInstancePayload {
|
||||
customerName: string;
|
||||
baseUrl: string;
|
||||
/** Leere Zeichenkette = Logo-Adresse entfernen (nur beim Aendern). */
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
const BASE = `${API_URL}/modules/nextcloud-status/instances`;
|
||||
|
||||
/** POST /instances — Cloud anlegen und sofort pruefen (nur Verwalten). */
|
||||
export async function createInstance(
|
||||
payload: NextcloudInstancePayload,
|
||||
): Promise<NextcloudInstance> {
|
||||
const res = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to create nextcloud instance'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** PUT /instances/:id — Cloud aendern (nur Verwalten). */
|
||||
export async function updateInstance(
|
||||
id: string,
|
||||
payload: Partial<NextcloudInstancePayload>,
|
||||
): Promise<NextcloudInstance> {
|
||||
const res = await fetch(`${BASE}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to update nextcloud instance'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** DELETE /instances/:id — Cloud entfernen (nur Verwalten). */
|
||||
export async function deleteInstance(id: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to delete nextcloud instance'));
|
||||
}
|
||||
|
||||
/** POST /instances/check — "Jetzt pruefen" fuer die ganze Liste, liefert die frische Liste. */
|
||||
export async function checkAll(): Promise<NextcloudList> {
|
||||
const res = await fetch(`${BASE}/check`, { method: 'POST', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instances'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** POST /instances/:id/check — eine Cloud sofort pruefen, liefert die aktualisierte Kachel. */
|
||||
export async function checkOne(id: string): Promise<NextcloudInstance> {
|
||||
const res = await fetch(`${BASE}/${id}/check`, { method: 'POST', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instance'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** POST /instances/:id/logo — Logo hochladen (Formularfeld `logo`, nur Verwalten). */
|
||||
export async function uploadLogo(id: string, file: File): Promise<NextcloudInstance> {
|
||||
const form = new FormData();
|
||||
form.append('logo', file);
|
||||
const res = await fetch(`${BASE}/${id}/logo`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to upload logo'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** DELETE /instances/:id/logo — hochgeladenes Logo entfernen (nur Verwalten). */
|
||||
export async function removeLogo(id: string): Promise<NextcloudInstance> {
|
||||
const res = await fetch(`${BASE}/${id}/logo`, { method: 'DELETE', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1851,6 +1851,50 @@
|
||||
"noReference": "Bewertung nicht möglich",
|
||||
"versionUnknown": "Version unbekannt",
|
||||
"notChecked": "Noch nicht geprüft"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sortieren nach",
|
||||
"name": "Kundenname",
|
||||
"status": "Status",
|
||||
"version": "Version",
|
||||
"eol": "Support-Ende"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Cloud hinzufügen",
|
||||
"checkAll": "Jetzt prüfen",
|
||||
"checking": "Wird geprüft …"
|
||||
},
|
||||
"cardActions": {
|
||||
"refresh": "Diese Cloud jetzt prüfen",
|
||||
"edit": "Cloud bearbeiten"
|
||||
},
|
||||
"form": {
|
||||
"addTitle": "Cloud hinzufügen",
|
||||
"editTitle": "Cloud bearbeiten",
|
||||
"customerName": "Kundenname",
|
||||
"baseUrl": "Adresse der Cloud",
|
||||
"baseUrlPlaceholder": "https://cloud.example.com",
|
||||
"baseUrlHint": "Tessera ruft nur die öffentliche Statusseite dieser Adresse ab. Zugangsdaten werden nicht benötigt.",
|
||||
"logo": "Logo",
|
||||
"logoNone": "Kein Logo",
|
||||
"logoUpload": "Bild hochladen",
|
||||
"logoUrl": "Bildadresse (https)",
|
||||
"logoUrlPlaceholder": "https://www.example.com/logo.png",
|
||||
"logoUrlHint": "Die Adresse muss mit https:// beginnen. Das Bild wird direkt von Ihrem Browser geladen.",
|
||||
"logoFileHint": "PNG, JPEG, GIF oder WebP, höchstens 1 MB.",
|
||||
"logoFileTooLarge": "Die Datei ist größer als 1 MB.",
|
||||
"logoCurrent": "Aktuelles Logo",
|
||||
"logoRemove": "Logo entfernen",
|
||||
"save": "Speichern",
|
||||
"saving": "Wird geprüft …",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Cloud entfernen",
|
||||
"deleteConfirm": "Möchten Sie die Cloud „{name}“ wirklich entfernen?",
|
||||
"deleteConfirmYes": "Endgültig entfernen",
|
||||
"required": "Bitte füllen Sie Kundenname und Adresse aus.",
|
||||
"saveError": "Die Cloud konnte nicht gespeichert werden.",
|
||||
"deleteError": "Die Cloud konnte nicht entfernt werden.",
|
||||
"close": "Dialog schließen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1851,6 +1851,50 @@
|
||||
"noReference": "Rating not possible",
|
||||
"versionUnknown": "Version unknown",
|
||||
"notChecked": "Not checked yet"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sort by",
|
||||
"name": "Customer name",
|
||||
"status": "Status",
|
||||
"version": "Version",
|
||||
"eol": "End of support"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add cloud",
|
||||
"checkAll": "Check now",
|
||||
"checking": "Checking …"
|
||||
},
|
||||
"cardActions": {
|
||||
"refresh": "Check this cloud now",
|
||||
"edit": "Edit cloud"
|
||||
},
|
||||
"form": {
|
||||
"addTitle": "Add cloud",
|
||||
"editTitle": "Edit cloud",
|
||||
"customerName": "Customer name",
|
||||
"baseUrl": "Cloud address",
|
||||
"baseUrlPlaceholder": "https://cloud.example.com",
|
||||
"baseUrlHint": "Tessera only reads the public status page of this address. No login data is needed.",
|
||||
"logo": "Logo",
|
||||
"logoNone": "No logo",
|
||||
"logoUpload": "Upload image",
|
||||
"logoUrl": "Image address (https)",
|
||||
"logoUrlPlaceholder": "https://www.example.com/logo.png",
|
||||
"logoUrlHint": "The address must start with https://. The image is loaded directly by your browser.",
|
||||
"logoFileHint": "PNG, JPEG, GIF or WebP, at most 1 MB.",
|
||||
"logoFileTooLarge": "The file is larger than 1 MB.",
|
||||
"logoCurrent": "Current logo",
|
||||
"logoRemove": "Remove logo",
|
||||
"save": "Save",
|
||||
"saving": "Checking …",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Remove cloud",
|
||||
"deleteConfirm": "Do you really want to remove the cloud “{name}”?",
|
||||
"deleteConfirmYes": "Remove permanently",
|
||||
"required": "Please fill in customer name and address.",
|
||||
"saveError": "The cloud could not be saved.",
|
||||
"deleteError": "The cloud could not be removed.",
|
||||
"close": "Close dialog"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
|
||||
'Neueste',
|
||||
'ausstehend',
|
||||
'Statusseite',
|
||||
'Bildadresse',
|
||||
// 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