Oeffentliche Statusseite nach Statuspage-Vorbild
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Neue Seite /status auf dem Hub: Gesamtlage als Banner, darunter die
freigegebenen Dienste mit 90-Tage-Balken, die Game-Server, und die
Stoerungs-Historie der letzten 90 Tage.

Die Adressliste ist von einer kommagetrennten Einstellung in eine Tabelle
gewandert. Jeder Dienst hat jetzt Namen, Gruppe und einen Schalter
"oeffentlich" — sonst stuende auf einer oeffentlichen Seite die nackte URL,
auch von internen Diensten. Die alte Liste wird beim ersten Start uebernommen,
bewusst als nicht oeffentlich: was dort auftaucht, soll eine Entscheidung sein
und nicht durch eine Migration passieren.

/api/status liefert nur freigegebene Dienste und dabei keine Adressen, nur
Namen. Auch die Stoerungs-Historie ist gefiltert — sonst verriete sie, was es
sonst noch gibt. Im Browser gegengeprueft: auf der ganzen Seite steht kein
einziges https://.

Stoerungen werden jetzt aufgezeichnet, nicht nur gemeldet: pro Adresse ein
offener Eintrag, bis es wieder laeuft. Beim Wiederkommen wird er geschlossen,
bevor die DM rausgeht — sonst bliebe er bei einem Sendefehler ewig offen.

Aufbewahrung von 7 auf 90 Tage. Der Balken kann jetzt zwei Aufloesungen:
168 Stunden fuers Panel ("was war letzte Nacht?") und 90 Tage fuer die
Statusseite ("wie zuverlaessig?"). Stuendlich ueber 90 Tage waeren ueber 2000
Striche und unlesbar.

Im Panel sind aus der reinen Anzeige Verwaltung geworden: anlegen, bearbeiten,
entfernen, freigeben — dazu die Stoerungsliste.

Beim Bauen gefunden: die Uebernahme der alten Liste stand mitten in db.js und
rief getSetting(), dessen prepared statement erst 300 Zeilen weiter unten
entsteht. Der Bot starb beim Start mit "Cannot access getSettingStmt before
initialization" — nachgewiesen, dann ans Dateiende verschoben.

Geprueft: 182 SQL-Abfragen gegen das Schema, alle schreibenden Routen bewacht,
Stoerungs-Logik durchgespielt (doppeltes Eroeffnen wird abgelehnt, Schliessen
ohne offene gibt null), und beide Seiten im Browser mit drei Diensten in allen
drei Zustaenden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 05:45:50 +02:00
co-authored by Claude Fable 5
parent fd2d31f07f
commit 55e9ef57e2
10 changed files with 557 additions and 53 deletions
+94 -17
View File
@@ -15,7 +15,9 @@ import {
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, deleteUserData,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
deleteUserData,
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage,
@@ -25,7 +27,7 @@ import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavo
import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.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 { watchdogState, watchedServices } 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';
@@ -190,32 +192,107 @@ 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.
/** Einen überwachten Dienst in die Form bringen, die beide Seiten nutzen */
function dienstStand(dienst, { tage = false } = {}) {
const s = watchdogState().get(dienst.url) ?? {};
return {
id: dienst.id,
name: dienst.name,
gruppe: dienst.gruppe,
oeffentlich: dienst.oeffentlich,
// 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: tage ? watchdogDays(dienst.url, 90) : watchdogBuckets(dienst.url, 168),
};
}
// Watchdog-Übersicht fürs Panel: alle Dienste mit voller Adresse.
// Nicht öffentlich — in der Liste können interne Dienste stehen.
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) ?? {};
adressen: watchedServices().map((d) => ({ ...dienstStand(d), url: d.url })),
stoerungen: listIncidents(90, 50),
};
});
// Öffentliche Statusseite: nur freigegebene Dienste, und bewusst OHNE
// Adresse — der Name reicht, die URL geht niemanden etwas an.
app.get('/api/status', async () => {
const dienste = watchedServices().filter((d) => d.oeffentlich);
const urls = new Set(dienste.map((d) => d.url));
return {
dienste: dienste.map((d) => dienstStand(d, { tage: true })),
server: listGameservers().map((s) => {
const r = lastResults.get(s.id);
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),
id: s.id,
name: s.name,
icon: GAME_ICONS[s.type] ?? '🎮',
online: r?.online ?? null,
players: r?.players ?? null,
max: r?.max ?? null,
};
}),
// Nur Störungen freigegebener Dienste — sonst verrät die Historie,
// was es sonst noch gibt
stoerungen: listIncidents(90, 20)
.filter((v) => urls.has(v.url))
.map(({ url, ...rest }) => ({
...rest,
name: dienste.find((d) => d.url === url)?.name ?? 'Dienst',
})),
};
});
// --- Überwachte Dienste verwalten (Panel) ---
/** Eingaben prüfen; antwortet bei Fehlern selbst */
function dienstBody(request, reply) {
const b = request.body ?? {};
const name = String(b.name ?? '').trim().slice(0, 60);
const url = String(b.url ?? '').trim();
if (!name) { reply.code(400).send({ error: 'Name fehlt' }); return null; }
if (!/^https?:\/\/.+/.test(url)) { reply.code(400).send({ error: 'URL mit http(s) erwartet' }); return null; }
return {
name, url,
gruppe: String(b.gruppe ?? 'Dienste').trim().slice(0, 40) || 'Dienste',
oeffentlich: Boolean(b.oeffentlich),
sort: Number(b.sort) || 0,
};
}
app.post('/api/monitored', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const d = dienstBody(request, reply);
if (!d) return;
createMonitored(d);
logAudit(getSessionUser(request), 'dienst überwacht', `${d.name} (${d.url})`);
return { ok: true, dienste: listMonitored() };
});
app.put('/api/monitored/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const d = dienstBody(request, reply);
if (!d) return;
updateMonitored({ ...d, id: Number(request.params.id) });
logAudit(getSessionUser(request), 'dienst geändert', d.name);
return { ok: true, dienste: listMonitored() };
});
app.delete('/api/monitored/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
logAudit(getSessionUser(request), 'dienst entfernt', String(request.params.id));
return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() };
});
// --- Module: zentrale An/Aus-Schalter aller Funktionen ---
app.get('/api/modules', async (request, reply) => {