diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5f26fb7..43c9c96 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -99,16 +99,15 @@ export default function App() {
// Member-Gate: OAuth war okay, aber User ist nicht auf dem Discord (?gate=1)
const [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate'));
const [invite, setInvite] = useState(null);
- // Frei angelegte Seiten, die ins Menü sollen
+ // Alle veroeffentlichten Seiten. Das Menue zeigt nur die mit „im Menü";
+ // im Fusszeilen-Bereich stehen sie alle — dort sucht man Rechtliches.
const [pages, setPages] = useState([]);
useEffect(() => {
apiGet('/api/me')
.then((data) => setMe({ scopes: [], ...data, loading: false }))
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
- apiGet('/api/pages')
- .then((d) => setPages(d.pages.filter((p) => p.in_menu)))
- .catch(() => {});
+ apiGet('/api/pages').then((d) => setPages(d.pages)).catch(() => {});
}, []);
useEffect(() => {
@@ -138,7 +137,7 @@ export default function App() {
{COMMUNITY_LINKS.map(([to, key]) => (
{t(key)}
))}
- {pages.map((p) => (
+ {pages.filter((p) => p.in_menu).map((p) => (
{p.title}
))}
@@ -241,8 +240,12 @@ export default function App() {
git.d4rkst3r.de
//
+ {t('footer.status')}
{t('footer.imprint')}
{t('footer.privacy')}
+ {pages.map((p) => (
+ {p.title}
+ ))}
);
diff --git a/frontend/src/BotApp.jsx b/frontend/src/BotApp.jsx
index 7073c25..117e8eb 100644
--- a/frontend/src/BotApp.jsx
+++ b/frontend/src/BotApp.jsx
@@ -17,6 +17,9 @@ export default function BotApp() {
const [scrolled, setScrolled] = useState(false);
const [drawer, setDrawer] = useState(false);
const [hub, setHub] = useState(null);
+ // Seiten wie die Nutzungsbedingungen liegen auf dem Hub — verlinkt
+ // wird deshalb absolut, und nur wenn es einen Hub gibt.
+ const [pages, setPages] = useState([]);
const location = useLocation();
const { t } = useT();
@@ -27,6 +30,7 @@ export default function BotApp() {
.then((data) => setMe({ scopes: [], ...data, loading: false }))
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
apiGet('/api/legal').then((d) => setHub(d.split ? d.hubUrl : null)).catch(() => {});
+ apiGet('/api/pages').then((d) => setPages(d.pages)).catch(() => {});
}, []);
useEffect(() => {
@@ -107,8 +111,12 @@ export default function BotApp() {
{t('footer.source')}
//
+ {hub && {t('footer.status')}}
{t('footer.imprint')}
{t('footer.privacy')}
+ {hub && pages.map((p) => (
+ {p.title}
+ ))}
);
diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js
index d43383d..78611cb 100644
--- a/frontend/src/locales/de.js
+++ b/frontend/src/locales/de.js
@@ -24,6 +24,7 @@ export default {
'nav.signOut': 'Abmelden',
'nav.menu': 'Menü',
'footer.source': 'Quellcode',
+ 'footer.status': 'Status',
'footer.imprint': 'Impressum',
'footer.privacy': 'Datenschutz',
'gate.text': 'Der Login ist Mitgliedern des Discord-Servers vorbehalten — tritt erst dem Server bei und logg dich dann nochmal ein.',
diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js
index 094a9b3..702b5dd 100644
--- a/frontend/src/locales/en.js
+++ b/frontend/src/locales/en.js
@@ -23,6 +23,7 @@ export default {
'nav.signOut': 'Sign out',
'nav.menu': 'Menu',
'footer.source': 'Source',
+ 'footer.status': 'Status',
'footer.imprint': 'Imprint',
'footer.privacy': 'Privacy',
'gate.text': 'Logging in is for members of the Discord server — join the server first, then log in again.',
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index c19b3f7..992660a 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1186,10 +1186,25 @@ export default function Settings({ me }) {
seit {fmtUptime(data.status.uptimeSeconds)}
+ {data.status.ping != null && ` · ${data.status.ping} ms`}
{data.status.guilds > 0 && ` · ${data.status.guilds} Server`}
+ {/* Die Laufzeit oben zählt nur bis zum letzten Neustart. Der
+ Balken zeigt die Woche davor — inklusive der Zeiten, in
+ denen gar nichts lief. */}
+ {data.status.uptime?.length > 0 ? (
+
+ Erreichbarkeit wird ab jetzt aufgezeichnet — der Balken füllt sich
+ in den nächsten Stunden.
+
+ )}
+
{activeModules.length}/{modules.length || '–'}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0bee597..a313a1a 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -842,7 +842,11 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
}
.settings-nav-icon { font-size: .95rem; }
-.settings-content { flex: 1; min-width: 0; animation: riseIn .3s ease both; }
+/* max-width gehört dazu: min-width allein reicht nicht, wenn ein Kind eine
+ grosse Mindestbreite mitbringt (die 168 Striche des Erreichbarkeits-Balkens).
+ Ohne die Grenze wuchs die Spalte auf 724 statt 335 px — und weil der Body
+ waagerecht abschneidet, war der Überstand nicht scrollbar, sondern weg. */
+.settings-content { flex: 1; min-width: 0; max-width: 100%; animation: riseIn .3s ease both; }
/* Tabs, die nur aus Formular-Blöcken bestehen, stellen ihre Blöcke ab genug
Breite nebeneinander — halbiert das Scrollen, ohne das Innenleben eines
@@ -1764,6 +1768,13 @@ label.toggle {
font-size: .64rem; letter-spacing: .08em; color: var(--muted2);
}
+/* Der Balken sitzt zwischen Kopfzeile und Kennzahlen. min-width: 0, sonst
+ sprengen ihn die 168 Striche auf schmalen Schirmen (siehe .srv-card). */
+.dash-uptrack { margin: 0 0 1.2rem; min-width: 0; }
+.dash-hint {
+ margin: 0 0 1.2rem; font-size: .8rem; color: var(--muted);
+}
+
.dash-kpis { display: grid; gap: .7rem; grid-template-columns: repeat(4, 1fr); }
.dash-kpi { display: flex; flex-direction: column; gap: .15rem; padding: .9rem 1rem; min-width: 0; }
.dash-v { font-size: 1.55rem; font-weight: 700; line-height: 1.1; }
diff --git a/src/bot/client.js b/src/bot/client.js
index 3f0b2a9..69aa65d 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -10,6 +10,7 @@ import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js';
import { registerAntiRaid } from './anti-raid.js';
import { registerAutoMod } from './automod.js';
+import { startHeartbeat } from './heartbeat.js';
import { registerMetadata, startLinkedRoles } from './linked-roles.js';
import { registerLevels } from './levels.js';
import { registerPresence } from './presence.js';
@@ -104,6 +105,10 @@ export async function startBot() {
}
}
startLinkedRoles(client);
+ // Ab hier zaehlt jede Minute: der Herzschlag laeuft immer,
+ // unabhaengig von Modulen — er ist die Grundlage dafuer, dass
+ // spaeter ueberhaupt jemand sagen kann, ob der Bot lief.
+ startHeartbeat(client);
});
// Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern
diff --git a/src/bot/heartbeat.js b/src/bot/heartbeat.js
new file mode 100644
index 0000000..9bc875d
--- /dev/null
+++ b/src/bot/heartbeat.js
@@ -0,0 +1,104 @@
+// Eigener Herzschlag — damit der Bot auch über sich selbst Auskunft geben kann.
+//
+// Der Wächter prüft Adressen von innen heraus. Über den Bot selbst sagt das
+// nichts: ist er weg, prüft niemand mehr, und hinterher sieht der Verlauf aus,
+// als wäre nie etwas gewesen. Deshalb schreibt er im festen Takt eine Zeile.
+// Ausgewertet wird die Abwesenheit: wo kein Herzschlag steht, lief er nicht.
+//
+// Aufgezeichnet wird nicht nur „Prozess lebt", sondern ob die Verbindung zu
+// Discord stand. Ein Bot, der läuft aber nicht verbunden ist, ist für alle
+// draußen genauso weg.
+import { recordHeartbeat, pruneHeartbeat } from '../db.js';
+
+/** Abstand zweier Herzschläge. Fest, siehe Kommentar an der Tabelle in db.js. */
+export const TAKT_MS = 2 * 60 * 1000;
+
+const STUNDE_MS = 3600000;
+
+/** SQLite-Zeitstempel („2026-08-01 12:30:00", UTC) als Millisekunden */
+function alsZeit(wert) {
+ if (!wert) return null;
+ const s = String(wert).trim();
+ const hatZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(s);
+ const ms = new Date(hatZone ? s : `${s.replace(' ', 'T')}Z`).getTime();
+ return Number.isNaN(ms) ? null : ms;
+}
+
+/**
+ * Aus den vorhandenen Stunden eine lückenlose Reihe machen.
+ *
+ * Der Balken liest fehlende Eimer als „keine Daten" und malt sie grau. Für
+ * einen Dienst stimmt das — niemand hat gemessen. Für den Bot selbst wäre es
+ * eine Beschönigung: dass nichts dasteht, *ist* die Aussage. Fehlende Stunden
+ * bekommen deshalb hier ihre erwartete Probenzahl und null Treffer, damit sie
+ * rot erscheinen statt grau.
+ *
+ * Grau bleibt nur, was vor der allerersten Aufzeichnung liegt.
+ *
+ * @param {Array<{stunde: string, proben: number, an: number, schnitt: number|null}>} rohe
+ * @param {string|null} ersteProbe — Zeitstempel der ersten Zeile überhaupt
+ * @param {number} jetzt
+ * @param {number} taktMs
+ * @param {number} stunden
+ */
+export function heartbeatSerie(rohe, ersteProbe, jetzt = Date.now(), taktMs = TAKT_MS, stunden = 168) {
+ const erste = alsZeit(ersteProbe);
+ if (erste == null) return [];
+
+ const proStunde = new Map((rohe ?? []).map((b) => [b.stunde, b]));
+ const stundeStart = Math.floor(jetzt / STUNDE_MS) * STUNDE_MS;
+ const raus = [];
+
+ for (let i = stunden - 1; i >= 0; i--) {
+ const von = stundeStart - i * STUNDE_MS;
+ // Nur der Teil der Stunde zählt, in dem überhaupt aufgezeichnet wurde:
+ // die angebrochene erste und die laufende letzte Stunde sind sonst
+ // dauerhaft „unvollständig" und damit gelb.
+ const anfang = Math.max(von, erste);
+ const ende = Math.min(von + STUNDE_MS, jetzt);
+ if (ende <= anfang) continue;
+
+ const key = new Date(von).toISOString().slice(0, 13);
+ const b = proStunde.get(key);
+ // Ein Splitter am Stundenanfang, in dem noch gar kein Schlag fällig
+ // war, würde sonst eine Sekunde nach voll rot aufleuchten
+ if (!b && ende - anfang < taktMs) continue;
+
+ const erwartet = Math.max(1, Math.round((ende - anfang) / taktMs));
+ raus.push({
+ stunde: key,
+ proben: erwartet,
+ // Mehr Schläge als erwartet (Neustart mitten in der Stunde: der
+ // erste Schlag kommt sofort) würden über 100 % ergeben
+ an: Math.min(b?.an ?? 0, erwartet),
+ schnitt: b?.schnitt ?? null,
+ });
+ }
+ return raus;
+}
+
+/** Herzschlag starten. Gibt eine Funktion zum Anhalten zurück. */
+export function startHeartbeat(client) {
+ // Einmal täglich aufräumen, gerechnet in Takten — ein zweiter Timer nur
+ // fürs Löschen wäre Aufwand ohne Gewinn.
+ const proTag = Math.round(86400000 / TAKT_MS);
+ let takte = 0;
+
+ const schlag = () => {
+ try {
+ const bereit = Boolean(client?.isReady?.());
+ // ping ist -1, solange keine Messung vorliegt
+ const ping = client?.ws?.ping;
+ recordHeartbeat(bereit, bereit && ping >= 0 ? ping : null);
+ if (++takte % proTag === 0) pruneHeartbeat();
+ } catch (error) {
+ console.error('[heartbeat]', error);
+ }
+ };
+
+ schlag();
+ const timer = setInterval(schlag, TAKT_MS);
+ // Ein Timer allein soll den Prozess nicht am Leben halten
+ timer.unref?.();
+ return () => clearInterval(timer);
+}
diff --git a/src/db.js b/src/db.js
index f87f854..cd6e0f4 100644
--- a/src/db.js
+++ b/src/db.js
@@ -775,6 +775,40 @@ export const watchdogBuckets = (url, hours = 168) => watchdogBucketsStmt.all(url
export const watchdogDays = (url, days = 90) => watchdogDaysStmt.all(url, `-${days} days`);
export const pruneWatchdogHistory = () => pruneWatchdogStmt.run();
+// Eigener Herzschlag. Der Wächter kann alles prüfen außer sich selbst — wenn
+// der Bot weg ist, prüft niemand mehr. Deshalb schreibt er im festen Takt eine
+// Zeile: was da steht, lief; wo nichts steht, lief er nicht.
+//
+// Der Takt ist absichtlich nicht einstellbar. Ein später geänderter Abstand
+// würde den alten Verlauf falsch aussehen lassen — die Zahl der erwarteten
+// Proben pro Stunde ließe sich rückwirkend nicht mehr bestimmen.
+db.exec(`
+ CREATE TABLE IF NOT EXISTS bot_heartbeat (
+ ts TEXT NOT NULL DEFAULT (datetime('now')),
+ ok INTEGER NOT NULL DEFAULT 1,
+ ms INTEGER
+ );
+ CREATE INDEX IF NOT EXISTS idx_heartbeat_ts ON bot_heartbeat (ts);
+`);
+const insertHeartbeatStmt = db.prepare('INSERT INTO bot_heartbeat (ok, ms) VALUES (?, ?)');
+const heartbeatBucketsStmt = 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 bot_heartbeat
+ WHERE ts >= datetime('now', ?)
+ GROUP BY stunde ORDER BY stunde
+`);
+const heartbeatFirstStmt = db.prepare('SELECT min(ts) AS erste FROM bot_heartbeat');
+const pruneHeartbeatStmt = db.prepare(`DELETE FROM bot_heartbeat WHERE ts < datetime('now', '-90 days')`);
+export const recordHeartbeat = (ok, ms) =>
+ insertHeartbeatStmt.run(ok ? 1 : 0, Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : null);
+export const heartbeatBuckets = (hours = 168) => heartbeatBucketsStmt.all(`-${hours} hours`);
+/** Zeitpunkt der ersten Aufzeichnung — davor ist „keine Daten", nicht „war aus" */
+export const heartbeatFirst = () => heartbeatFirstStmt.get()?.erste ?? null;
+export const pruneHeartbeat = () => pruneHeartbeatStmt.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 (?, ?)');
diff --git a/src/web/api.js b/src/web/api.js
index ddd0f88..c53a797 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -16,6 +16,7 @@ import {
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
+ heartbeatBuckets, heartbeatFirst,
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
deleteUserData,
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
@@ -29,6 +30,7 @@ import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
import { watchdogState, watchedServices } from '../bot/watchdog.js';
import { listRules, setRuleEnabled } from '../bot/automod.js';
+import { heartbeatSerie } from '../bot/heartbeat.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';
@@ -855,6 +857,10 @@ ${rssItems}
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
botDescription: client.application?.description ?? '',
uptimeSeconds: Math.floor(process.uptime()),
+ // Laufzeit seit dem letzten Start sagt nur, wie lange es diesmal
+ // gutgegangen ist. Der Herzschlag zeigt die Woche davor mit.
+ uptime: heartbeatSerie(heartbeatBuckets(168), heartbeatFirst()),
+ ping: client.ws?.ping >= 0 ? Math.round(client.ws.ping) : null,
guilds: client.guilds.cache.size,
giteaTokenConfigured: Boolean(giteaApiToken()),
lastBackup: getSetting('last_backup'),