feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel

- Wiederholungsauftrag jede Minute für Clouds mit einem Fehlschlag älter als fünf Minuten
- Neue Adresse setzt den Prüfstand zurück, der gemeldete Zustand bleibt
- Kachel: Hinweis Prüfung fehlgeschlagen, Fehlercodes als lesbarer Text mit Tooltip

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:26:23 +02:00
parent faed0d760f
commit 11a70c9ee9
14 changed files with 515 additions and 34 deletions
@@ -8,6 +8,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { ALERT_MAIL_RETRY_MS, NextcloudAlertService } from './nextcloud-alert.service'; import { ALERT_MAIL_RETRY_MS, NextcloudAlertService } from './nextcloud-alert.service';
import { buildNextcloudAlertMail } from './nextcloud-alert-mail';
import type { NextcloudRating } from './nextcloud-rating'; import type { NextcloudRating } from './nextcloud-rating';
const NOW = new Date('2026-10-02T12:30:00Z'); 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.prisma.user.findMany).not.toHaveBeenCalled();
expect(ctx.mail.sendNextcloudAlertEmail).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<typeof buildNextcloudAlertMail>[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<typeof makeService>, row: typeof ROW, rating: NextcloudRating) {
const result = await ctx.service.evaluateAfterCheck('t1', row, rating, NOW);
await result.delivery;
}
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { RETRY_DELAY_MS } from './nextcloud-alert-rules';
import { import {
NEXTCLOUD_CRON, NEXTCLOUD_CRON,
NEXTCLOUD_JOB_NAME, NEXTCLOUD_JOB_NAME,
NEXTCLOUD_RETRY_CRON,
NEXTCLOUD_RETRY_JOB_NAME,
NextcloudStatusSchedulerService, NextcloudStatusSchedulerService,
} from './nextcloud-status-scheduler.service'; } from './nextcloud-status-scheduler.service';
@@ -40,10 +43,22 @@ function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: s
} }
describe('NextcloudStatusSchedulerService', () => { describe('NextcloudStatusSchedulerService', () => {
it('registriert genau einen stuendlichen Auftrag ohne Datenbankzugriff und startet ihn', async () => { it('registriert den stuendlichen Auftrag und die Wiederholung ohne Datenbankzugriff und startet beide', async () => {
const { registry, service, release, scheduler } = makeScheduler(); const { registry, service, release, scheduler, logSpy } = makeScheduler();
await scheduler.onApplicationBootstrap(); 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); expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true);
const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME); const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME);
expect(job.cronTime.source).toBe(NEXTCLOUD_CRON); expect(job.cronTime.source).toBe(NEXTCLOUD_CRON);
@@ -108,4 +123,83 @@ describe('NextcloudStatusSchedulerService', () => {
await scheduler.tick(); await scheduler.tick();
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2); 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<void>((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);
});
});
}); });
@@ -1,5 +1,6 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule'; import { SchedulerRegistry } from '@nestjs/schedule';
import { RETRY_DELAY_MS } from './nextcloud-alert-rules';
import { NextcloudReleaseService } from './nextcloud-release.service'; import { NextcloudReleaseService } from './nextcloud-release.service';
import { import {
CHECK_CONCURRENCY, CHECK_CONCURRENCY,
@@ -27,6 +28,10 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll'; export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll';
/** Jede volle Stunde (L-08). */ /** Jede volle Stunde (L-08). */
export const NEXTCLOUD_CRON = '0 * * * *'; 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 * NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds
@@ -50,6 +55,7 @@ export const NEXTCLOUD_CRON = '0 * * * *';
export class NextcloudStatusSchedulerService implements OnApplicationBootstrap { export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
private readonly logger = new Logger(NextcloudStatusSchedulerService.name); private readonly logger = new Logger(NextcloudStatusSchedulerService.name);
private running = false; private running = false;
private retryRunning = false;
constructor( constructor(
private readonly schedulerRegistry: SchedulerRegistry, private readonly schedulerRegistry: SchedulerRegistry,
@@ -75,6 +81,15 @@ export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any); this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any);
job.start(); job.start();
this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`); 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); void this.release.refresh().catch(() => undefined);
} catch (err) { } catch (err) {
this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`); this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`);
@@ -89,29 +104,56 @@ export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
} }
this.running = true; this.running = true;
try { try {
const rows = await this.service.loadAllInstancesForScheduler(); await this.checkRows(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 { } finally {
this.running = false; 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<void> {
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<void> {
// 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}`,
);
}
});
}
} }
@@ -271,11 +271,25 @@ describe('NextcloudStatusService', () => {
expect(fetchNextcloudStatus).not.toHaveBeenCalled(); 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/' }); 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', 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); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
vi.mocked(fetchNextcloudStatus).mockClear(); vi.mocked(fetchNextcloudStatus).mockClear();
@@ -283,6 +297,20 @@ describe('NextcloudStatusService', () => {
expect(fetchNextcloudStatus).not.toHaveBeenCalled(); 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 () => { it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow( await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
BadRequestException, BadRequestException,
@@ -435,5 +463,25 @@ describe('NextcloudStatusService', () => {
}); });
expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]); 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]);
});
}); });
}); });
@@ -39,6 +39,7 @@ export const PUBLIC_SELECT = {
edition: true, edition: true,
errorKind: true, errorKind: true,
errorDetail: true, errorDetail: true,
consecutiveFailures: true,
} as const; } as const;
type PublicRow = { type PublicRow = {
@@ -56,6 +57,7 @@ type PublicRow = {
edition: string | null; edition: string | null;
errorKind: string | null; errorKind: string | null;
errorDetail: string | null; errorDetail: string | null;
consecutiveFailures: number;
}; };
export interface NextcloudInstanceView { export interface NextcloudInstanceView {
@@ -74,6 +76,11 @@ export interface NextcloudInstanceView {
edition: string | null; edition: string | null;
errorKind: string | null; errorKind: string | null;
errorDetail: 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; rating: NextcloudRating;
/** /**
@@ -125,6 +132,7 @@ export class NextcloudStatusService {
private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView { private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView {
const status = { const status = {
pendingRetry: row.consecutiveFailures === 1,
checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null, checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
reachable: row.reachable, reachable: row.reachable,
maintenance: row.maintenance, maintenance: row.maintenance,
@@ -271,6 +279,22 @@ export class NextcloudStatusService {
if (baseUrl !== existing.baseUrl) { if (baseUrl !== existing.baseUrl) {
data.baseUrl = baseUrl; data.baseUrl = baseUrl;
urlChanged = true; 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 * Leserecht ueber `system_read_policy ... FOR SELECT` der Migration
* 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder * 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder
* Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen * 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); const systemPrisma = forSystem(this.prisma);
return systemPrisma.nextcloudInstance.findMany({ 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 }, select: { id: true, tenantId: true },
}); });
} }
@@ -2,6 +2,7 @@
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { errorHint } from '@/components/nextcloud-status/error-hint';
import { RATING_STYLE, ratingReasonText } from '@/components/nextcloud-status/rating-display'; import { RATING_STYLE, ratingReasonText } from '@/components/nextcloud-status/rating-display';
import { formatAge } from '@/components/proxmox/proxmox-status'; import { formatAge } from '@/components/proxmox/proxmox-status';
import { logoSrc, type NextcloudInstance } from '@/lib/nextcloud-status-api'; import { logoSrc, type NextcloudInstance } from '@/lib/nextcloud-status-api';
@@ -55,10 +56,15 @@ export function CloudTile({
const reason = ratingReasonText(t, instance.rating, locale); const reason = ratingReasonText(t, instance.rating, locale);
const src = logoFailed ? null : logoSrc(instance); const src = logoFailed ? null : logoSrc(instance);
const checkedAge = formatAge(instance.status.checkedAt, now, locale); 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.level === 'red' &&
instance.rating.reason === 'unreachable' && 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 ( return (
<article <article
@@ -198,8 +204,24 @@ export function CloudTile({
</span> </span>
</div> </div>
{showErrorDetail && ( {hintText && (
<p className="break-words text-xs text-muted-foreground">{instance.status.errorDetail}</p> <p
data-testid="error-hint"
className="break-words text-xs text-muted-foreground"
title={hintRaw}
>
{hintText}
</p>
)}
{instance.status.pendingRetry === true && (
<p
data-testid="pending-retry"
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<span aria-hidden="true" className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-warn" />
{t('card.pendingRetry')}
</p>
)} )}
<div <div
@@ -378,4 +378,62 @@ describe('NextcloudStatusPage', () => {
).toHaveAttribute('aria-pressed', '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(<NextcloudStatusPage />);
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();
});
});
}); });
@@ -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',
});
});
});
@@ -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' };
}
+6
View File
@@ -44,6 +44,12 @@ export interface NextcloudInstanceStatus {
edition: string | null; edition: string | null;
errorKind: string | null; errorKind: string | null;
errorDetail: 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 { export interface NextcloudInstance {
+12 -1
View File
@@ -1834,7 +1834,8 @@
"lastCheck": "Zuletzt geprüft {age}", "lastCheck": "Zuletzt geprüft {age}",
"neverChecked": "Noch nie geprüft", "neverChecked": "Noch nie geprüft",
"openCloud": "Cloud im neuen Tab öffnen", "openCloud": "Cloud im neuen Tab öffnen",
"noLogo": "Kein Logo" "noLogo": "Kein Logo",
"pendingRetry": "Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt"
}, },
"level": { "level": {
"green": "Grün", "green": "Grün",
@@ -1856,6 +1857,16 @@
"versionUnknown": "Version unbekannt", "versionUnknown": "Version unbekannt",
"notChecked": "Noch nicht geprüft" "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": { "sort": {
"label": "Sortieren nach", "label": "Sortieren nach",
"name": "Kundenname", "name": "Kundenname",
+12 -1
View File
@@ -1834,7 +1834,8 @@
"lastCheck": "Last checked {age}", "lastCheck": "Last checked {age}",
"neverChecked": "Never checked", "neverChecked": "Never checked",
"openCloud": "Open cloud in a new tab", "openCloud": "Open cloud in a new tab",
"noLogo": "No logo" "noLogo": "No logo",
"pendingRetry": "Check failed, will be repeated in a few minutes"
}, },
"level": { "level": {
"green": "Green", "green": "Green",
@@ -1856,6 +1857,16 @@
"versionUnknown": "Version unknown", "versionUnknown": "Version unknown",
"notChecked": "Not checked yet" "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": { "sort": {
"label": "Sort by", "label": "Sort by",
"name": "Customer name", "name": "Customer name",
@@ -109,6 +109,9 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'aktuell', 'aktuell',
'Statusseite', 'Statusseite',
'Bildadresse', 'Bildadresse',
// quick-261002-kxc: Fehlerhinweise der Kachel (korrektes Deutsch)
'passt',
'vertrauenswürdig',
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch) // quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
'Aussteller', 'Aussteller',
'Betriebssystemen', 'Betriebssystemen',
@@ -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 eigenen Mandanten gebunden (höchstens vier gleichzeitig) und schreibt nie im
Systemkontext. `FORSYSTEM_ALLOWED_CALL_SITES` pinnt genau einen Aufruf in Systemkontext. `FORSYSTEM_ALLOWED_CALL_SITES` pinnt genau einen Aufruf in
`nextcloud-status.service.ts` (neue Summe: 7 Dateien, 8 Aufrufe). `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 ## Bestandsaufnahme