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'),