From 55e9ef57e25f0d3e19f9e50e3973e977ad52a663 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 05:45:50 +0200 Subject: [PATCH] Oeffentliche Statusseite nach Statuspage-Vorbild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Seite /status auf dem Hub: Gesamtlage als Banner, darunter die freigegebenen Dienste mit 90-Tage-Balken, die Game-Server, und die Stoerungs-Historie der letzten 90 Tage. Die Adressliste ist von einer kommagetrennten Einstellung in eine Tabelle gewandert. Jeder Dienst hat jetzt Namen, Gruppe und einen Schalter "oeffentlich" — sonst stuende auf einer oeffentlichen Seite die nackte URL, auch von internen Diensten. Die alte Liste wird beim ersten Start uebernommen, bewusst als nicht oeffentlich: was dort auftaucht, soll eine Entscheidung sein und nicht durch eine Migration passieren. /api/status liefert nur freigegebene Dienste und dabei keine Adressen, nur Namen. Auch die Stoerungs-Historie ist gefiltert — sonst verriete sie, was es sonst noch gibt. Im Browser gegengeprueft: auf der ganzen Seite steht kein einziges https://. Stoerungen werden jetzt aufgezeichnet, nicht nur gemeldet: pro Adresse ein offener Eintrag, bis es wieder laeuft. Beim Wiederkommen wird er geschlossen, bevor die DM rausgeht — sonst bliebe er bei einem Sendefehler ewig offen. Aufbewahrung von 7 auf 90 Tage. Der Balken kann jetzt zwei Aufloesungen: 168 Stunden fuers Panel ("was war letzte Nacht?") und 90 Tage fuer die Statusseite ("wie zuverlaessig?"). Stuendlich ueber 90 Tage waeren ueber 2000 Striche und unlesbar. Im Panel sind aus der reinen Anzeige Verwaltung geworden: anlegen, bearbeiten, entfernen, freigeben — dazu die Stoerungsliste. Beim Bauen gefunden: die Uebernahme der alten Liste stand mitten in db.js und rief getSetting(), dessen prepared statement erst 300 Zeilen weiter unten entsteht. Der Bot starb beim Start mit "Cannot access getSettingStmt before initialization" — nachgewiesen, dann ans Dateiende verschoben. Geprueft: 182 SQL-Abfragen gegen das Schema, alle schreibenden Routen bewacht, Stoerungs-Logik durchgespielt (doppeltes Eroeffnen wird abgelehnt, Schliessen ohne offene gibt null), und beide Seiten im Browser mit drei Diensten in allen drei Zustaenden. Co-Authored-By: Claude Fable 5 --- frontend/src/App.jsx | 3 + frontend/src/components/UptimeTrack.jsx | 48 +++++---- frontend/src/locales/de.js | 18 ++++ frontend/src/locales/en.js | 18 ++++ frontend/src/pages/Settings.jsx | 83 +++++++++++++- frontend/src/pages/Status.jsx | 137 ++++++++++++++++++++++++ frontend/src/style.css | 40 +++++++ src/bot/watchdog.js | 32 +++--- src/db.js | 120 ++++++++++++++++++++- src/web/api.js | 111 ++++++++++++++++--- 10 files changed, 557 insertions(+), 53 deletions(-) create mode 100644 frontend/src/pages/Status.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fd17899..5f26fb7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,7 @@ import Commits from './pages/Commits.jsx'; import Settings from './pages/Settings.jsx'; import Profil from './pages/Profil.jsx'; import Server from './pages/Server.jsx'; +import Status from './pages/Status.jsx'; import Home from './pages/Home.jsx'; import DevlogDetail from './pages/DevlogDetail.jsx'; import Impressum from './pages/Impressum.jsx'; @@ -22,6 +23,7 @@ import { useT, LangSwitch } from './i18n.jsx'; // Beschriftung erst beim Rendern uebersetzen — der Pfad bleibt deutsch, // damit geteilte Links weiter funktionieren. const COMMUNITY_LINKS = [ + ['/status', 'nav.status'], ['/galerie', 'nav.gallery'], ['/level', 'nav.level'], ['/events', 'nav.events'], @@ -216,6 +218,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/UptimeTrack.jsx b/frontend/src/components/UptimeTrack.jsx index 93584b4..733eb29 100644 --- a/frontend/src/components/UptimeTrack.jsx +++ b/frontend/src/components/UptimeTrack.jsx @@ -1,31 +1,41 @@ -// Erreichbarkeits-Balken: ein Strich pro Stunde über 7 Tage. +// Erreichbarkeits-Balken: ein Strich je Zeitabschnitt. // -// 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. +// Zwei Auflösungen, weil zwei Seiten unterschiedliche Fragen stellen: +// stunden — 168 Striche über 7 Tage, fürs Panel („was war letzte Nacht?") +// tage — 90 Striche über 90 Tage, für die Statusseite („wie zuverlässig?") +// +// Stündlich über 90 Tage wären über 2000 Striche und damit unlesbar; Statuspage +// löst es genauso. Der Server liefert die passenden Eimer, hier wird nur +// gezeichnet und die Lücken ergänzt. import { useT, useFmt } from '../i18n.jsx'; -const STUNDEN = 168; +const MODI = { + stunden: { anzahl: 168, ms: 3600000, schluessel: (d) => d.toISOString().slice(0, 13), feld: 'stunde' }, + tage: { anzahl: 90, ms: 86400000, schluessel: (d) => d.toISOString().slice(0, 10), feld: 'tag' }, +}; -export default function UptimeTrack({ buckets }) { +export default function UptimeTrack({ buckets, modus = 'stunden' }) { 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 m = MODI[modus] ?? MODI.stunden; + const proAbschnitt = new Map(buckets.map((b) => [b[m.feld], b])); + + const start = new Date(); + if (modus === 'tage') start.setHours(0, 0, 0, 0); + else start.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); + for (let i = m.anzahl - 1; i >= 0; i--) { + const d = new Date(start.getTime() - i * m.ms); + // Schlüssel wie strftime 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 = m.schluessel(d); + const b = proAbschnitt.get(key); let art = 'leer'; if (b) { gemessen += b.proben; @@ -37,7 +47,9 @@ export default function UptimeTrack({ buckets }) { 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' }); + const wann = modus === 'tage' + ? datum(s.zeit, { day: '2-digit', month: '2-digit', year: 'numeric' }) + : 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 @@ -54,7 +66,7 @@ export default function UptimeTrack({ buckets }) { ))}
- {t('server.days7')} + {modus === 'tage' ? t('status.days90') : t('server.days7')} {quote != null && ( = 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}> {quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} % diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index 2e933a7..008a705 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -62,6 +62,24 @@ export default { 'home.tile.level': 'XP-Bestenliste — wer redet am meisten mit?', 'home.tile.events': 'Playtests, Streams und was sonst ansteht', + 'nav.status': 'Status', + 'status.tag': '// Betriebslage', + 'status.subtitle': 'Erreichbarkeit der Dienste und Game-Server — alle 60 Sekunden aktualisiert.', + 'status.error': 'Der Status konnte nicht geladen werden.', + 'status.allOk': 'Alle Systeme betriebsbereit', + 'status.partial': 'Teilweise gestört', + 'status.majorOutage': 'Größere Störung', + 'status.waiting': 'Wird geprüft', + 'status.nothing': 'Nichts eingerichtet', + 'status.services': '// Dienste', + 'status.gameservers': '// Game-Server', + 'status.incidents': '// Störungen der letzten 90 Tage', + 'status.noIncidents': 'Keine Störungen aufgezeichnet.', + 'status.ok': 'erreichbar', + 'status.down': 'nicht erreichbar', + 'status.ongoing': 'läuft noch', + 'status.minutes': '%s min', + 'status.days90': 'vor 90 Tagen', // ── Devlogs ─────────────────────────────────────── 'devlogs.tag': '// Archiv', 'devlogs.subtitle': 'Entwicklungs-Updates, frisch aus dem Editor — automatisch archiviert.', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index a27a8d4..b386a1e 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -61,6 +61,24 @@ export default { 'home.tile.level': 'XP leaderboard — who talks the most?', 'home.tile.events': 'Playtests, streams and whatever else is coming up', + 'nav.status': 'Status', + 'status.tag': '// Operational status', + 'status.subtitle': 'Reachability of services and game servers — refreshed every 60 seconds.', + 'status.error': 'The status could not be loaded.', + 'status.allOk': 'All systems operational', + 'status.partial': 'Partially degraded', + 'status.majorOutage': 'Major outage', + 'status.waiting': 'Checking', + 'status.nothing': 'Nothing set up', + 'status.services': '// Services', + 'status.gameservers': '// Game servers', + 'status.incidents': '// Incidents in the last 90 days', + 'status.noIncidents': 'No incidents recorded.', + 'status.ok': 'operational', + 'status.down': 'down', + 'status.ongoing': 'ongoing', + 'status.minutes': '%s min', + 'status.days90': '90 days ago', // ── Devlogs ─────────────────────────────────────── 'devlogs.tag': '// Archive', 'devlogs.subtitle': 'Development updates, fresh from the editor — archived automatically.', diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index d3aa33a..fa79e9c 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -343,6 +343,7 @@ export default function Settings({ me }) { const [audit, setAudit] = useState([]); const [serverStats, setServerStats] = useState(null); const [watchdog, setWatchdog] = useState(null); + const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false }); const [owner, setOwner] = useState(null); const [auditAlle, setAuditAlle] = useState(false); // Module (An/Aus für alle Funktionen) @@ -2353,16 +2354,76 @@ export default function Settings({ me }) {
- {a.url} + {a.name} + {a.oeffentlich && öffentlich} {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`} + +
+ {a.url}
))} + +

+ {dienstDraft.id ? 'Dienst bearbeiten' : 'Dienst hinzufügen'} +

+
+ + setDienstDraft({ ...dienstDraft, name: e.target.value })} /> +
+
+ + setDienstDraft({ ...dienstDraft, url: e.target.value })} /> +
+
+ +
+
+ + {dienstDraft.id && ( + + )} +
+
+ )} + + {watchdog?.stoerungen?.length > 0 && ( +
+

// Störungen

+
+ {watchdog.stoerungen.slice(0, 20).map((v) => ( +
+ + {watchdog.adressen.find((a) => a.url === v.url)?.name ?? v.url} + + {vorWieLange(v.begonnen)} + {v.beendet ? `${v.minuten} min` : 'läuft noch'} +
+ ))} +
)} @@ -2974,6 +3035,26 @@ export default function Settings({ me }) { ); + async function dienstSpeichern() { + const body = { ...dienstDraft }; + try { + if (body.id) await apiPut(`/api/monitored/${body.id}`, body); + else await apiPost('/api/monitored', body); + setDienstDraft({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false }); + apiGet('/api/watchdog').then(setWatchdog).catch(() => {}); + setFeedback('Gespeichert.'); + } catch (e) { + setFeedback(e.body?.error ?? 'Speichern fehlgeschlagen.'); + } + setTimeout(() => setFeedback(null), 3000); + } + + async function dienstLoeschen(id, name) { + if (!window.confirm(`\u201e${name}" nicht mehr überwachen?`)) return; + await apiDelete(`/api/monitored/${id}`).catch(() => {}); + apiGet('/api/watchdog').then(setWatchdog).catch(() => {}); + } + const auditSichtbar = auditAlle ? audit : audit.slice(0, 12); const teamBearbeitet = team.some((t) => t.user_id === teamDraft.user_id); diff --git a/frontend/src/pages/Status.jsx b/frontend/src/pages/Status.jsx new file mode 100644 index 0000000..9deac65 --- /dev/null +++ b/frontend/src/pages/Status.jsx @@ -0,0 +1,137 @@ +// Öffentliche Statusseite im Statuspage-Stil: Gesamtlage oben, darunter die +// freigegebenen Dienste und die Game-Server, unten die Störungs-Historie. +// +// Adressen stehen hier bewusst nicht — der Server liefert nur Namen. Was auf +// dieser Seite auftaucht, entscheidet der Schalter „öffentlich" je Dienst. +import { useEffect, useState } from 'react'; +import { apiGet } from '../api.js'; +import { SkeletonCards } from '../components/Skeleton.jsx'; +import UptimeTrack from '../components/UptimeTrack.jsx'; +import { useT, useFmt } from '../i18n.jsx'; +import { IconCheck, IconWarning, IconClock } from '../icons.jsx'; + +/** Gesamtlage aus allen Teilen — die schlechteste zählt */ +function gesamtlage(daten) { + const alles = [ + ...(daten.dienste ?? []).map((d) => d.online), + ...(daten.server ?? []).map((s) => s.online), + ]; + if (alles.length === 0) return 'leer'; + if (alles.some((o) => o === false)) return alles.every((o) => o === false) ? 'aus' : 'teil'; + // null heißt „noch nicht geprüft" und ist keine Störung + return alles.some((o) => o === true) ? 'gut' : 'wartet'; +} + +export default function Status() { + const { t } = useT(); + const { datum } = useFmt(); + const [daten, setDaten] = useState(null); + const [fehler, setFehler] = useState(false); + + useEffect(() => { + const laden = () => apiGet('/api/status').then(setDaten).catch(() => setFehler(true)); + laden(); + const timer = setInterval(laden, 60_000); + window.scrollTo(0, 0); + return () => clearInterval(timer); + }, []); + + const lage = daten ? gesamtlage(daten) : null; + const BANNER = { + gut: { icon: IconCheck, text: t('status.allOk') }, + teil: { icon: IconWarning, text: t('status.partial') }, + aus: { icon: IconWarning, text: t('status.majorOutage') }, + wartet: { icon: IconClock, text: t('status.waiting') }, + leer: { icon: IconClock, text: t('status.nothing') }, + }; + const Banner = lage ? BANNER[lage].icon : null; + + return ( + <> +
+
STATUS
+
+ +
+ {fehler &&

{t('status.error')}

} + {!fehler && !daten && } + + {lage && ( +
+ + {BANNER[lage].text} +
+ )} + + {daten?.dienste?.length > 0 && ( +
+

{t('status.services')}

+ {daten.dienste.map((d) => ( +
+
+ + {d.name} + + {d.online === null ? t('status.waiting') : d.online ? t('status.ok') : t('status.down')} + +
+ +
+ ))} +
+ )} + + {daten?.server?.length > 0 && ( +
+

{t('status.gameservers')}

+
+ {daten.server.map((s) => ( +
+ {s.icon} + {s.name} + + + {s.online + ? `${s.players ?? 0}${s.max ? ` / ${s.max}` : ''}` + : s.online === null ? t('status.waiting') : t('status.down')} + +
+ ))} +
+
+ )} + + {daten && ( +
+

{t('status.incidents')}

+ {daten.stoerungen.length === 0 ? ( +

{t('status.noIncidents')}

+ ) : ( +
+ {daten.stoerungen.map((v) => ( +
+ {v.name} + + {datum(`${v.begonnen.replace(' ', 'T')}Z`, { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', + })} + + + {v.beendet ? t('status.minutes', v.minuten) : t('status.ongoing')} + +
+ ))} +
+ )} +
+ )} +
+ + ); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index e2445d1..4ff43a2 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1815,6 +1815,46 @@ label.toggle { .dash-group-name { flex-basis: 7rem; } } +/* ── ÖFFENTLICHE STATUSSEITE ───────────────────────── */ +.st-banner { + display: flex; align-items: center; gap: .8rem; + padding: 1rem 1.2rem; border-radius: 12px; margin-bottom: 2rem; + font-family: var(--mono); font-size: .8rem; letter-spacing: .1em; + text-transform: uppercase; border: 1px solid; +} +.st-banner.gut { border-color: rgba(59, 165, 93, .4); background: rgba(59, 165, 93, .07); color: #3ba55d; } +.st-banner.teil { border-color: color-mix(in srgb, var(--neon) 40%, transparent); background: color-mix(in srgb, var(--neon) 7%, transparent); color: var(--neon); } +.st-banner.aus { border-color: rgba(242, 63, 67, .45); background: rgba(242, 63, 67, .08); color: #f23f43; } +.st-banner.wartet, .st-banner.leer { border-color: var(--border); color: var(--muted); } + +.st-row { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; } +.st-head { display: flex; align-items: center; gap: .6rem; } +.st-name { font-weight: 600; font-size: .92rem; } +.st-state { + margin-left: auto; font-family: var(--mono); + font-size: .62rem; letter-spacing: .1em; text-transform: uppercase; color: var(--muted2); +} + +.st-servers { display: grid; gap: .6rem; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); } +.st-server { display: flex; align-items: center; gap: .6rem; padding: .75rem .9rem; min-width: 0; } +.st-server-icon { font-size: 1.1rem; } + +.st-incidents { display: flex; flex-direction: column; } +.st-incident { + display: flex; align-items: center; gap: 1rem; + padding: .6rem .2rem; font-size: .84rem; + border-bottom: 1px solid rgba(255, 255, 255, .05); +} +.st-incident:last-child { border-bottom: none; } +.st-incident.offen .st-inc-dur { color: #f23f43; } +.st-inc-name { font-weight: 600; min-width: 9rem; } +.st-inc-time { font-family: var(--mono); font-size: .66rem; color: var(--muted2); } +.st-inc-dur { margin-left: auto; font-family: var(--mono); font-size: .68rem; color: var(--muted); } + +@media (max-width: 620px) { + .st-inc-name { min-width: 0; } +} + /* ── 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; } diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js index 474c600..4ba7626 100644 --- a/src/bot/watchdog.js +++ b/src/bot/watchdog.js @@ -1,7 +1,10 @@ // 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, recordWatchdogSample, pruneWatchdogHistory } from '../db.js'; +import { + listMonitored, recordWatchdogSample, pruneWatchdogHistory, + startIncident, endIncident, pruneIncidents, +} from '../db.js'; import { config } from '../config.js'; import { moduleEnabled } from '../modules.js'; import { brandEmbed } from '../embeds.js'; @@ -15,11 +18,10 @@ export function watchdogState() { return state; } -export function watchedUrls() { - return (getSetting('watchdog_urls') ?? '') - .split(',') - .map((s) => s.trim()) - .filter((s) => /^https?:\/\//.test(s)); +/** Was geprüft wird — kommt jetzt aus der Dienst-Tabelle, nicht mehr aus der + * kommagetrennten Einstellung. Die wurde beim ersten Start übernommen. */ +export function watchedServices() { + return listMonitored().filter((s) => /^https?:\/\//.test(s.url)); } async function dmAdmin(client, embed) { @@ -31,7 +33,8 @@ async function dmAdmin(client, embed) { /** 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 url of watchedUrls()) { + for (const dienst of watchedServices()) { + const { url } = dienst; const s = state.get(url) ?? { fails: 0, down: false, since: null }; let ok = false; @@ -64,27 +67,31 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog if (ok) { if (s.down) { const minuten = Math.max(1, Math.round((Date.now() - s.since) / 60000)); + // Störung schließen, bevor gemeldet wird — sonst steht sie bei + // einem Fehler beim Senden für immer offen in der Historie. + endIncident(url); await dmAdmin(client, brandEmbed(client, 'WATCHDOG') .setTitle('✅ Wieder erreichbar') - .setDescription(`**${url}**`) + .setDescription(`**${dienst.name}**\n${url}`) .addFields( { name: 'Ausfall', value: `~${minuten} min`, inline: true }, { name: 'Antwort', value: `${detail} · ${dauer} ms`, inline: true }, )); - console.log(`[watchdog] ${url} wieder ok`); + console.log(`[watchdog] ${dienst.name} wieder ok`); } 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) { + startIncident(url, detail); await dmAdmin(client, brandEmbed(client, 'WATCHDOG', { secondary: true }) .setTitle('🚨 Nicht erreichbar') - .setDescription(`**${url}**`) + .setDescription(`**${dienst.name}**\n${url}`) .addFields( { name: 'Fehler', value: detail, inline: true }, { name: 'Versuche', value: `${fails} in Folge`, inline: true }, )); - console.warn(`[watchdog] ${url} down (${detail})`); + console.warn(`[watchdog] ${dienst.name} down (${detail})`); state.set(url, { fails, down: true, since: Date.now(), status, ms: null, geprueft: Date.now() }); } else { state.set(url, { ...s, fails, status, ms: null, geprueft: Date.now() }); @@ -92,9 +99,10 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog } } - // Alte Messpunkte wegräumen, gleiche Aufbewahrung wie beim Game-Server + // Alte Messpunkte und abgeschlossene Störungen wegräumen (90 Tage) try { pruneWatchdogHistory(); + pruneIncidents(); } catch { /* nicht kritisch, beim nächsten Durchlauf wieder */ } } diff --git a/src/db.js b/src/db.js index b28bd60..6805d1e 100644 --- a/src/db.js +++ b/src/db.js @@ -712,9 +712,23 @@ 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. +// Überwachte Dienste: was der Wächter prüft und was auf der Statusseite steht. +// Vorher war das eine kommagetrennte Einstellung — für eine öffentliche Seite +// braucht jeder Eintrag einen Namen und die Entscheidung, ob er dort auftaucht. +db.exec(` + CREATE TABLE IF NOT EXISTS monitored_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + gruppe TEXT NOT NULL DEFAULT 'Dienste', + oeffentlich INTEGER NOT NULL DEFAULT 0, + sort INTEGER NOT NULL DEFAULT 0 + ); +`); + +// Watchdog: Erreichbarkeit der überwachten Adressen — gleiche Bauart wie beim +// Game-Server, damit beide denselben Balken benutzen können. Schlüssel bleibt +// die Adresse und nicht die Dienst-ID: so überlebt der Verlauf ein Umbenennen. db.exec(` CREATE TABLE IF NOT EXISTS watchdog_history ( url TEXT NOT NULL, @@ -723,6 +737,15 @@ db.exec(` ms INTEGER ); CREATE INDEX IF NOT EXISTS idx_wd_url_ts ON watchdog_history (url, ts); + + CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + begonnen TEXT NOT NULL DEFAULT (datetime('now')), + beendet TEXT, + grund TEXT + ); + CREATE INDEX IF NOT EXISTS idx_incidents_url ON incidents (url, begonnen DESC); `); const insertWatchdogSample = db.prepare('INSERT INTO watchdog_history (url, ok, ms) VALUES (?, ?, ?)'); const watchdogBucketsStmt = db.prepare(` @@ -734,13 +757,79 @@ const watchdogBucketsStmt = db.prepare(` 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')`); +// Tagesweise fürs 90-Tage-Bild: stündlich wären das über 2000 Striche, die auf +// keinen Schirm passen. Statuspage macht es genauso — 90 Tage, ein Strich je Tag. +const watchdogDaysStmt = db.prepare(` + SELECT date(ts) AS tag, + COUNT(*) AS proben, + SUM(ok) AS an, + CAST(AVG(ms) AS INTEGER) AS schnitt + FROM watchdog_history + WHERE url = ? AND ts >= date('now', ?) + GROUP BY tag ORDER BY tag +`); +const pruneWatchdogStmt = db.prepare(`DELETE FROM watchdog_history WHERE ts < datetime('now', '-90 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 watchdogDays = (url, days = 90) => watchdogDaysStmt.all(url, `-${days} days`); export const pruneWatchdogHistory = () => pruneWatchdogStmt.run(); +/* ── Störungen: pro Adresse ein offener Eintrag, bis es wieder läuft ── */ +const openIncidentStmt = db.prepare('SELECT * FROM incidents WHERE url = ? AND beendet IS NULL'); +const startIncidentStmt = db.prepare('INSERT INTO incidents (url, grund) VALUES (?, ?)'); +const endIncidentStmt = db.prepare(`UPDATE incidents SET beendet = datetime('now') WHERE id = ?`); +const listIncidentsStmt = db.prepare(` + SELECT id, url, begonnen, beendet, grund, + CAST((julianday(COALESCE(beendet, 'now')) - julianday(begonnen)) * 1440 AS INTEGER) AS minuten + FROM incidents + WHERE begonnen >= date('now', ?) + ORDER BY begonnen DESC LIMIT ? +`); +const pruneIncidentsStmt = db.prepare(`DELETE FROM incidents WHERE beendet IS NOT NULL AND begonnen < date('now', '-90 days')`); + +/** Störung eröffnen — doppelte für dieselbe Adresse verhindert der offene Eintrag */ +export function startIncident(url, grund) { + if (openIncidentStmt.get(url)) return false; + startIncidentStmt.run(url, grund ?? null); + return true; +} +export function endIncident(url) { + const offen = openIncidentStmt.get(url); + if (!offen) return null; + endIncidentStmt.run(offen.id); + return offen; +} +export const listIncidents = (days = 90, limit = 50) => listIncidentsStmt.all(`-${days} days`, limit); +export const pruneIncidents = () => pruneIncidentsStmt.run(); + +/* ── Überwachte Dienste ── */ +const listServicesMonStmt = db.prepare('SELECT * FROM monitored_services ORDER BY sort, name'); +const insertServiceMonStmt = db.prepare(` + INSERT INTO monitored_services (name, url, gruppe, oeffentlich, sort) + VALUES (@name, @url, @gruppe, @oeffentlich, @sort) + ON CONFLICT(url) DO NOTHING +`); +const updateServiceMonStmt = db.prepare(` + UPDATE monitored_services SET name = @name, url = @url, gruppe = @gruppe, + oeffentlich = @oeffentlich, sort = @sort WHERE id = @id +`); +const deleteServiceMonStmt = db.prepare('DELETE FROM monitored_services WHERE id = ?'); +export const listMonitored = () => listServicesMonStmt.all().map((r) => ({ ...r, oeffentlich: r.oeffentlich === 1 })); +export const createMonitored = (s) => insertServiceMonStmt.run({ + name: String(s.name || s.url || '').slice(0, 60), + url: s.url, + gruppe: s.gruppe || 'Dienste', + oeffentlich: s.oeffentlich ? 1 : 0, + sort: Number(s.sort) || 0, +}).lastInsertRowid; +export const updateMonitored = (s) => updateServiceMonStmt.run({ ...s, oeffentlich: s.oeffentlich ? 1 : 0 }); +export const deleteMonitored = (id) => deleteServiceMonStmt.run(id).changes > 0; + +// Die einmalige Übernahme der alten Adressliste steht am Dateiende — sie liest +// eine Einstellung, und getSetting() hängt an einem const, das erst weiter +// unten entsteht. Hier aufgerufen stirbt der Bot beim Start. + // Moderation: Verwarnungen + Sticky-Roles db.exec(` CREATE TABLE IF NOT EXISTS warns ( @@ -985,6 +1074,27 @@ export function setSetting(key, value) { setSettingStmt.run(key, value); } +// Einmalige Übernahme der alten kommagetrennten Watchdog-Liste in die +// Dienst-Tabelle. Muss hier unten stehen, weil sie getSetting() braucht. +// +// Bewusst nicht öffentlich: was auf der Statusseite auftaucht, soll eine +// bewusste Entscheidung sein und nicht durch eine Migration passieren. +{ + const schonDa = db.prepare('SELECT count(*) AS n FROM monitored_services').get().n; + const alt = getSetting('watchdog_urls') ?? ''; + if (schonDa === 0 && alt.trim()) { + const urls = alt.split(',').map((s) => s.trim()).filter(Boolean); + for (const [i, url] of urls.entries()) { + let name = url; + try { name = new URL(url).hostname; } catch { /* bleibt die rohe Adresse */ } + createMonitored({ name, url, gruppe: 'Dienste', oeffentlich: false, sort: i }); + } + if (urls.length > 0) { + console.log(`[db] ${urls.length} Watchdog-Adresse(n) in die Dienst-Tabelle übernommen`); + } + } +} + const insertCommit = db.prepare(` INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at) VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at) diff --git a/src/web/api.js b/src/web/api.js index 7625126..fa7c0d0 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -15,7 +15,9 @@ import { saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin, saveTemplate, listTemplates, deleteTemplate, getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes, - logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, deleteUserData, + logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays, + listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored, + deleteUserData, saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp, createService, updateService, listServices, deleteService, savePage, getPage, listPages, listPublishedPages, deletePage, @@ -25,7 +27,7 @@ import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavo import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.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 { watchdogState, watchedServices } 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'; @@ -190,32 +192,107 @@ 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. + /** Einen überwachten Dienst in die Form bringen, die beide Seiten nutzen */ + function dienstStand(dienst, { tage = false } = {}) { + const s = watchdogState().get(dienst.url) ?? {}; + return { + id: dienst.id, + name: dienst.name, + gruppe: dienst.gruppe, + oeffentlich: dienst.oeffentlich, + // 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: tage ? watchdogDays(dienst.url, 90) : watchdogBuckets(dienst.url, 168), + }; + } + + // Watchdog-Übersicht fürs Panel: alle Dienste mit voller Adresse. + // Nicht öffentlich — in der Liste können interne Dienste stehen. 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) ?? {}; + adressen: watchedServices().map((d) => ({ ...dienstStand(d), url: d.url })), + stoerungen: listIncidents(90, 50), + }; + }); + + // Öffentliche Statusseite: nur freigegebene Dienste, und bewusst OHNE + // Adresse — der Name reicht, die URL geht niemanden etwas an. + app.get('/api/status', async () => { + const dienste = watchedServices().filter((d) => d.oeffentlich); + const urls = new Set(dienste.map((d) => d.url)); + return { + dienste: dienste.map((d) => dienstStand(d, { tage: true })), + server: listGameservers().map((s) => { + const r = lastResults.get(s.id); 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), + id: s.id, + name: s.name, + icon: GAME_ICONS[s.type] ?? '🎮', + online: r?.online ?? null, + players: r?.players ?? null, + max: r?.max ?? null, }; }), + // Nur Störungen freigegebener Dienste — sonst verrät die Historie, + // was es sonst noch gibt + stoerungen: listIncidents(90, 20) + .filter((v) => urls.has(v.url)) + .map(({ url, ...rest }) => ({ + ...rest, + name: dienste.find((d) => d.url === url)?.name ?? 'Dienst', + })), }; }); + // --- Überwachte Dienste verwalten (Panel) --- + + /** Eingaben prüfen; antwortet bei Fehlern selbst */ + function dienstBody(request, reply) { + const b = request.body ?? {}; + const name = String(b.name ?? '').trim().slice(0, 60); + const url = String(b.url ?? '').trim(); + if (!name) { reply.code(400).send({ error: 'Name fehlt' }); return null; } + if (!/^https?:\/\/.+/.test(url)) { reply.code(400).send({ error: 'URL mit http(s) erwartet' }); return null; } + return { + name, url, + gruppe: String(b.gruppe ?? 'Dienste').trim().slice(0, 40) || 'Dienste', + oeffentlich: Boolean(b.oeffentlich), + sort: Number(b.sort) || 0, + }; + } + + app.post('/api/monitored', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const d = dienstBody(request, reply); + if (!d) return; + createMonitored(d); + logAudit(getSessionUser(request), 'dienst überwacht', `${d.name} (${d.url})`); + return { ok: true, dienste: listMonitored() }; + }); + + app.put('/api/monitored/:id', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const d = dienstBody(request, reply); + if (!d) return; + updateMonitored({ ...d, id: Number(request.params.id) }); + logAudit(getSessionUser(request), 'dienst geändert', d.name); + return { ok: true, dienste: listMonitored() }; + }); + + app.delete('/api/monitored/:id', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + logAudit(getSessionUser(request), 'dienst entfernt', String(request.params.id)); + return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() }; + }); + // --- Module: zentrale An/Aus-Schalter aller Funktionen --- app.get('/api/modules', async (request, reply) => {