diff --git a/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts index 24bf0c1..c618767 100644 --- a/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts @@ -8,6 +8,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({ import { forTenant } from '../prisma/prisma-tenant.extension'; import { ALERT_MAIL_RETRY_MS, NextcloudAlertService } from './nextcloud-alert.service'; +import { buildNextcloudAlertMail } from './nextcloud-alert-mail'; import type { NextcloudRating } from './nextcloud-rating'; const NOW = new Date('2026-10-02T12:30:00Z'); @@ -313,4 +314,26 @@ describe('NextcloudAlertService Empfaenger (L-06)', () => { expect(ctx.prisma.user.findMany).not.toHaveBeenCalled(); expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled(); }); + + it('rot -> gruen (z. B. nach korrigierter Adresse): Betreff "wieder in Ordnung" und Zeile "Aktueller Stand"', async () => { + withSubscriber(ctx); + let built: { subject: string; text: string } | null = null; + ctx.mail.sendNextcloudAlertEmail.mockImplementation( + async (_t: string, _to: string, input: Parameters[0]) => { + built = buildNextcloudAlertMail(input, 'https://tessera.example.invalid'); + return true; + }, + ); + await run2(ctx, { ...ROW, alertState: 'red' }, GREEN); + expect(built).not.toBeNull(); + expect((built as unknown as { subject: string }).subject).toBe( + 'Nextcloud Kunde A: wieder in Ordnung', + ); + expect((built as unknown as { text: string }).text).toContain('Aktueller Stand: Aktuell'); + }); }); + +async function run2(ctx: ReturnType, row: typeof ROW, rating: NextcloudRating) { + const result = await ctx.service.evaluateAfterCheck('t1', row, rating, NOW); + await result.delivery; +} diff --git a/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts index 73f08b3..ca8dcf8 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; +import { RETRY_DELAY_MS } from './nextcloud-alert-rules'; import { NEXTCLOUD_CRON, NEXTCLOUD_JOB_NAME, + NEXTCLOUD_RETRY_CRON, + NEXTCLOUD_RETRY_JOB_NAME, NextcloudStatusSchedulerService, } from './nextcloud-status-scheduler.service'; @@ -40,10 +43,22 @@ function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: s } describe('NextcloudStatusSchedulerService', () => { - it('registriert genau einen stuendlichen Auftrag ohne Datenbankzugriff und startet ihn', async () => { - const { registry, service, release, scheduler } = makeScheduler(); + it('registriert den stuendlichen Auftrag und die Wiederholung ohne Datenbankzugriff und startet beide', async () => { + const { registry, service, release, scheduler, logSpy } = makeScheduler(); await scheduler.onApplicationBootstrap(); - expect(registry.addCronJob).toHaveBeenCalledTimes(1); + expect(registry.addCronJob).toHaveBeenCalledTimes(2); + expect(registry.__jobs.has(NEXTCLOUD_RETRY_JOB_NAME)).toBe(true); + expect(NEXTCLOUD_RETRY_JOB_NAME).toBe('nextcloud-status-retry'); + expect(NEXTCLOUD_RETRY_CRON).toBe('* * * * *'); + const retryJob = registry.__jobs.get(NEXTCLOUD_RETRY_JOB_NAME); + expect(retryJob.cronTime.source).toBe(NEXTCLOUD_RETRY_CRON); + expect(retryJob.isActive ?? retryJob.running).toBeTruthy(); + expect( + logSpy.mock.calls.some((c) => + String(c[0]).includes('Nextcloud-Status retry job registered: * * * * *'), + ), + ).toBe(true); + retryJob.stop(); expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true); const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME); expect(job.cronTime.source).toBe(NEXTCLOUD_CRON); @@ -108,4 +123,83 @@ describe('NextcloudStatusSchedulerService', () => { await scheduler.tick(); expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2); }); + + describe('retryTick', () => { + const NOW = new Date('2026-10-02T12:10:00Z'); + + it('laedt nur Clouds mit Fehlschlag aelter als fuenf Minuten und prueft sie gebunden', async () => { + const rows = [ + { id: 'a', tenantId: 'tA' }, + { id: 'b', tenantId: 'tB' }, + ]; + const { scheduler, service, calls } = makeScheduler(rows); + await scheduler.retryTick(NOW); + expect(service.loadAllInstancesForScheduler).toHaveBeenCalledWith({ + retryDueBefore: new Date(NOW.getTime() - RETRY_DELAY_MS), + }); + expect(RETRY_DELAY_MS).toBe(5 * 60 * 1000); + expect(calls).toEqual( + expect.arrayContaining([ + ['tA', 'a'], + ['tB', 'b'], + ]), + ); + expect(calls).toHaveLength(2); + }); + + it('prueft hoechstens vier gleichzeitig', async () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: `c${i}`, tenantId: 't' })); + const { scheduler, service } = makeScheduler(rows); + let inFlight = 0; + let peak = 0; + service.checkInstance.mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + }); + await scheduler.retryTick(NOW); + expect(service.checkInstance).toHaveBeenCalledTimes(10); + expect(peak).toBeLessThanOrEqual(4); + expect(peak).toBeGreaterThan(1); + }); + + it('ueberspringt, solange der vorige Durchlauf laeuft; der stuendliche Durchlauf blockiert ihn nicht', async () => { + const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]); + let release!: () => void; + service.checkInstance.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const first = scheduler.retryTick(NOW); + await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1)); + await scheduler.retryTick(NOW); + expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalled(); + // der stuendliche Durchlauf hat einen eigenen Schutz + await scheduler.tick(); + expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2); + release(); + await first; + }); + + it('wirft nie: ein Fehler beim Laden und ein Fehler je Cloud werden protokolliert', async () => { + const { scheduler, service, errorSpy, calls } = makeScheduler( + [ + { id: 'x', tenantId: 't' }, + { id: 'y', tenantId: 't' }, + ], + ['x'], + ); + await expect(scheduler.retryTick(NOW)).resolves.toBeUndefined(); + expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y']); + expect(errorSpy).toHaveBeenCalledTimes(1); + + service.loadAllInstancesForScheduler.mockRejectedValueOnce(new Error('db weg')); + await expect(scheduler.retryTick(NOW)).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts index 3ffa02e..98ab084 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; +import { RETRY_DELAY_MS } from './nextcloud-alert-rules'; import { NextcloudReleaseService } from './nextcloud-release.service'; import { CHECK_CONCURRENCY, @@ -27,6 +28,10 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll'; /** Jede volle Stunde (L-08). */ export const NEXTCLOUD_CRON = '0 * * * *'; +/** Name des Wiederholungsauftrags (quick-261002-kxc, L-03). */ +export const NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry'; +/** Jede Minute: prueft nur Clouds, deren erster Fehlschlag fuenf Minuten zurueckliegt. */ +export const NEXTCLOUD_RETRY_CRON = '* * * * *'; /** * NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds @@ -50,6 +55,7 @@ export const NEXTCLOUD_CRON = '0 * * * *'; export class NextcloudStatusSchedulerService implements OnApplicationBootstrap { private readonly logger = new Logger(NextcloudStatusSchedulerService.name); private running = false; + private retryRunning = false; constructor( private readonly schedulerRegistry: SchedulerRegistry, @@ -75,6 +81,15 @@ export class NextcloudStatusSchedulerService implements OnApplicationBootstrap { this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any); job.start(); this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`); + const retryJob = new CronJobClass(NEXTCLOUD_RETRY_CRON, () => { + this.retryTick().catch((err) => + this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`), + ); + }); + // biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService + this.schedulerRegistry.addCronJob(NEXTCLOUD_RETRY_JOB_NAME, retryJob as any); + retryJob.start(); + this.logger.log(`Nextcloud-Status retry job registered: ${NEXTCLOUD_RETRY_CRON}`); void this.release.refresh().catch(() => undefined); } catch (err) { this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`); @@ -89,29 +104,56 @@ export class NextcloudStatusSchedulerService implements OnApplicationBootstrap { } 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(); - 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}`, - ); - } - }); + await this.checkRows(await this.service.loadAllInstancesForScheduler()); } finally { this.running = false; } } + + /** + * Wiederholung: nur Clouds mit genau einem Fehlschlag, der mindestens + * `RETRY_DELAY_MS` zurueckliegt. Wirft nie; ein laufender Durchlauf haelt + * den naechsten an. + */ + async retryTick(now: Date = new Date()): Promise { + if (this.retryRunning) { + this.logger.warn('Nextcloud retry tick skipped — previous run still active'); + return; + } + this.retryRunning = true; + try { + const rows = await this.service.loadAllInstancesForScheduler({ + retryDueBefore: new Date(now.getTime() - RETRY_DELAY_MS), + }); + await this.checkRows(rows); + } catch (err) { + this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`); + } finally { + this.retryRunning = false; + } + } + + /** Prueft jede Cloud an ihren eigenen Mandanten gebunden, hoechstens vier gleichzeitig. */ + private async checkRows(rows: { id: string; tenantId: string }[]): Promise { + // Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt. + const byTenant = new Map(); + 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}`, + ); + } + }); + } } diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts index 23fa9c4..7174199 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts @@ -271,11 +271,25 @@ describe('NextcloudStatusService', () => { expect(fetchNextcloudStatus).not.toHaveBeenCalled(); }); - it('updateInstance: neue Adresse wird normalisiert und neu geprueft, unveraenderte nicht', async () => { + it('updateInstance: neue Adresse wird normalisiert, setzt den Pruefstand zurueck (alertState bleibt) und prueft neu, 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({ + const data = prisma.nextcloudInstance.update.mock.calls[0][0].data; + expect(data).toEqual({ baseUrl: 'https://neu.example.de', + reachable: null, + maintenance: null, + needsDbUpgrade: null, + versionString: null, + edition: null, + productName: null, + errorKind: null, + errorDetail: null, + lastCheckedAt: null, + consecutiveFailures: 0, + firstFailureAt: null, }); + expect(data).not.toHaveProperty('alertState'); + expect(data).not.toHaveProperty('alertReason'); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1); vi.mocked(fetchNextcloudStatus).mockClear(); @@ -283,6 +297,20 @@ describe('NextcloudStatusService', () => { expect(fetchNextcloudStatus).not.toHaveBeenCalled(); }); + it('updateInstance: rote Cloud, korrigierte Adresse antwortet gruen -> Meldung "wieder in Ordnung" wird entschieden', async () => { + prisma.nextcloudInstance.findFirst.mockResolvedValue({ + id: 'i1', + baseUrl: 'https://kaputt.example.de', + consecutiveFailures: 0, + }); + prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'red' })); + await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de' }); + expect(alerts.evaluateAfterCheck).toHaveBeenCalledTimes(1); + const [, row, rating] = alerts.evaluateAfterCheck.mock.calls[0]; + expect(row.alertState).toBe('red'); + expect(rating).toMatchObject({ level: 'green', reason: 'current' }); + }); + it('updateInstance: ungueltige Adresse -> BadRequest', async () => { await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow( BadRequestException, @@ -435,5 +463,25 @@ describe('NextcloudStatusService', () => { }); expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]); }); + + it('loadAllInstancesForScheduler mit retryDueBefore filtert auf genau einen Fehlschlag, Select bleibt Kennung und Mandant', async () => { + prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]); + const due = new Date('2026-10-02T12:00:00Z'); + await service.loadAllInstancesForScheduler({ retryDueBefore: due }); + expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({ + where: { consecutiveFailures: 1, firstFailureAt: { lte: due } }, + select: { id: true, tenantId: true }, + }); + }); + + it('pendingRetry ist genau bei einem Fehlschlag wahr', async () => { + prisma.nextcloudInstance.findMany.mockResolvedValue([ + makeRow({ id: 'a', consecutiveFailures: 0 }), + makeRow({ id: 'b', consecutiveFailures: 1 }), + makeRow({ id: 'c', consecutiveFailures: 2 }), + ]); + const result = await service.listForTenant('t1', 'u1'); + expect(result.instances.map((i) => i.status.pendingRetry)).toEqual([false, true, false]); + }); }); }); diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.ts index da08626..2aac054 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.ts @@ -39,6 +39,7 @@ export const PUBLIC_SELECT = { edition: true, errorKind: true, errorDetail: true, + consecutiveFailures: true, } as const; type PublicRow = { @@ -56,6 +57,7 @@ type PublicRow = { edition: string | null; errorKind: string | null; errorDetail: string | null; + consecutiveFailures: number; }; export interface NextcloudInstanceView { @@ -74,6 +76,11 @@ export interface NextcloudInstanceView { edition: string | null; errorKind: string | null; errorDetail: string | null; + /** + * Genau ein Fehlschlag in Folge: die Kachel zeigt den letzten guten Stand + * mit Hinweis, die Wiederholung folgt in wenigen Minuten (L-03). + */ + pendingRetry: boolean; }; rating: NextcloudRating; /** @@ -125,6 +132,7 @@ export class NextcloudStatusService { private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView { const status = { + pendingRetry: row.consecutiveFailures === 1, checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null, reachable: row.reachable, maintenance: row.maintenance, @@ -271,6 +279,22 @@ export class NextcloudStatusService { if (baseUrl !== existing.baseUrl) { data.baseUrl = baseUrl; urlChanged = true; + // Neue Adresse: der alte Pruefstand gilt nicht mehr (D-K8). `alertState` + // bleibt bewusst unberuehrt — wird eine kaputte Adresse korrigiert und + // antwortet die neue, geht "wieder in Ordnung" an die Abonnenten. + Object.assign(data, { + reachable: null, + maintenance: null, + needsDbUpgrade: null, + versionString: null, + edition: null, + productName: null, + errorKind: null, + errorDetail: null, + lastCheckedAt: null, + consecutiveFailures: 0, + firstFailureAt: null, + }); } } @@ -403,11 +427,25 @@ export class NextcloudStatusService { * 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`). + * Mandanten gebunden (`checkInstance`). Mit `retryDueBefore` (Wiederholung + * nach dem ersten Fehlschlag, quick-261002-kxc) filtert dieselbe Abfrage auf + * Clouds mit genau einem Fehlschlag, der vor diesem Zeitpunkt lag. */ - async loadAllInstancesForScheduler(): Promise<{ id: string; tenantId: string }[]> { + async loadAllInstancesForScheduler(filter?: { + retryDueBefore: Date; + }): Promise<{ id: string; tenantId: string }[]> { const systemPrisma = forSystem(this.prisma); return systemPrisma.nextcloudInstance.findMany({ + // Wiederholungsauftrag (D-K3): nur Clouds mit genau einem Fehlschlag, dessen + // Zeitpunkt lange genug zurueckliegt — derselbe Systemlesezugriff, kein neuer. + ...(filter + ? { + where: { + consecutiveFailures: 1, + firstFailureAt: { lte: filter.retryDueBefore }, + }, + } + : {}), select: { id: true, tenantId: true }, }); } diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx index 5730106..7200bcb 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx @@ -2,6 +2,7 @@ import { useLocale, useTranslations } from 'next-intl'; import { useState } from 'react'; +import { errorHint } from '@/components/nextcloud-status/error-hint'; import { RATING_STYLE, ratingReasonText } from '@/components/nextcloud-status/rating-display'; import { formatAge } from '@/components/proxmox/proxmox-status'; import { logoSrc, type NextcloudInstance } from '@/lib/nextcloud-status-api'; @@ -55,10 +56,15 @@ export function CloudTile({ const reason = ratingReasonText(t, instance.rating, locale); const src = logoFailed ? null : logoSrc(instance); const checkedAge = formatAge(instance.status.checkedAt, now, locale); - const showErrorDetail = + // Fehlerhinweis in Klartext; die Rohkennung bleibt als Tooltip erhalten. + const hint = instance.rating.level === 'red' && instance.rating.reason === 'unreachable' && - instance.status.errorDetail; + (instance.status.errorKind || instance.status.errorDetail) + ? errorHint(instance.status.errorKind, instance.status.errorDetail) + : null; + const hintText = hint ? t(`errorHint.${hint.key}`, { code: hint.code ?? '' }) : null; + const hintRaw = instance.status.errorDetail ?? instance.status.errorKind ?? undefined; return (
- {showErrorDetail && ( -

{instance.status.errorDetail}

+ {hintText && ( +

+ {hintText} +

+ )} + + {instance.status.pendingRetry === true && ( +

+

)}
{ ).toHaveAttribute('aria-pressed', 'true'); }); }); + + describe('Hinweise auf der Kachel (quick-261002-kxc)', () => { + const redWith = (errorKind: string | null, errorDetail: string | null): NextcloudInstance => + makeInstance({ + id: 'r', + customerName: 'Kunde R', + status: { + checkedAt: new Date().toISOString(), + reachable: false, + maintenance: null, + needsDbUpgrade: null, + versionString: null, + edition: null, + errorKind, + errorDetail, + }, + rating: { level: 'red', reason: 'unreachable', updateTo: null, eolDate: null, cycle: null }, + }); + + const renderOne = async (instance: NextcloudInstance) => { + mockListInstances.mockResolvedValue({ ...LIST, instances: [instance] }); + render(); + return screen.findByTestId('cloud-tile'); + }; + + it('zeigt "Prüfung fehlgeschlagen" bei pendingRetry, Pille und Version behalten den guten Stand', async () => { + const base = makeInstance({ id: 'p', customerName: 'Kunde P' }); + const tile = await renderOne({ ...base, status: { ...base.status, pendingRetry: true } }); + expect(within(tile).getByTestId('pending-retry')).toHaveTextContent( + 'Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt', + ); + expect(tile).toHaveAttribute('data-level', 'green'); + expect(within(tile).getByText('Aktuell')).toBeInTheDocument(); + expect(within(tile).getByText('35.0.1')).toBeInTheDocument(); + }); + + it('zeigt den Hinweis nicht ohne pendingRetry', async () => { + const tile = await renderOne(makeInstance({ id: 'q', customerName: 'Kunde Q' })); + expect(within(tile).queryByTestId('pending-retry')).toBeNull(); + }); + + it.each([ + ['tls', 'ERR_TLS_CERT_ALTNAME_INVALID', 'Zertifikat passt nicht zur Adresse'], + ['tls', 'CERT_HAS_EXPIRED', 'Zertifikat abgelaufen'], + ['tls', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'Zertifikat nicht vertrauenswürdig'], + ['network', 'ENOTFOUND', 'Adresse nicht gefunden'], + ['network', 'ECONNREFUSED', 'Verbindung abgelehnt'], + ['timeout', null, 'Zeitüberschreitung'], + ['http-status', 'HTTP 502', 'Server antwortet mit Fehler 502'], + ['network', 'ECONNRESET', 'Verbindungsfehler'], + ])('„Nicht erreichbar“ zeigt %s / %s als „%s“, die Rohkennung als Tooltip', async (kind, detail, text) => { + const tile = await renderOne(redWith(kind, detail)); + const hint = within(tile).getByTestId('error-hint'); + expect(hint).toHaveTextContent(text); + expect(hint).toHaveAttribute('title', detail ?? kind); + expect(within(tile).queryByText(detail ?? '__nichts__')).toBeNull(); + }); + }); }); diff --git a/apps/web/src/components/nextcloud-status/error-hint.test.ts b/apps/web/src/components/nextcloud-status/error-hint.test.ts new file mode 100644 index 0000000..63b24c9 --- /dev/null +++ b/apps/web/src/components/nextcloud-status/error-hint.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import de from '@/messages/de.json'; +import en from '@/messages/en.json'; +import { type ErrorHintKey, errorHint } from './error-hint'; + +describe('errorHint', () => { + it.each([ + ['tls', 'ERR_TLS_CERT_ALTNAME_INVALID', { key: 'certName' }], + ['tls', 'HOSTNAME_MISMATCH', { key: 'certName' }], + ['tls', 'CERT_HAS_EXPIRED', { key: 'certExpired' }], + ['tls', 'DEPTH_ZERO_SELF_SIGNED_CERT', { key: 'certUntrusted' }], + ['tls', 'SELF_SIGNED_CERT_IN_CHAIN', { key: 'certUntrusted' }], + ['tls', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', { key: 'certUntrusted' }], + ['tls', 'IRGENDWAS', { key: 'fallback' }], + ['network', 'ENOTFOUND', { key: 'dnsNotFound' }], + ['network', 'EAI_AGAIN', { key: 'dnsNotFound' }], + ['network', 'ECONNREFUSED', { key: 'connectionRefused' }], + ['network', 'ETIMEDOUT', { key: 'timeout' }], + ['timeout', null, { key: 'timeout' }], + ['http-status', 'HTTP 502', { key: 'httpStatus', code: '502' }], + ['http-status', 'HTTP 404', { key: 'httpStatus', code: '404' }], + ['http-status', 'kaputt', { key: 'fallback' }], + ['network', 'ECONNRESET', { key: 'fallback' }], + ['network', null, { key: 'fallback' }], + ['redirect', 'Zu viele Weiterleitungen', { key: 'fallback' }], + [null, null, { key: 'fallback' }], + ])('%s / %s', (kind, detail, expected) => { + expect(errorHint(kind, detail)).toEqual(expected); + }); + + it('jeder Schluessel hat in Deutsch und Englisch einen Text; Deutsch wie vereinbart', () => { + const keys: ErrorHintKey[] = [ + 'certName', + 'certExpired', + 'certUntrusted', + 'dnsNotFound', + 'connectionRefused', + 'timeout', + 'httpStatus', + 'fallback', + ]; + for (const key of keys) { + expect(de.nextcloudStatus.errorHint[key], `de ${key}`).toBeTruthy(); + expect(en.nextcloudStatus.errorHint[key], `en ${key}`).toBeTruthy(); + } + expect(de.nextcloudStatus.errorHint).toMatchObject({ + certName: 'Zertifikat passt nicht zur Adresse', + certExpired: 'Zertifikat abgelaufen', + certUntrusted: 'Zertifikat nicht vertrauenswürdig', + dnsNotFound: 'Adresse nicht gefunden', + connectionRefused: 'Verbindung abgelehnt', + timeout: 'Zeitüberschreitung', + httpStatus: 'Server antwortet mit Fehler {code}', + fallback: 'Verbindungsfehler', + }); + }); +}); diff --git a/apps/web/src/components/nextcloud-status/error-hint.ts b/apps/web/src/components/nextcloud-status/error-hint.ts new file mode 100644 index 0000000..d9d0912 --- /dev/null +++ b/apps/web/src/components/nextcloud-status/error-hint.ts @@ -0,0 +1,61 @@ +/** + * Lesbare Hinweise zu fehlgeschlagenen Abrufen (quick-261002-kxc). Die API + * liefert nur Fehlerart (`errorKind`) und eine Kurzkennung (`errorDetail`, + * z. B. `ERR_TLS_CERT_ALTNAME_INVALID`, `ECONNREFUSED`, `HTTP 502`); die Kachel + * zeigt daraus einen Klartext, die Rohkennung bleibt als Tooltip erhalten. + * Dieselben Regeln gelten fuer den Mailtext (`describeCheckError` in der API). + */ + +export type ErrorHintKey = + | 'certName' + | 'certExpired' + | 'certUntrusted' + | 'dnsNotFound' + | 'connectionRefused' + | 'timeout' + | 'httpStatus' + | 'fallback'; + +export interface ErrorHint { + key: ErrorHintKey; + /** Nur bei `httpStatus`: der Statuscode. */ + code?: string; +} + +const CERT_NAME = new Set(['ERR_TLS_CERT_ALTNAME_INVALID', 'HOSTNAME_MISMATCH']); +const CERT_EXPIRED = new Set(['CERT_HAS_EXPIRED']); +const CERT_UNTRUSTED = new Set([ + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'CERT_UNTRUSTED', + 'CERT_NOT_YET_VALID', + 'CERT_REVOKED', +]); +const DNS = new Set(['ENOTFOUND', 'EAI_AGAIN']); +const TIMEOUT = new Set(['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT']); + +export function errorHint( + errorKind: string | null | undefined, + errorDetail: string | null | undefined, +): ErrorHint { + const detail = errorDetail ?? ''; + if (errorKind === 'tls') { + if (CERT_NAME.has(detail)) return { key: 'certName' }; + if (CERT_EXPIRED.has(detail)) return { key: 'certExpired' }; + if (CERT_UNTRUSTED.has(detail)) return { key: 'certUntrusted' }; + return { key: 'fallback' }; + } + if (errorKind === 'timeout') return { key: 'timeout' }; + if (errorKind === 'http-status') { + const match = /^HTTP (\d{3})$/.exec(detail); + return match ? { key: 'httpStatus', code: match[1] } : { key: 'fallback' }; + } + if (errorKind === 'network') { + if (DNS.has(detail)) return { key: 'dnsNotFound' }; + if (detail === 'ECONNREFUSED') return { key: 'connectionRefused' }; + if (TIMEOUT.has(detail)) return { key: 'timeout' }; + } + return { key: 'fallback' }; +} diff --git a/apps/web/src/lib/nextcloud-status-api.ts b/apps/web/src/lib/nextcloud-status-api.ts index a08ecc5..77d43a1 100644 --- a/apps/web/src/lib/nextcloud-status-api.ts +++ b/apps/web/src/lib/nextcloud-status-api.ts @@ -44,6 +44,12 @@ export interface NextcloudInstanceStatus { edition: string | null; errorKind: string | null; errorDetail: string | null; + /** + * Genau ein Fehlschlag in Folge (quick-261002-kxc): Pille, Version und letzte + * Pruefung zeigen den zuletzt guten Stand, die Wiederholung folgt in wenigen + * Minuten. Fehlt das Feld, gilt `false`. + */ + pendingRetry?: boolean; } export interface NextcloudInstance { diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 7db77e6..d35a41c 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1834,7 +1834,8 @@ "lastCheck": "Zuletzt geprüft {age}", "neverChecked": "Noch nie geprüft", "openCloud": "Cloud im neuen Tab öffnen", - "noLogo": "Kein Logo" + "noLogo": "Kein Logo", + "pendingRetry": "Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt" }, "level": { "green": "Grün", @@ -1856,6 +1857,16 @@ "versionUnknown": "Version unbekannt", "notChecked": "Noch nicht geprüft" }, + "errorHint": { + "certName": "Zertifikat passt nicht zur Adresse", + "certExpired": "Zertifikat abgelaufen", + "certUntrusted": "Zertifikat nicht vertrauenswürdig", + "dnsNotFound": "Adresse nicht gefunden", + "connectionRefused": "Verbindung abgelehnt", + "timeout": "Zeitüberschreitung", + "httpStatus": "Server antwortet mit Fehler {code}", + "fallback": "Verbindungsfehler" + }, "sort": { "label": "Sortieren nach", "name": "Kundenname", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index a9394ea..5107e63 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1834,7 +1834,8 @@ "lastCheck": "Last checked {age}", "neverChecked": "Never checked", "openCloud": "Open cloud in a new tab", - "noLogo": "No logo" + "noLogo": "No logo", + "pendingRetry": "Check failed, will be repeated in a few minutes" }, "level": { "green": "Green", @@ -1856,6 +1857,16 @@ "versionUnknown": "Version unknown", "notChecked": "Not checked yet" }, + "errorHint": { + "certName": "Certificate does not match the address", + "certExpired": "Certificate expired", + "certUntrusted": "Certificate not trusted", + "dnsNotFound": "Address not found", + "connectionRefused": "Connection refused", + "timeout": "Timed out", + "httpStatus": "Server responds with error {code}", + "fallback": "Connection error" + }, "sort": { "label": "Sort by", "name": "Customer name", diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 55b8de1..c234504 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -109,6 +109,9 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ 'aktuell', 'Statusseite', 'Bildadresse', + // quick-261002-kxc: Fehlerhinweise der Kachel (korrektes Deutsch) + 'passt', + 'vertrauenswürdig', // quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch) 'Aussteller', 'Betriebssystemen', diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 0e5e9a2..9e28c6b 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -702,6 +702,13 @@ geloest durch die drei SECURITY-DEFINER-Funktionen, nicht durch die Bauform eigenen Mandanten gebunden (höchstens vier gleichzeitig) und schreibt nie im Systemkontext. `FORSYSTEM_ALLOWED_CALL_SITES` pinnt genau einen Aufruf in `nextcloud-status.service.ts` (neue Summe: 7 Dateien, 8 Aufrufe). + **Ergänzung quick-261002-kxc:** ein zweiter Auftrag `nextcloud-status-retry` + (`* * * * *`, jede Minute) wiederholt nur Clouds mit genau einem Fehlschlag, + der mindestens fünf Minuten zurückliegt. Er nutzt denselben einen + Systemlesezugriff (`loadAllInstancesForScheduler({ retryDueBefore })`, nur + ein zusätzlicher `where`-Filter auf derselben `findMany`-Abfrage, weiterhin + `select: { id, tenantId }`) — keine neue Policy, keine neue Fundstelle: + Zählung unverändert 7 Dateien, 8 Aufrufe. ## Bestandsaufnahme