diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js index 4ba7626..02a2d17 100644 --- a/src/bot/watchdog.js +++ b/src/bot/watchdog.js @@ -1,6 +1,6 @@ // Watchdog: prüft konfigurierte URLs, meldet Ausfälle per DM und schreibt // jeden Check mit — daraus baut die Übersicht im Panel ihren Uptime-Balken. -// URLs kommen aus dem Setting watchdog_urls (Config-Seite), leer = deaktiviert. +// Was geprüft wird, steht in der Dienst-Tabelle (Setup → Dienste). import { listMonitored, recordWatchdogSample, pruneWatchdogHistory, startIncident, endIncident, pruneIncidents, @@ -30,31 +30,154 @@ async function dmAdmin(client, embed) { await user?.send({ embeds: [embed] }).catch(() => {}); } +// ---------------------------------------------------------------- Der Grund +// +// Bis hierhin stand im Ausfall-Embed nur "HTTP 503". Das sagt, DASS etwas ist, +// nicht WAS — und wer nachts eine DM bekommt, will genau das wissen, bevor er +// sich einloggt. +// +// Viele Dienste sagen es nämlich selbst. d4rk_media zum Beispiel antwortet auf +// /status mit einer 503 UND einer Begründung: +// +// { "stand": "gestoert", "pruefungen": [ +// { "was": "bestand", "ok": false, +// "hinweis": "kein Eintrag in der Datenbank, aber Dateien auf der Platte" } ] } +// +// Dieser Satz ist der Unterschied zwischen "irgendwas ist kaputt" und "die +// Datenbank ist leer, die Dateien liegen noch da". Er kostet uns nichts: den +// Rumpf müssen wir ohnehin lesen oder verwerfen, sonst hängt die Verbindung. +// +// DAS URTEIL BLEIBT DER STATUSCODE. Ein Dienst, der 200 mit "ok": false +// antwortet, gilt weiterhin als erreichbar — alles andere wäre eine stille +// Verhaltensänderung für jede andere überwachte Adresse. Wer das anders will, +// lässt seinen Dienst eine 5xx schicken; d4rk_media kann das über ?streng=1. + +/** Wie viel vom Rumpf wir überhaupt anfassen. Ein überwachter Dienst könnte + * ein Megabyte zurückgeben; für einen Satz Begründung reicht das hier. */ +const GRUND_MAX_BYTES = 64 * 1024; + +/** Höchstlänge des Satzes im Embed. Discord nimmt 1024 je Feld — aber eine + * Begründung, die man scrollen muss, ist keine. */ +const GRUND_MAX_ZEICHEN = 240; + +/** Aus einer JSON-Antwort einen Satz machen, oder null. + * + * Erst die Form, die unsere eigenen Dienste sprechen (eine Liste von + * Prüfungen), dann die üblichen Einzelfelder. Wer keins davon liefert, + * bekommt weiterhin nur "HTTP 503" — das ist kein Fehler, nur keine + * Zusatzauskunft. */ +function grundAus(daten) { + if (!daten || typeof daten !== 'object') return null; + + // Unsere Form: was ist konkret durchgefallen? + if (Array.isArray(daten.pruefungen)) { + const schlecht = daten.pruefungen + .filter((p) => p && p.ok === false) + .map((p) => (p.hinweis ? `${p.was}: ${p.hinweis}` : String(p.was))) + .filter(Boolean); + if (schlecht.length) return schlecht.join(' · '); + } + + // Die üblichen Verdächtigen, in der Reihenfolge ihrer Aussagekraft. + for (const feld of ['grund', 'reason', 'error', 'message', 'detail', 'fehler']) { + const wert = daten[feld]; + if (typeof wert === 'string' && wert.trim()) return wert.trim(); + } + + // Als Letztes ein bloßer Zustand ("degraded", "gestoert") — besser als + // nichts, aber nur, wenn er nicht ohnehin "ok" sagt. + for (const feld of ['stand', 'status', 'state']) { + const wert = daten[feld]; + if (typeof wert === 'string' && !/^(ok|up|gesund|healthy)$/i.test(wert.trim())) { + return wert.trim(); + } + } + return null; +} + +/** Den Rumpf lesen, wenn er klein genug und JSON ist. Gibt immer etwas zurück + * (notfalls null) und wirft nie — eine Begründung ist ein Zusatz, kein Grund, + * eine Prüfung scheitern zu lassen. + * + * Der Rumpf wird IN JEDEM FALL verbraucht oder verworfen. Ein `fetch`, dessen + * Antwort niemand anfasst, hält die Verbindung offen, bis der Aufräumer sie + * holt — bei einer Prüfung alle paar Minuten fällt das nicht auf, aber es ist + * schlicht der falsche Umgang. */ +async function grundLesen(res) { + try { + const typ = res.headers.get('content-type') ?? ''; + const laenge = Number(res.headers.get('content-length') ?? NaN); + + if (!/\bjson\b/i.test(typ) || (Number.isFinite(laenge) && laenge > GRUND_MAX_BYTES)) { + await res.body?.cancel(); + return null; + } + + const text = await res.text(); + if (text.length > GRUND_MAX_BYTES) return null; + + const satz = grundAus(JSON.parse(text)); + if (!satz) return null; + return satz.length > GRUND_MAX_ZEICHEN + ? `${satz.slice(0, GRUND_MAX_ZEICHEN - 1)}…` + : satz; + } catch { + // Kaputtes JSON, abgebrochene Verbindung, was auch immer: dann eben + // ohne Begründung. + await res.body?.cancel().catch(() => {}); + return null; + } +} + +// -------------------------------------------------------------- Eine Prüfung + +/** Eine einzelne Adresse prüfen. Wirft nie. */ +async function pruefe(dienst) { + const start = Date.now(); + try { + const res = await fetch(dienst.url, { + method: 'GET', + redirect: 'follow', + signal: AbortSignal.timeout(tuningMs.seconds('watchdog_timeout')), + headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' }, + }); + const grund = await grundLesen(res); + return { + ok: res.status < 500, + status: res.status, + detail: grund ? `HTTP ${res.status} — ${grund}` : `HTTP ${res.status}`, + dauer: Date.now() - start, + }; + } catch (error) { + return { + ok: false, + status: null, + detail: error.cause?.code ?? error.name ?? 'Fehler', + dauer: Date.now() - start, + }; + } +} + /** Ein Prüfdurchlauf — exportiert für Tests und den Intervall-Timer */ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog_fails') } = {}) { if (!moduleEnabled('watchdog')) return; - for (const dienst of watchedServices()) { - const { url } = dienst; - const s = state.get(url) ?? { fails: 0, down: false, since: null }; + const dienste = watchedServices(); - let ok = false; - let detail = ''; - let status = null; - const start = Date.now(); - try { - const res = await fetch(url, { - method: 'GET', - redirect: 'follow', - signal: AbortSignal.timeout(tuningMs.seconds('watchdog_timeout')), - headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' }, - }); - ok = res.status < 500; - status = res.status; - detail = `HTTP ${res.status}`; - } catch (error) { - detail = error.cause?.code ?? error.name ?? 'Fehler'; - } - const dauer = Date.now() - start; + // NEBENEINANDER statt nacheinander. Vorher lief die Schleife der Reihe + // nach, und jede Adresse durfte bis zum Zeitlimit brauchen — bei fünf + // Diensten und zehn Sekunden Grenze konnte ein Durchlauf also fast eine + // Minute dauern. Steht das Prüfintervall auf zwei Minuten, ist das schon + // knapp; bei zehn Diensten überholt sich der Wächter selbst. + // + // Die Antworten kommen dadurch gleichzeitig, das Auswerten bleibt der + // Reihe nach: die DMs sollen in einer nachvollziehbaren Ordnung ankommen + // und nicht in der, in der zufällig geantwortet wurde. + const ergebnisse = await Promise.all(dienste.map(pruefe)); + + for (const [i, dienst] of dienste.entries()) { + const { url } = dienst; + const { ok, status, detail, dauer } = ergebnisse[i]; + const s = state.get(url) ?? { fails: 0, down: false, since: null }; // Immer mitschreiben, nicht nur bei Wechseln — der Balken braucht // durchgehende Messpunkte, sonst sähen ruhige Stunden aus wie Lücken. @@ -83,12 +206,15 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog } else { const fails = s.fails + 1; if (!s.down && fails >= failsBeforeAlert) { + // Der Grund geht MIT in die Störung — listIncidents gibt ihn + // zurück, und damit steht er in der Historie der öffentlichen + // Statusseite und nicht nur einmalig in einer DM. startIncident(url, detail); await dmAdmin(client, brandEmbed(client, 'WATCHDOG', { secondary: true }) .setTitle('🚨 Nicht erreichbar') .setDescription(`**${dienst.name}**\n${url}`) .addFields( - { name: 'Fehler', value: detail, inline: true }, + { name: 'Fehler', value: detail, inline: false }, { name: 'Versuche', value: `${fails} in Folge`, inline: true }, )); console.warn(`[watchdog] ${dienst.name} down (${detail})`); @@ -99,6 +225,14 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog } } + // Was nicht mehr überwacht wird, gehört auch nicht mehr in den Zustand. + // Ohne das zeigt die Übersicht im Panel Dienste an, die längst gelöscht + // sind — und der Eintrag bliebe bis zum Neustart des Bots stehen. + const aktuell = new Set(dienste.map((d) => d.url)); + for (const url of state.keys()) { + if (!aktuell.has(url)) state.delete(url); + } + // Alte Messpunkte und abgeschlossene Störungen wegräumen (90 Tage) try { pruneWatchdogHistory();