From fd2d31f07f8e6d7bbd2ad0626ea5cadbddfdd281 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 05:28:22 +0200 Subject: [PATCH] Watchdog: Embeds statt Textzeilen, dazu eine Uebersicht im Panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die DM-Alarme waren als einzige Meldung noch reiner Text — direkt neben dem Raid-Alarm, der ein Embed ist. Jetzt beide im selben Look, mit Ausfalldauer bzw. Fehlergrund und Antwortzeit als Felder. Uebersicht im Panel (System, unter den Watchdog-URLs): je Adresse ein Erreichbarkeits-Balken ueber 7 Tage, gruen/rot/grau wie bei den Game-Servern, dazu Status, Antwortzeit und seit wann etwas weg ist. Dafuer schreibt der Waechter jetzt mit — vorher lebte sein Zustand nur im Speicher und war nach jedem Neustart weg. Neue Tabelle watchdog_history, gleiche Bauart und gleiche 7-Tage-Aufbewahrung wie player_history, stundenweise zusammengefasst statt roh ausgeliefert. Wichtig dabei: aufgezeichnet wird jeder Check, nicht nur die Wechsel. Sonst saehen ruhige Stunden im Balken aus wie Messluecken. Der Balken ist aus Server.jsx in components/UptimeTrack.jsx gewandert, damit oeffentliche Seite und Panel denselben benutzen. Er zeigt die Antwortzeit im Tooltip nur, wo sie erhoben wird — bei Game-Servern gibt es keine. Der Endpunkt /api/watchdog liegt hinter der Rechtepruefung, nicht oeffentlich: in der Adressliste koennen interne Dienste stehen. Zwei fehlende Importe in api.js gefunden und ergaenzt (moduleEnabled, tuning) — der erste Aufruf der Route waere sonst mit ReferenceError gestorben. Geprueft: alle 171 SQL-Abfragen gegen das Schema, die drei neuen inklusive. Im Browser mit drei Faellen — laeuft, ist weg, noch nie geprueft: Punktfarbe, Quote, Ausfallstunden und Messluecken stimmen, die ungepruefte Adresse zeigt korrekt gar keinen Balken. Oeffentliche Server-Seite nach dem Umbau gegengeprueft. Co-Authored-By: Claude Fable 5 --- frontend/src/components/UptimeTrack.jsx | 67 +++++++++++++++++++++++++ frontend/src/pages/Server.jsx | 64 +---------------------- frontend/src/pages/Settings.jsx | 28 +++++++++++ frontend/src/style.css | 18 +++++++ src/bot/watchdog.js | 61 +++++++++++++++++----- src/db.js | 29 +++++++++++ src/web/api.js | 33 ++++++++++-- 7 files changed, 222 insertions(+), 78 deletions(-) create mode 100644 frontend/src/components/UptimeTrack.jsx diff --git a/frontend/src/components/UptimeTrack.jsx b/frontend/src/components/UptimeTrack.jsx new file mode 100644 index 0000000..93584b4 --- /dev/null +++ b/frontend/src/components/UptimeTrack.jsx @@ -0,0 +1,67 @@ +// Erreichbarkeits-Balken: ein Strich pro Stunde über 7 Tage. +// +// Wird von der öffentlichen Server-Seite und der Watchdog-Übersicht im Panel +// benutzt — beide bekommen dieselben Stundeneimer vom Server geliefert +// ({ stunde, proben, an }), deshalb liegt das hier und nicht in einer Seite. +import { useT, useFmt } from '../i18n.jsx'; + +const STUNDEN = 168; + +export default function UptimeTrack({ buckets }) { + const { t } = useT(); + const { tag, datum } = useFmt(); + if (!buckets?.length) return null; + + const proStunde = new Map(buckets.map((b) => [b.stunde, b])); + const jetzt = new Date(); + jetzt.setMinutes(0, 0, 0); + + const striche = []; + let gemessen = 0; + let erreichbar = 0; + for (let i = STUNDEN - 1; i >= 0; i--) { + const d = new Date(jetzt.getTime() - i * 3600000); + // Schlüssel wie strftime('%Y-%m-%dT%H') in SQLite — das rechnet in UTC, + // hier also bewusst auch. Angezeigt wird gleich die Ortszeit, sonst + // stünde im Tooltip eine Stunde, die niemand wiedererkennt. + const key = d.toISOString().slice(0, 13); + const b = proStunde.get(key); + let art = 'leer'; + if (b) { + gemessen += b.proben; + erreichbar += b.an; + art = b.an === b.proben ? 'gut' : b.an === 0 ? 'aus' : 'teil'; + } + striche.push({ key, zeit: d, art, b }); + } + + const quote = gemessen > 0 ? (erreichbar / gemessen) * 100 : null; + const titel = (s) => { + const wann = datum(s.zeit, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + if (!s.b) return `${wann} — ${t('server.noData')}`; + const anteil = Math.round((s.b.an / s.b.proben) * 100); + // Antwortzeit nur, wo sie erhoben wird (Watchdog) und etwas lief + return s.b.schnitt != null && s.b.an > 0 + ? `${wann} — ${anteil} % · ${s.b.schnitt} ms` + : `${wann} — ${anteil} %`; + }; + + return ( +
+
+ {striche.map((s) => ( + + ))} +
+
+ {t('server.days7')} + {quote != null && ( + = 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}> + {quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} % + + )} + {t('server.now')} +
+
+ ); +} diff --git a/frontend/src/pages/Server.jsx b/frontend/src/pages/Server.jsx index 7340d5a..b374414 100644 --- a/frontend/src/pages/Server.jsx +++ b/frontend/src/pages/Server.jsx @@ -6,6 +6,7 @@ import { apiGet } from '../api.js'; import { SkeletonCards } from '../components/Skeleton.jsx'; import { IconGame, IconMapPin, IconClock, IconCheck } from '../icons.jsx'; import { useT, useFmt } from '../i18n.jsx'; +import UptimeTrack from '../components/UptimeTrack.jsx'; const UHRZEIT = { hour: '2-digit', minute: '2-digit' }; @@ -38,69 +39,6 @@ function HistoryChart({ history, max }) { ); } -/** - * Erreichbarkeits-Balken: ein Strich pro Stunde der letzten 7 Tage. - * - * Der Server liefert nur Stunden, in denen wirklich gemessen wurde. Fehlende - * werden hier als Lücke gezeichnet statt als „online" — sonst sähe eine - * Bot-Auszeit aus wie ein einwandfrei laufender Server. - */ -function UptimeTrack({ buckets }) { - const { t } = useT(); - const { tag, datum } = useFmt(); - if (!buckets?.length) return null; - - const proStunde = new Map(buckets.map((b) => [b.stunde, b])); - const jetzt = new Date(); - jetzt.setMinutes(0, 0, 0); - - const striche = []; - let gemessen = 0; - let erreichbar = 0; - for (let i = 167; i >= 0; i--) { - const d = new Date(jetzt.getTime() - i * 3600000); - // Schlüssel wie strftime('%Y-%m-%dT%H') in SQLite — das rechnet in UTC, - // hier also bewusst auch. Angezeigt wird gleich die Ortszeit, sonst - // stünde im Tooltip eine Stunde, die niemand wiedererkennt. - const key = d.toISOString().slice(0, 13); - const b = proStunde.get(key); - let art = 'leer'; - if (b) { - gemessen += b.proben; - erreichbar += b.an; - art = b.an === b.proben ? 'gut' : b.an === 0 ? 'aus' : 'teil'; - } - striche.push({ key, zeit: d, art, b }); - } - - const quote = gemessen > 0 ? (erreichbar / gemessen) * 100 : null; - const titel = (s) => { - const wann = datum(s.zeit, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); - return s.b - ? `${wann} — ${Math.round((s.b.an / s.b.proben) * 100)} %` - : `${wann} — ${t('server.noData')}`; - }; - - return ( -
-
- {striche.map((s) => ( - - ))} -
-
- {t('server.days7')} - {quote != null && ( - = 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}> - {quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} % - - )} - {t('server.now')} -
-
- ); -} - /** Spieler-Peak aus den 24h-Samples (Uptime kommt aus dem Balken oben) */ function historyStats(history) { if (history.length === 0) return null; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 5eafe3f..d3aa33a 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -10,6 +10,7 @@ import { } from '../icons.jsx'; import { moduleIcon } from '../module-icons.jsx'; import ModulePage from '../components/ModulePage.jsx'; +import UptimeTrack from '../components/UptimeTrack.jsx'; import { findSettings, SETTING_INDEX } from '../setting-index.js'; import { Markdown } from '../markdown.jsx'; @@ -341,6 +342,7 @@ export default function Settings({ me }) { const [team, setTeam] = useState([]); const [audit, setAudit] = useState([]); const [serverStats, setServerStats] = useState(null); + const [watchdog, setWatchdog] = useState(null); const [owner, setOwner] = useState(null); const [auditAlle, setAuditAlle] = useState(false); // Module (An/Aus für alle Funktionen) @@ -467,6 +469,7 @@ export default function Settings({ me }) { apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {}); // Fuers Dashboard: Mitgliederzahl und Nachrichten pro Tag apiGet('/api/serverstats').then(setServerStats).catch(() => {}); + apiGet('/api/watchdog').then(setWatchdog).catch(() => {}); apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {}); apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {}); apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {}); @@ -2337,6 +2340,31 @@ export default function Settings({ me }) { setForm({ ...form, watchdog_urls: e.target.value })} /> + + {watchdog?.adressen?.length > 0 && ( +
+

// Erreichbarkeit

+

+ Ein Strich je Stunde über die letzten 7 Tage — grün erreichbar, rot + Ausfall, grau keine Messung. Geprüft wird alle {watchdog.intervall} Minuten. + {!watchdog.aktiv && ' Der Wächter ist gerade ausgeschaltet, es kommen keine neuen Messpunkte dazu.'} +

+ {watchdog.adressen.map((a) => ( +
+
+ + {a.url} + + {a.online === null && 'noch nicht geprüft'} + {a.online === true && `${a.status ?? 'OK'}${a.ms != null ? ` · ${a.ms} ms` : ''}`} + {a.online === false && `seit ${vorWieLange(new Date(a.seit).toISOString())} weg`} + +
+ +
+ ))} +
+ )} ); diff --git a/frontend/src/style.css b/frontend/src/style.css index dc6718e..e2445d1 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1815,6 +1815,24 @@ label.toggle { .dash-group-name { flex-basis: 7rem; } } +/* ── WATCHDOG-ÜBERSICHT ────────────────────────────── */ +.wd-card { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; } +.wd-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; } +.wd-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; } +.wd-dot.an { background: var(--success, #3ba55d); } +.wd-dot.aus { background: #f23f43; } +.wd-dot.wartet { background: var(--muted2); } +.wd-url { + font-family: var(--mono); font-size: .74rem; + color: var(--text); text-decoration: none; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; +} +.wd-url:hover { color: var(--neon); } +.wd-meta { + margin-left: auto; font-family: var(--mono); + font-size: .62rem; letter-spacing: .06em; color: var(--muted2); white-space: nowrap; +} + /* ── ERREICHBARKEITS-BALKEN ────────────────────────── */ /* Ein Strich pro Stunde, 168 Stück. Auf schmalen Schirmen werden die Striche dünner als ein Pixel — deshalb scrollt der Balken dort lieber, statt zu diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js index 10554f6..474c600 100644 --- a/src/bot/watchdog.js +++ b/src/bot/watchdog.js @@ -1,23 +1,31 @@ -// Watchdog: prüft konfigurierte URLs und schickt dem Admin eine DM bei Ausfall. +// 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. -import { getSetting } from '../db.js'; +import { getSetting, recordWatchdogSample, pruneWatchdogHistory } from '../db.js'; import { config } from '../config.js'; import { moduleEnabled } from '../modules.js'; +import { brandEmbed } from '../embeds.js'; import { tuning, tuningMs, everyTuned } from '../tuning.js'; // URL → { fails, down, since } const state = new Map(); -function watchedUrls() { +/** Aktueller Stand je Adresse — für die Übersicht im Panel */ +export function watchdogState() { + return state; +} + +export function watchedUrls() { return (getSetting('watchdog_urls') ?? '') .split(',') .map((s) => s.trim()) .filter((s) => /^https?:\/\//.test(s)); } -async function dmAdmin(client, content) { - const user = await client.users.fetch(config.adminDiscordId); - await user.send(content); +async function dmAdmin(client, embed) { + if (!config.adminDiscordId) return; + const user = await client.users.fetch(config.adminDiscordId).catch(() => null); + await user?.send({ embeds: [embed] }).catch(() => {}); } /** Ein Prüfdurchlauf — exportiert für Tests und den Intervall-Timer */ @@ -28,6 +36,8 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog let ok = false; let detail = ''; + let status = null; + const start = Date.now(); try { const res = await fetch(url, { method: 'GET', @@ -36,29 +46,56 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog 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; + + // Immer mitschreiben, nicht nur bei Wechseln — der Balken braucht + // durchgehende Messpunkte, sonst sähen ruhige Stunden aus wie Lücken. + try { + recordWatchdogSample(url, ok, ok ? dauer : null); + } catch (error) { + console.error('[watchdog] Messpunkt nicht gespeichert:', error.message); + } if (ok) { if (s.down) { - const minutes = Math.max(1, Math.round((Date.now() - s.since) / 60000)); - await dmAdmin(client, `✅ **${url}** ist wieder erreichbar (Downtime: ~${minutes} min).`).catch(() => {}); + const minuten = Math.max(1, Math.round((Date.now() - s.since) / 60000)); + await dmAdmin(client, brandEmbed(client, 'WATCHDOG') + .setTitle('✅ Wieder erreichbar') + .setDescription(`**${url}**`) + .addFields( + { name: 'Ausfall', value: `~${minuten} min`, inline: true }, + { name: 'Antwort', value: `${detail} · ${dauer} ms`, inline: true }, + )); console.log(`[watchdog] ${url} wieder ok`); } - state.set(url, { fails: 0, down: false, since: null }); + state.set(url, { fails: 0, down: false, since: null, status, ms: dauer, geprueft: Date.now() }); } else { const fails = s.fails + 1; if (!s.down && fails >= failsBeforeAlert) { - await dmAdmin(client, `🚨 **${url}** ist DOWN (${detail}) — seit ${failsBeforeAlert} Checks nicht erreichbar!`).catch(() => {}); + await dmAdmin(client, brandEmbed(client, 'WATCHDOG', { secondary: true }) + .setTitle('🚨 Nicht erreichbar') + .setDescription(`**${url}**`) + .addFields( + { name: 'Fehler', value: detail, inline: true }, + { name: 'Versuche', value: `${fails} in Folge`, inline: true }, + )); console.warn(`[watchdog] ${url} down (${detail})`); - state.set(url, { fails, down: true, since: Date.now() }); + state.set(url, { fails, down: true, since: Date.now(), status, ms: null, geprueft: Date.now() }); } else { - state.set(url, { ...s, fails }); + state.set(url, { ...s, fails, status, ms: null, geprueft: Date.now() }); } } } + + // Alte Messpunkte wegräumen, gleiche Aufbewahrung wie beim Game-Server + try { + pruneWatchdogHistory(); + } catch { /* nicht kritisch, beim nächsten Durchlauf wieder */ } } export function startWatchdog(client) { diff --git a/src/db.js b/src/db.js index e76a818..b28bd60 100644 --- a/src/db.js +++ b/src/db.js @@ -712,6 +712,35 @@ export const playerHistory = (serverId, hours = 24) => playerHistoryStmt.all(ser export const uptimeBuckets = (serverId, hours = 168) => uptimeBucketsStmt.all(serverId, `-${hours} hours`); export const prunePlayerHistory = () => prunePlayerHistoryStmt.run(); +// Watchdog: Erreichbarkeit der überwachten Adressen — gleiche Bauart wie oben, +// damit die Übersicht denselben Balken benutzen kann. Adresse als Schlüssel: +// die Liste steht als Einstellung und hat keine IDs. +db.exec(` + CREATE TABLE IF NOT EXISTS watchdog_history ( + url TEXT NOT NULL, + ts TEXT NOT NULL DEFAULT (datetime('now')), + ok INTEGER NOT NULL, + ms INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_wd_url_ts ON watchdog_history (url, ts); +`); +const insertWatchdogSample = db.prepare('INSERT INTO watchdog_history (url, ok, ms) VALUES (?, ?, ?)'); +const watchdogBucketsStmt = db.prepare(` + SELECT strftime('%Y-%m-%dT%H', ts) AS stunde, + COUNT(*) AS proben, + SUM(ok) AS an, + CAST(AVG(ms) AS INTEGER) AS schnitt + FROM watchdog_history + WHERE url = ? AND ts >= datetime('now', ?) + GROUP BY stunde ORDER BY stunde +`); +// Adressen, die aus der Einstellung entfernt wurden, sollen nicht ewig bleiben +const pruneWatchdogStmt = db.prepare(`DELETE FROM watchdog_history WHERE ts < datetime('now', '-7 days')`); +export const recordWatchdogSample = (url, ok, ms) => + insertWatchdogSample.run(url, ok ? 1 : 0, Number.isFinite(ms) ? Math.round(ms) : null); +export const watchdogBuckets = (url, hours = 168) => watchdogBucketsStmt.all(url, `-${hours} hours`); +export const pruneWatchdogHistory = () => pruneWatchdogStmt.run(); + // Moderation: Verwarnungen + Sticky-Roles db.exec(` CREATE TABLE IF NOT EXISTS warns ( diff --git a/src/web/api.js b/src/web/api.js index fd02b14..7625126 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -15,16 +15,17 @@ import { saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin, saveTemplate, listTemplates, deleteTemplate, getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes, - logAudit, listAudit, playerHistory, uptimeBuckets, deleteUserData, + logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, deleteUserData, saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp, createService, updateService, listServices, deleteService, savePage, getPage, listPages, listPublishedPages, deletePage, } from '../db.js'; import crypto from 'node:crypto'; -import { moduleStates, moduleDetail, setModuleEnabled, moduleFavorites, setModuleFavorites, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js'; +import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js'; import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js'; -import { tuningStates, setTuning, TUNING_GROUPS } from '../tuning.js'; +import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js'; import { lastResults, GAME_ICONS } from '../bot/server-monitor.js'; +import { watchdogState, watchedUrls } from '../bot/watchdog.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { sanitizeEmbed } from './api-v1.js'; import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js'; @@ -189,6 +190,32 @@ export function registerApiRoutes(app, client) { }), })); + // Watchdog-Übersicht: Stand und Erreichbarkeit der überwachten Adressen. + // Nicht öffentlich — in der Liste können interne Dienste stehen, die + // niemand von außen kennen muss. + app.get('/api/watchdog', async (request, reply) => { + if (requireAnyScope(request, reply)) return; + const stand = watchdogState(); + return { + aktiv: moduleEnabled('watchdog'), + intervall: tuning('watchdog_interval'), + adressen: watchedUrls().map((url) => { + const s = stand.get(url) ?? {}; + return { + url, + // undefined = seit dem Start noch nicht geprüft + online: s.geprueft ? !s.down : null, + fails: s.fails ?? 0, + seit: s.since ?? null, + ms: s.ms ?? null, + status: s.status ?? null, + geprueft: s.geprueft ?? null, + uptime: watchdogBuckets(url, 168), + }; + }), + }; + }); + // --- Module: zentrale An/Aus-Schalter aller Funktionen --- app.get('/api/modules', async (request, reply) => {