diff --git a/frontend/src/components/UptimeTrack.jsx b/frontend/src/components/UptimeTrack.jsx
new file mode 100644
index 0000000..93584b4
--- /dev/null
+++ b/frontend/src/components/UptimeTrack.jsx
@@ -0,0 +1,67 @@
+// Erreichbarkeits-Balken: ein Strich pro Stunde über 7 Tage.
+//
+// Wird von der öffentlichen Server-Seite und der Watchdog-Übersicht im Panel
+// benutzt — beide bekommen dieselben Stundeneimer vom Server geliefert
+// ({ stunde, proben, an }), deshalb liegt das hier und nicht in einer Seite.
+import { useT, useFmt } from '../i18n.jsx';
+
+const STUNDEN = 168;
+
+export default function UptimeTrack({ buckets }) {
+ const { t } = useT();
+ const { tag, datum } = useFmt();
+ if (!buckets?.length) return null;
+
+ const proStunde = new Map(buckets.map((b) => [b.stunde, b]));
+ const jetzt = new Date();
+ jetzt.setMinutes(0, 0, 0);
+
+ const striche = [];
+ let gemessen = 0;
+ let erreichbar = 0;
+ for (let i = STUNDEN - 1; i >= 0; i--) {
+ const d = new Date(jetzt.getTime() - i * 3600000);
+ // Schlüssel wie strftime('%Y-%m-%dT%H') in SQLite — das rechnet in UTC,
+ // hier also bewusst auch. Angezeigt wird gleich die Ortszeit, sonst
+ // stünde im Tooltip eine Stunde, die niemand wiedererkennt.
+ const key = d.toISOString().slice(0, 13);
+ const b = proStunde.get(key);
+ let art = 'leer';
+ if (b) {
+ gemessen += b.proben;
+ erreichbar += b.an;
+ art = b.an === b.proben ? 'gut' : b.an === 0 ? 'aus' : 'teil';
+ }
+ striche.push({ key, zeit: d, art, b });
+ }
+
+ const quote = gemessen > 0 ? (erreichbar / gemessen) * 100 : null;
+ const titel = (s) => {
+ const wann = datum(s.zeit, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
+ if (!s.b) return `${wann} — ${t('server.noData')}`;
+ const anteil = Math.round((s.b.an / s.b.proben) * 100);
+ // Antwortzeit nur, wo sie erhoben wird (Watchdog) und etwas lief
+ return s.b.schnitt != null && s.b.an > 0
+ ? `${wann} — ${anteil} % · ${s.b.schnitt} ms`
+ : `${wann} — ${anteil} %`;
+ };
+
+ return (
+
+
+ {striche.map((s) => (
+
+ ))}
+
+
+ {t('server.days7')}
+ {quote != null && (
+ = 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}>
+ {quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} %
+
+ )}
+ {t('server.now')}
+
+
+ );
+}
diff --git a/frontend/src/pages/Server.jsx b/frontend/src/pages/Server.jsx
index 7340d5a..b374414 100644
--- a/frontend/src/pages/Server.jsx
+++ b/frontend/src/pages/Server.jsx
@@ -6,6 +6,7 @@ import { apiGet } from '../api.js';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { IconGame, IconMapPin, IconClock, IconCheck } from '../icons.jsx';
import { useT, useFmt } from '../i18n.jsx';
+import UptimeTrack from '../components/UptimeTrack.jsx';
const UHRZEIT = { hour: '2-digit', minute: '2-digit' };
@@ -38,69 +39,6 @@ function HistoryChart({ history, max }) {
);
}
-/**
- * Erreichbarkeits-Balken: ein Strich pro Stunde der letzten 7 Tage.
- *
- * Der Server liefert nur Stunden, in denen wirklich gemessen wurde. Fehlende
- * werden hier als Lücke gezeichnet statt als „online" — sonst sähe eine
- * Bot-Auszeit aus wie ein einwandfrei laufender Server.
- */
-function UptimeTrack({ buckets }) {
- const { t } = useT();
- const { tag, datum } = useFmt();
- if (!buckets?.length) return null;
-
- const proStunde = new Map(buckets.map((b) => [b.stunde, b]));
- const jetzt = new Date();
- jetzt.setMinutes(0, 0, 0);
-
- const striche = [];
- let gemessen = 0;
- let erreichbar = 0;
- for (let i = 167; i >= 0; i--) {
- const d = new Date(jetzt.getTime() - i * 3600000);
- // Schlüssel wie strftime('%Y-%m-%dT%H') in SQLite — das rechnet in UTC,
- // hier also bewusst auch. Angezeigt wird gleich die Ortszeit, sonst
- // stünde im Tooltip eine Stunde, die niemand wiedererkennt.
- const key = d.toISOString().slice(0, 13);
- const b = proStunde.get(key);
- let art = 'leer';
- if (b) {
- gemessen += b.proben;
- erreichbar += b.an;
- art = b.an === b.proben ? 'gut' : b.an === 0 ? 'aus' : 'teil';
- }
- striche.push({ key, zeit: d, art, b });
- }
-
- const quote = gemessen > 0 ? (erreichbar / gemessen) * 100 : null;
- const titel = (s) => {
- const wann = datum(s.zeit, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
- return s.b
- ? `${wann} — ${Math.round((s.b.an / s.b.proben) * 100)} %`
- : `${wann} — ${t('server.noData')}`;
- };
-
- return (
-
-
- {striche.map((s) => (
-
- ))}
-
-
- {t('server.days7')}
- {quote != null && (
- = 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}>
- {quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} %
-
- )}
- {t('server.now')}
-
-
- );
-}
-
/** Spieler-Peak aus den 24h-Samples (Uptime kommt aus dem Balken oben) */
function historyStats(history) {
if (history.length === 0) return null;
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 5eafe3f..d3aa33a 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -10,6 +10,7 @@ import {
} from '../icons.jsx';
import { moduleIcon } from '../module-icons.jsx';
import ModulePage from '../components/ModulePage.jsx';
+import UptimeTrack from '../components/UptimeTrack.jsx';
import { findSettings, SETTING_INDEX } from '../setting-index.js';
import { Markdown } from '../markdown.jsx';
@@ -341,6 +342,7 @@ export default function Settings({ me }) {
const [team, setTeam] = useState([]);
const [audit, setAudit] = useState([]);
const [serverStats, setServerStats] = useState(null);
+ const [watchdog, setWatchdog] = useState(null);
const [owner, setOwner] = useState(null);
const [auditAlle, setAuditAlle] = useState(false);
// Module (An/Aus für alle Funktionen)
@@ -467,6 +469,7 @@ export default function Settings({ me }) {
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
// Fuers Dashboard: Mitgliederzahl und Nachrichten pro Tag
apiGet('/api/serverstats').then(setServerStats).catch(() => {});
+ apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
@@ -2337,6 +2340,31 @@ export default function Settings({ me }) {
setForm({ ...form, watchdog_urls: e.target.value })} />
+
+ {watchdog?.adressen?.length > 0 && (
+
+
// Erreichbarkeit
+
+ Ein Strich je Stunde über die letzten 7 Tage — grün erreichbar, rot
+ Ausfall, grau keine Messung. Geprüft wird alle {watchdog.intervall} Minuten.
+ {!watchdog.aktiv && ' Der Wächter ist gerade ausgeschaltet, es kommen keine neuen Messpunkte dazu.'}
+
+ {watchdog.adressen.map((a) => (
+
+
+
+
{a.url}
+
+ {a.online === null && 'noch nicht geprüft'}
+ {a.online === true && `${a.status ?? 'OK'}${a.ms != null ? ` · ${a.ms} ms` : ''}`}
+ {a.online === false && `seit ${vorWieLange(new Date(a.seit).toISOString())} weg`}
+
+
+
+
+ ))}
+
+ )}
>
);
diff --git a/frontend/src/style.css b/frontend/src/style.css
index dc6718e..e2445d1 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1815,6 +1815,24 @@ label.toggle {
.dash-group-name { flex-basis: 7rem; }
}
+/* ── WATCHDOG-ÜBERSICHT ────────────────────────────── */
+.wd-card { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; }
+.wd-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
+.wd-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
+.wd-dot.an { background: var(--success, #3ba55d); }
+.wd-dot.aus { background: #f23f43; }
+.wd-dot.wartet { background: var(--muted2); }
+.wd-url {
+ font-family: var(--mono); font-size: .74rem;
+ color: var(--text); text-decoration: none;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
+}
+.wd-url:hover { color: var(--neon); }
+.wd-meta {
+ margin-left: auto; font-family: var(--mono);
+ font-size: .62rem; letter-spacing: .06em; color: var(--muted2); white-space: nowrap;
+}
+
/* ── ERREICHBARKEITS-BALKEN ────────────────────────── */
/* Ein Strich pro Stunde, 168 Stück. Auf schmalen Schirmen werden die Striche
dünner als ein Pixel — deshalb scrollt der Balken dort lieber, statt zu
diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js
index 10554f6..474c600 100644
--- a/src/bot/watchdog.js
+++ b/src/bot/watchdog.js
@@ -1,23 +1,31 @@
-// Watchdog: prüft konfigurierte URLs und schickt dem Admin eine DM bei Ausfall.
+// Watchdog: prüft konfigurierte URLs, meldet Ausfälle per DM und schreibt
+// jeden Check mit — daraus baut die Übersicht im Panel ihren Uptime-Balken.
// URLs kommen aus dem Setting watchdog_urls (Config-Seite), leer = deaktiviert.
-import { getSetting } from '../db.js';
+import { getSetting, recordWatchdogSample, pruneWatchdogHistory } from '../db.js';
import { config } from '../config.js';
import { moduleEnabled } from '../modules.js';
+import { brandEmbed } from '../embeds.js';
import { tuning, tuningMs, everyTuned } from '../tuning.js';
// URL → { fails, down, since }
const state = new Map();
-function watchedUrls() {
+/** Aktueller Stand je Adresse — für die Übersicht im Panel */
+export function watchdogState() {
+ return state;
+}
+
+export function watchedUrls() {
return (getSetting('watchdog_urls') ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => /^https?:\/\//.test(s));
}
-async function dmAdmin(client, content) {
- const user = await client.users.fetch(config.adminDiscordId);
- await user.send(content);
+async function dmAdmin(client, embed) {
+ if (!config.adminDiscordId) return;
+ const user = await client.users.fetch(config.adminDiscordId).catch(() => null);
+ await user?.send({ embeds: [embed] }).catch(() => {});
}
/** Ein Prüfdurchlauf — exportiert für Tests und den Intervall-Timer */
@@ -28,6 +36,8 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog
let ok = false;
let detail = '';
+ let status = null;
+ const start = Date.now();
try {
const res = await fetch(url, {
method: 'GET',
@@ -36,29 +46,56 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog
headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' },
});
ok = res.status < 500;
+ status = res.status;
detail = `HTTP ${res.status}`;
} catch (error) {
detail = error.cause?.code ?? error.name ?? 'Fehler';
}
+ const dauer = Date.now() - start;
+
+ // Immer mitschreiben, nicht nur bei Wechseln — der Balken braucht
+ // durchgehende Messpunkte, sonst sähen ruhige Stunden aus wie Lücken.
+ try {
+ recordWatchdogSample(url, ok, ok ? dauer : null);
+ } catch (error) {
+ console.error('[watchdog] Messpunkt nicht gespeichert:', error.message);
+ }
if (ok) {
if (s.down) {
- const minutes = Math.max(1, Math.round((Date.now() - s.since) / 60000));
- await dmAdmin(client, `✅ **${url}** ist wieder erreichbar (Downtime: ~${minutes} min).`).catch(() => {});
+ const minuten = Math.max(1, Math.round((Date.now() - s.since) / 60000));
+ await dmAdmin(client, brandEmbed(client, 'WATCHDOG')
+ .setTitle('✅ Wieder erreichbar')
+ .setDescription(`**${url}**`)
+ .addFields(
+ { name: 'Ausfall', value: `~${minuten} min`, inline: true },
+ { name: 'Antwort', value: `${detail} · ${dauer} ms`, inline: true },
+ ));
console.log(`[watchdog] ${url} wieder ok`);
}
- state.set(url, { fails: 0, down: false, since: null });
+ state.set(url, { fails: 0, down: false, since: null, status, ms: dauer, geprueft: Date.now() });
} else {
const fails = s.fails + 1;
if (!s.down && fails >= failsBeforeAlert) {
- await dmAdmin(client, `🚨 **${url}** ist DOWN (${detail}) — seit ${failsBeforeAlert} Checks nicht erreichbar!`).catch(() => {});
+ await dmAdmin(client, brandEmbed(client, 'WATCHDOG', { secondary: true })
+ .setTitle('🚨 Nicht erreichbar')
+ .setDescription(`**${url}**`)
+ .addFields(
+ { name: 'Fehler', value: detail, inline: true },
+ { name: 'Versuche', value: `${fails} in Folge`, inline: true },
+ ));
console.warn(`[watchdog] ${url} down (${detail})`);
- state.set(url, { fails, down: true, since: Date.now() });
+ state.set(url, { fails, down: true, since: Date.now(), status, ms: null, geprueft: Date.now() });
} else {
- state.set(url, { ...s, fails });
+ state.set(url, { ...s, fails, status, ms: null, geprueft: Date.now() });
}
}
}
+
+ // Alte Messpunkte wegräumen, gleiche Aufbewahrung wie beim Game-Server
+ try {
+ pruneWatchdogHistory();
+ } catch { /* nicht kritisch, beim nächsten Durchlauf wieder */ }
}
export function startWatchdog(client) {
diff --git a/src/db.js b/src/db.js
index e76a818..b28bd60 100644
--- a/src/db.js
+++ b/src/db.js
@@ -712,6 +712,35 @@ export const playerHistory = (serverId, hours = 24) => playerHistoryStmt.all(ser
export const uptimeBuckets = (serverId, hours = 168) => uptimeBucketsStmt.all(serverId, `-${hours} hours`);
export const prunePlayerHistory = () => prunePlayerHistoryStmt.run();
+// Watchdog: Erreichbarkeit der überwachten Adressen — gleiche Bauart wie oben,
+// damit die Übersicht denselben Balken benutzen kann. Adresse als Schlüssel:
+// die Liste steht als Einstellung und hat keine IDs.
+db.exec(`
+ CREATE TABLE IF NOT EXISTS watchdog_history (
+ url TEXT NOT NULL,
+ ts TEXT NOT NULL DEFAULT (datetime('now')),
+ ok INTEGER NOT NULL,
+ ms INTEGER
+ );
+ CREATE INDEX IF NOT EXISTS idx_wd_url_ts ON watchdog_history (url, ts);
+`);
+const insertWatchdogSample = db.prepare('INSERT INTO watchdog_history (url, ok, ms) VALUES (?, ?, ?)');
+const watchdogBucketsStmt = db.prepare(`
+ SELECT strftime('%Y-%m-%dT%H', ts) AS stunde,
+ COUNT(*) AS proben,
+ SUM(ok) AS an,
+ CAST(AVG(ms) AS INTEGER) AS schnitt
+ FROM watchdog_history
+ WHERE url = ? AND ts >= datetime('now', ?)
+ GROUP BY stunde ORDER BY stunde
+`);
+// Adressen, die aus der Einstellung entfernt wurden, sollen nicht ewig bleiben
+const pruneWatchdogStmt = db.prepare(`DELETE FROM watchdog_history WHERE ts < datetime('now', '-7 days')`);
+export const recordWatchdogSample = (url, ok, ms) =>
+ insertWatchdogSample.run(url, ok ? 1 : 0, Number.isFinite(ms) ? Math.round(ms) : null);
+export const watchdogBuckets = (url, hours = 168) => watchdogBucketsStmt.all(url, `-${hours} hours`);
+export const pruneWatchdogHistory = () => pruneWatchdogStmt.run();
+
// Moderation: Verwarnungen + Sticky-Roles
db.exec(`
CREATE TABLE IF NOT EXISTS warns (
diff --git a/src/web/api.js b/src/web/api.js
index fd02b14..7625126 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -15,16 +15,17 @@ import {
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
- logAudit, listAudit, playerHistory, uptimeBuckets, deleteUserData,
+ logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, deleteUserData,
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage,
} from '../db.js';
import crypto from 'node:crypto';
-import { moduleStates, moduleDetail, setModuleEnabled, moduleFavorites, setModuleFavorites, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js';
+import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js';
import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
-import { tuningStates, setTuning, TUNING_GROUPS } from '../tuning.js';
+import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
+import { watchdogState, watchedUrls } from '../bot/watchdog.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
@@ -189,6 +190,32 @@ export function registerApiRoutes(app, client) {
}),
}));
+ // Watchdog-Übersicht: Stand und Erreichbarkeit der überwachten Adressen.
+ // Nicht öffentlich — in der Liste können interne Dienste stehen, die
+ // niemand von außen kennen muss.
+ app.get('/api/watchdog', async (request, reply) => {
+ if (requireAnyScope(request, reply)) return;
+ const stand = watchdogState();
+ return {
+ aktiv: moduleEnabled('watchdog'),
+ intervall: tuning('watchdog_interval'),
+ adressen: watchedUrls().map((url) => {
+ const s = stand.get(url) ?? {};
+ return {
+ url,
+ // undefined = seit dem Start noch nicht geprüft
+ online: s.geprueft ? !s.down : null,
+ fails: s.fails ?? 0,
+ seit: s.since ?? null,
+ ms: s.ms ?? null,
+ status: s.status ?? null,
+ geprueft: s.geprueft ?? null,
+ uptime: watchdogBuckets(url, 168),
+ };
+ }),
+ };
+ });
+
// --- Module: zentrale An/Aus-Schalter aller Funktionen ---
app.get('/api/modules', async (request, reply) => {