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) => {