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
@@ -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' };
}