From e32152b5ac13cc64942cd1c83dbc5638140628dc Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 07:10:53 +0200 Subject: [PATCH] Eigener Herzschlag: der Bot kann jetzt auch ueber sich selbst Auskunft geben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Waechter prueft alles ausser sich selbst — ist der Bot weg, prueft niemand mehr, und hinterher sieht der Verlauf aus, als waere nie etwas gewesen. Im Dashboard stand deshalb nur "seit 12d 4h", also wie lange es diesmal gutgegangen ist. Was davor war, wusste niemand. Jetzt schreibt der Bot alle zwei Minuten eine Zeile: laeuft der Prozess, und steht die Verbindung zu Discord? Ein Bot, der laeuft aber nicht verbunden ist, ist fuer alle draussen genauso weg — deshalb zaehlt beides. Der Takt ist absichtlich nicht einstellbar. Ein spaeter geaenderter Abstand wuerde den alten Verlauf falsch aussehen lassen: wie viele Proben eine Stunde haette haben muessen, liesse sich rueckwirkend nicht mehr sagen. Ausgewertet wird vor allem die Abwesenheit. Fuer einen fremden Dienst heisst eine fehlende Stunde "niemand hat gemessen" und wird grau; beim Bot selbst ist genau das die Aussage — fehlende Stunden werden rot. Grau bleibt nur, was vor der allerersten Aufzeichnung liegt. Die angebrochene erste und die laufende letzte Stunde rechnen anteilig, sonst waeren sie dauerhaft gelb. Nebenbei mitgenommen: - Statusseite und die frei angelegten Seiten stehen jetzt in der Fusszeile beider Seiten — dort sucht man Rechtliches. Die Navigationsleiste zeigt weiterhin nur, was "im Menü" gesetzt hat; wer die Nutzungsbedingungen dort raus nimmt, hat sie trotzdem noch in der Fusszeile. - Die Config-Spalte hatte keine Obergrenze. min-width:0 allein reicht nicht, wenn ein Kind eine grosse Mindestbreite mitbringt — mit dem Balken wuchs sie auf 724 statt 335 px, und weil der Body waagerecht abschneidet, war der Ueberstand nicht scrollbar, sondern weg. Co-Authored-By: Claude Opus 5 --- frontend/src/App.jsx | 13 ++-- frontend/src/BotApp.jsx | 8 +++ frontend/src/locales/de.js | 1 + frontend/src/locales/en.js | 1 + frontend/src/pages/Settings.jsx | 15 +++++ frontend/src/style.css | 13 +++- src/bot/client.js | 5 ++ src/bot/heartbeat.js | 104 ++++++++++++++++++++++++++++++++ src/db.js | 34 +++++++++++ src/web/api.js | 6 ++ 10 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 src/bot/heartbeat.js 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'),