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:
2026-10-02 14:54:58 +02:00
parent 5ef7b0c6cc
commit 6698ef1a92
23 changed files with 2061 additions and 30 deletions
@@ -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;
}
}
}