Eigener Herzschlag: der Bot kann jetzt auch ueber sich selbst Auskunft geben
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 07:10:53 +02:00
co-authored by Claude Opus 5
parent 3080f52e71
commit e32152b5ac
10 changed files with 194 additions and 6 deletions
+8 -5
View File
@@ -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]) => (
<NavLink to={to} key={to}>{t(key)}</NavLink>
))}
{pages.map((p) => (
{pages.filter((p) => p.in_menu).map((p) => (
<NavLink to={`/${p.slug}`} key={p.slug}>{p.title}</NavLink>
))}
</NavDropdown>
@@ -241,8 +240,12 @@ export default function App() {
git.d4rkst3r.de
</a>
<span className="footer-sep">//</span>
<NavLink to="/status">{t('footer.status')}</NavLink>
<NavLink to="/impressum">{t('footer.imprint')}</NavLink>
<NavLink to="/datenschutz">{t('footer.privacy')}</NavLink>
{pages.map((p) => (
<NavLink to={`/${p.slug}`} key={p.slug}>{p.title}</NavLink>
))}
</footer>
</div>
);
+8
View File
@@ -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')}
</a>
<span className="footer-sep">//</span>
{hub && <a href={`${hub}/status`}>{t('footer.status')}</a>}
<NavLink to="/impressum">{t('footer.imprint')}</NavLink>
<NavLink to="/datenschutz">{t('footer.privacy')}</NavLink>
{hub && pages.map((p) => (
<a href={`${hub}/${p.slug}`} key={p.slug}>{p.title}</a>
))}
</footer>
</div>
);
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+15
View File
@@ -1186,10 +1186,25 @@ export default function Settings({ me }) {
</div>
<span className="dash-uptime">
seit {fmtUptime(data.status.uptimeSeconds)}
{data.status.ping != null && ` · ${data.status.ping} ms`}
{data.status.guilds > 0 && ` · ${data.status.guilds} Server`}
</span>
</div>
{/* 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 ? (
<div className="dash-uptrack">
<UptimeTrack buckets={data.status.uptime} />
</div>
) : (
<p className="dash-hint">
Erreichbarkeit wird ab jetzt aufgezeichnet der Balken füllt sich
in den nächsten Stunden.
</p>
)}
<div className="dash-kpis">
<div className="card dash-kpi">
<span className="dash-v">{activeModules.length}<span className="dash-of">/{modules.length || ''}</span></span>
+12 -1
View File
@@ -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; }
+5
View File
@@ -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
+104
View File
@@ -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);
}
+34
View File
@@ -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 (?, ?)');
+6
View File
@@ -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'),