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
@@ -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 (
<article
@@ -198,8 +204,24 @@ export function CloudTile({
</span>
</div>
{showErrorDetail && (
<p className="break-words text-xs text-muted-foreground">{instance.status.errorDetail}</p>
{hintText && (
<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
@@ -378,4 +378,62 @@ describe('NextcloudStatusPage', () => {
).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;
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 {
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
@@ -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',