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`}
+
setDienstDraft({
+ id: a.id, name: a.name, url: a.url,
+ gruppe: a.gruppe, oeffentlich: a.oeffentlich,
+ })}
+ >
+ Bearbeiten
+
+
dienstLoeschen(a.id, a.name)}>✕
+
{a.url}
))}
+
+
+ {dienstDraft.id ? 'Dienst bearbeiten' : 'Dienst hinzufügen'}
+
+
+ Name steht so auf der öffentlichen Statusseite
+ setDienstDraft({ ...dienstDraft, name: e.target.value })} />
+
+
+ Adresse wird geprüft; erscheint nie öffentlich
+ setDienstDraft({ ...dienstDraft, url: e.target.value })} />
+
+
+
+ setDienstDraft({ ...dienstDraft, oeffentlich: e.target.checked })} />
+ Auf der öffentlichen Statusseite zeigen
+
+
+
+
+ {dienstDraft.id ? 'Änderungen speichern' : 'Hinzufügen'}
+
+ {dienstDraft.id && (
+ setDienstDraft({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false })}>
+ Abbrechen
+
+ )}
+
+
+ )}
+
+ {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
+
+
+
{t('status.tag')}
+
{t('nav.status')}
+
{t('status.subtitle')}
+
+
+
+
+ {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) => {