diff --git a/README.md b/README.md index 327d9a2..af3a0f8 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | Funktion | Was sie macht | |---|---| | 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Name im Spiel, 🔒 bei Passwort oder Whitelist, Spieler-Balken, Map, Version, Mod-Anzahl, Spieler-Liste, Knöpfe für Verbinden, Mod-Download und frei eintragbare Links wie Regeln oder TeamSpeak, Ping); **alle Spiele, die gamedig kennt** — die Auswahl im Panel kommt aus der Bibliothek selbst, inklusive Standard-Port und Hinweis auf nötige Zugangs-Codes (Farming Simulator, Terraria) — plus FiveM + HTTP-Check; Down/Up-Alerts mit Ausfalldauer | +| 🖥️ **Panel-Anbindung** *(optional)* | Trägt der Betreiber unter „Server" die Adresse des [d4rk_gameserver](https://git.d4rkst3r.de/D4rkst3r/d4rk_media)-Panels und ein Lese-Zeichen ein, weiß der Monitor mehr als eine Abfrage hergibt. gamedig **fragt** einen Server — ein startendes Modpack antwortet nicht und sieht dadurch aus wie ein Ausfall (am ATM10 gemessen: 306 Sekunden bis zum ersten Spieler). Das Panel **sieht** den Container: 🟡 „Startet gerade" statt 🔴 „Offline", kein Fehlalarm während des Hochfahrens, kein Alarm für einen im Panel gestoppten Server — aber sehr wohl einer für Absturz, OOM-Kill oder fehlenden Container. Dazu RAM und CPU im Embed. Ohne Eintrag ändert sich nichts, und antwortet das Panel nicht, ebenfalls nicht | | 🚜 **LS — Höfe & Preise** | Für Farming-Simulator-Server: Besitzkarte auf dem Luftbild des Servers (Parzellen nach Hof eingefärbt, Fläche = Hektar, Fahrzeuge und Spieler live), die Preiskurven übers Jahr mit „jetzt verkaufen oder warten", dazu **je Hof ein Embed**, das sich selbst aktualisiert: Land, Fuhrpark mit Wert und Betriebsstunden, was in die Werkstatt muss, was gewaschen gehört und was geladen ist. Dazu die **Modliste**: der Bot meldet, was neu, aktualisiert oder entfernt wurde, und auf `/mods/` steht jeder Mod einzeln zum Laden — durch den Bot hindurch, damit der Zugangs-Code des Spielservers nicht in einem Link landet. **Nicht möglich:** welche Frucht auf welchem Feld steht, und Tierställe — beides gibt der Feed nicht her (siehe [docs/ls-feed.md](docs/ls-feed.md)) | ### Server & Technik diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js index b287c8d..4998e00 100644 --- a/src/bot/server-monitor.js +++ b/src/bot/server-monitor.js @@ -8,6 +8,7 @@ import { brandFooter, botStatusText } from '../runtime-settings.js'; import { brandEmbed } from '../embeds.js'; import { moduleEnabled } from '../modules.js'; import { tuning, tuningMs, everyTuned } from '../tuning.js'; +import { panelStatus, panelEintrag, zustandText, lastText, startetNoch, gewolltAus } from '../panel.js'; const GREEN = 0x23a55a; const RED = 0xf23f43; @@ -257,8 +258,14 @@ export function buildServerEmbed(r) { // nicht verlässlich, und dann stünde das Steuerzeichen wörtlich da. kopf.push(`*${r.spielname.trim().slice(0, 200)}*`); } + // Wenn GameDig nichts erreicht hat, das Panel aber sagt, dass der + // Container laeuft und noch startet: DAS ist die richtige Auskunft. Ein + // Modpack braucht Minuten bis zum ersten Spieler -- am ATM10 des + // Betreibers 306 Sekunden -- und ein rotes "Offline" waere in dieser Zeit + // schlicht falsch. + const ausPanel = r.online ? null : zustandText(r.panel); kopf.push([ - r.online ? '🟢 **Online**' : '🔴 **Offline**', + r.online ? '🟢 **Online**' : (ausPanel ?? '🔴 **Offline**'), zugang.grund ? `🔒 ${zugang.grund}` : null, r.online && r.ping != null ? `${r.ping} ms` : null, ].filter(Boolean).join(' · ')); @@ -276,6 +283,12 @@ export function buildServerEmbed(r) { r.mods != null ? { name: 'Mods', value: String(r.mods), inline: true } : null, ])); + // Was nur das Panel weiss: was der Server gerade verbraucht. + { + const last = lastText(r.panel); + if (last) embed.addFields({ name: 'Auf dem Wirt', value: last }); + } + if (r.online && r.playerNames.length > 0) { embed.addFields({ name: `Wer drauf ist (${r.playerNames.length})`, @@ -318,6 +331,31 @@ async function handleAlerts(client, r) { alertState.set(r.id, { fails: 0, down: false, since: null }); return; } + + // KEIN ALARM, WENN DAS PANEL DEN ZUSTAND ERKLÄREN KANN. + // + // Das ist der eigentliche Gewinn der Anbindung. GameDig *fragt* einen + // Server; ein startender antwortet nicht, und nach zwei Fehlversuchen + // stünde hier eine Ausfallmeldung. Bei einem Modpack sind das jedes Mal + // Minuten Fehlalarm — am ATM10 des Betreibers 306 Sekunden bis zum ersten + // Spieler. Das Panel *sieht* den Container und weiß, dass er läuft und + // nur das Fertig-Merkmal noch nicht durch ist. + // + // Zwei Fälle, eine Behandlung: nichts tun. Der Zähler bleibt dabei + // ausdrücklich STEHEN statt zurückgesetzt zu werden, aus zwei Gründen: + // + // - Läuft die Gnadenfrist ab, ohne dass der Server fertig wird, greift + // `startetNoch` nicht mehr und der Alarm kommt sofort statt erst nach + // zwei weiteren Durchläufen. + // - Ein bereits gemeldeter Ausfall bleibt gemeldet. Sonst verschluckt + // ein Stopp im Panel die spätere „✅ wieder online"-Entwarnung, und im + // Alarm-Kanal bliebe ein 🚨 ohne Auflösung stehen. + // + // Und was NICHT unterdrückt wird: ein Absturz, ein OOM-Kill und ein + // fehlender Container. Das sind die Fälle, für die es den Kanal gibt — + // hier hilft das Panel dem Alarm, statt ihn zu ersetzen. + if (startetNoch(r.panel) || gewolltAus(r.panel)) return; + const fails = s.fails + 1; if (!s.down && fails >= tuning('monitor_fails')) { const channel = await client.channels.fetch(channelId).catch(() => null); @@ -343,6 +381,12 @@ export async function monitorTick(client) { const results = await Promise.all(servers.map(queryServer)); + // Die Zusatzauskunft vom Panel: EINMAL je Durchlauf, nicht je Server. + // Sie ist optional -- ist nichts eingetragen oder antwortet das Panel + // nicht, bleibt `panel` schlicht null und alles läuft wie vorher. + const panelItems = await panelStatus(); + for (const r of results) r.panel = panelEintrag(panelItems, r); + // Verlauf für die Web-Seite festhalten + Cache fürs API for (const r of results) { lastResults.set(r.id, { ...r, checkedAt: Date.now() }); diff --git a/src/modules.js b/src/modules.js index 32e9518..ba51185 100644 --- a/src/modules.js +++ b/src/modules.js @@ -243,6 +243,13 @@ export const MODULES = [ fields: [ { setting: 'status_channel_id', label: 'Status-Kanal', art: 'kanal', hint: 'hier steht das Live-Embed' }, { setting: 'server_alert_channel_id', label: 'Alarm-Kanal', art: 'kanal', hint: '🚨 down / ✅ wieder online' }, + // Optional. GameDig FRAGT einen Server -- ein startender antwortet + // nicht, und das sieht aus wie ein Ausfall. Das Panel SIEHT den + // Container und kann "startet gerade" von "aus" unterscheiden. + { setting: 'panel_url', label: 'Panel-Adresse', art: 'text', + hint: 'optional, z. B. https://panel.d4rkst3r.de — unterscheidet „startet gerade" von „offline"' }, + { setting: 'panel_zeichen', label: 'Panel-Zeichen', art: 'text', + hint: 'im Panel unter „Zugang für Programme" anlegen; darf nur lesen' }, ], tool: { tab: 'server', label: 'Game-Server verwalten' }, }, diff --git a/src/panel.js b/src/panel.js new file mode 100644 index 0000000..2929d4b --- /dev/null +++ b/src/panel.js @@ -0,0 +1,164 @@ +// Auskunft vom d4rk_gameserver-Panel. +// +// WARUM ÜBERHAUPT, wo der Monitor doch schon GameDig hat: weil GameDig einen +// Server FRAGT, und ein Server, der noch startet, antwortet nicht. Für den +// Monitor sieht das aus wie „offline" — und genau dann steht im Alarm-Kanal +// eine Ausfallmeldung, obwohl niemand etwas kaputtgemacht hat. +// +// Ein Modpack braucht bis zum ersten Spieler Minuten; am ATM10 des Betreibers +// gemessen sind es 306 Sekunden. Fünf Minuten Fehlalarm bei jedem Neustart. +// +// Das Panel weiß es besser, weil es den Container sieht statt ihn zu fragen: +// +// zustand was Docker sagt (running, exited, weg) +// bereit ob das Fertig-Merkmal im Protokoll stand +// +// Damit lässt sich „läuft noch gar nicht" von „startet gerade" und von +// „hängt" unterscheiden. +// +// ES IST ABSICHTLICH OPTIONAL. Ohne Adresse und Zeichen verhält sich der +// Monitor exakt wie vorher, und wenn das Panel nicht antwortet, ebenfalls. +// Eine Zusatzauskunft darf nie dazu führen, dass die Hauptauskunft ausfällt. + +// Kurz zwischenspeichern: der Monitor fragt je Server einmal, und bei acht +// Servern wären das acht Abrufe für dieselbe Antwort. +let zwischen = { zeit: 0, items: null }; +const FRISCH_MS = 20_000; + +/** Der ganze Panel-Status, oder null. Wirft nie. */ +export async function panelStatus() { + // ERST HIER GELADEN und nicht oben im Modul: `runtime-settings` zieht die + // Datenbank mit herein, und dann laesst sich keine der reinen Funktionen + // unten mehr ohne better-sqlite3 pruefen. Eine Probe, die eine Datenbank + // braucht, um eine Zeichenkette zu vergleichen, schreibt niemand. + const { panelUrl, panelZeichen } = await import('./runtime-settings.js'); + const basis = panelUrl(); + const zeichen = panelZeichen(); + if (!basis || !zeichen) return null; + + if (zwischen.items && Date.now() - zwischen.zeit < FRISCH_MS) return zwischen.items; + + try { + const steuer = AbortSignal.timeout(5000); + const antwort = await fetch(`${basis.replace(/\/$/, '')}/api/oeffentlich/status`, { + headers: { authorization: `Bearer ${zeichen}` }, + signal: steuer, + }); + if (!antwort.ok) return null; + const daten = await antwort.json(); + zwischen = { zeit: Date.now(), items: Array.isArray(daten.items) ? daten.items : [] }; + return zwischen.items; + } catch { + // Panel weg, Zeichen zurückgezogen, Netz hakt — der Monitor läuft + // dann einfach ohne diese Zusatzauskunft weiter. + return null; + } +} + +/** + * Den Panel-Eintrag zu einem Monitor-Server finden. + * + * ÜBER DEN PORT, nicht über den Namen. Namen sind Geschmackssache und stehen + * in zwei Werkzeugen selten gleich; der Port ist eine Zahl, die beide kennen + * und die nur einmal vergeben ist. + */ +export function panelEintrag(items, server) { + if (!items?.length) return null; + const port = Number(server?.port); + if (Number.isFinite(port) && port > 0) { + const treffer = items.find((x) => Number(x.port) === port); + if (treffer) return treffer; + } + // Zweite Möglichkeit: gleicher Name, klein geschrieben. Hilft, wenn + // jemand den Port im Monitor weggelassen hat. + const name = String(server?.name ?? '').trim().toLowerCase(); + return items.find((x) => String(x.name).trim().toLowerCase() === name) ?? null; +} + +/** + * Wie lange ein Start dauern darf, bevor er als hängend gilt. + * + * Gemessen: ATM10 braucht 306 Sekunden bis zum ersten Spieler. Fünfzehn + * Minuten sind knapp das Dreifache — genug Luft für ein größeres Pack auf + * kaltem Cache, und kurz genug, dass ein hängender Start noch am selben + * Abend auffällt. + * + * DIESE GRENZE IST NÖTIG, sonst frisst die Anbindung genau den Alarm, für den + * es sie gibt: `bereit` wird nie von allein wahr. Ein Server, der beim Start + * hängt, bliebe für immer „startet gerade" — und für immer ohne Meldung. + */ +export const STARTGNADE_MS = 15 * 60_000; + +/** Läuft der Container und ist er noch in der zugestandenen Startzeit? */ +export function startetNoch(eintrag) { + if (eintrag?.zustand !== 'running' || eintrag.bereit) return false; + // Ohne Startzeit lässt sich nichts begrenzen. Das passiert nur, wenn + // Dockers Zeitstempel unlesbar ist — dieselbe Antwort, die uns gerade + // „running" gesagt hat. Ein kaputtes Feld ist kein hängender Server, + // deshalb hier Nachsicht. + if (!eintrag.seit) return true; + return Date.now() - eintrag.seit < STARTGNADE_MS; +} + +/** + * War das Ende gewollt? + * + * 0 heißt sauber beendet, 143 ist SIGTERM (`docker stop`) und 137 SIGKILL + * nach abgelaufener Frist — alle drei entstehen, wenn jemand im Panel auf + * Stopp drückt. Jeder andere Code ist ein Absturz, und `oom` heißt: der + * Speicher war zu knapp. Genau dafür ist der Alarm-Kanal da. + */ +export function gewolltAus(eintrag) { + if (eintrag?.zustand !== 'exited') return false; + if (eintrag.oom) return false; + // `code` fehlt bei einem älteren Panel. Dann ist die Frage nicht + // beantwortbar, und im Zweifel gilt der Ausfall als echt. + return eintrag.code === 0 || eintrag.code === 143 || eintrag.code === 137; +} + +/** + * Was im Embed stehen soll, wenn GameDig nichts erreicht hat. + * + * Gibt null zurück, wenn das Panel nichts beizutragen hat -- dann bleibt es + * beim schlichten „Offline". + */ +export function zustandText(eintrag) { + if (!eintrag) return null; + + if (eintrag.zustand === 'running' && !eintrag.bereit) { + const s = eintrag.seit ? Math.round((Date.now() - eintrag.seit) / 1000) : null; + const dauer = s == null ? '' : s < 90 ? ` (seit ${s} s)` : ` (seit ${Math.round(s / 60)} min)`; + // Nach der Gnadenfrist ist „startet gerade" keine Auskunft mehr, + // sondern eine Beschwichtigung. Dann steht hier, was Sache ist. + return startetNoch(eintrag) + ? `🟡 **Startet gerade**${dauer}` + : `🟠 **Startet seit ${dauer.replace(/^ \(seit |\)$/g, '')} und ist nicht fertig**`; + } + + if (eintrag.zustand === 'exited') { + if (eintrag.oom) return '🔴 **Vom Speicher erschlagen** (out of memory)'; + if (gewolltAus(eintrag)) return '⚫ **Gestoppt** (im Panel)'; + // Auch ohne `code` (älteres Panel) landet man hier — dann ohne Zahl. + return eintrag.code == null + ? '🔴 **Beendet**' + : `🔴 **Abgestürzt** (Code ${eintrag.code})`; + } + + if (eintrag.zustand === 'weg') return '🔴 **Container fehlt**'; + return null; +} + +/** „1,2 GB / 7 GB · 14 %" — nur wenn das Panel Werte hat. */ +export function lastText(eintrag) { + if (!eintrag || eintrag.zustand !== 'running') return null; + const teile = []; + if (eintrag.ram) { + const gb = (b) => `${(b / 1024 ** 3).toFixed(1)} GB`; + teile.push(eintrag.ram_grenze ? `${gb(eintrag.ram)} / ${gb(eintrag.ram_grenze)}` : gb(eintrag.ram)); + } + // Das Panel liefert Dockers Kern-Prozent (200 = zwei Kerne voll). Für eine + // Statusmeldung ist der Anteil an der Maschine die verständlichere Zahl -- + // „187 %" liest sich wie ein Fehler. + if (eintrag.cpu != null && eintrag.kerne) teile.push(`${(eintrag.cpu / eintrag.kerne).toFixed(1)} % CPU`); + return teile.length ? teile.join(' · ') : null; +} diff --git a/src/runtime-settings.js b/src/runtime-settings.js index fabc248..ae45879 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -325,3 +325,20 @@ export function repoIgnored(repo) { (r) => r.toLowerCase() === repo.toLowerCase() ); } + +/** + * d4rk_gameserver-Panel — Adresse und Lesezeichen. + * + * BEIDES NUR HIER UND NICHT IN DER .env: der Betreiber soll es im Webinterface + * eintragen können, so wie alles andere auch. Das Zeichen darf ausschließlich + * lesen; selbst wenn es abhandenkommt, kann damit niemand einen Server stoppen. + * + * Leer = die Zusatzauskunft ist aus, und der Monitor verhält sich wie vorher. + */ +export function panelUrl() { + return (getSetting('panel_url') || '').replace(/\/$/, ''); +} + +export function panelZeichen() { + return getSetting('panel_zeichen') || ''; +} diff --git a/tools/panel-pruefen.mjs b/tools/panel-pruefen.mjs new file mode 100644 index 0000000..a7674a7 --- /dev/null +++ b/tools/panel-pruefen.mjs @@ -0,0 +1,92 @@ +// Die Panel-Anbindung -- ohne Discord, ohne Datenbank, nur die Logik. +// +// Geprüft wird vor allem, was passieren muss, wenn das Panel NICHT da ist: +// dann darf sich nichts anders verhalten als vorher. Eine Zusatzauskunft, die +// den Monitor mit herunterreißt, wäre schlimmer als gar keine. +// +// node tools/panel-pruefen.mjs + +import { panelEintrag, zustandText, lastText, startetNoch, gewolltAus, STARTGNADE_MS } from '../src/panel.js' + +let fehler = 0 +const zeig = (gut, was, dazu = '') => { + if (!gut) fehler++ + console.log(` ${gut ? 'ok ' : 'FEHL'} ${was}${dazu ? ' ' + dazu : ''}`) +} + +const items = [ + // So sieht ein Modpack in der Startphase aus: Container läuft, das + // Fertig-Merkmal stand noch nicht im Protokoll, GameDig bekommt nichts. + { name: 'atm11-test', zustand: 'running', bereit: false, port: 26000, + seit: Date.now() - 120_000, cpu: 187, kerne: 20, ram: 5_153_960_755, ram_grenze: 7_516_192_768 }, + { name: 'nallheim', zustand: 'running', bereit: true, port: 26004, + seit: Date.now() - 3_600_000, cpu: 20, kerne: 20, ram: 1_073_741_824, ram_grenze: 5_368_709_120 }, + { name: 'alt', zustand: 'exited', bereit: false, port: 26010, code: 0, oom: false, + seit: null, cpu: null, kerne: null, ram: null, ram_grenze: null }, +] + +// Ein Start, der die Gnadenfrist gerissen hat -- der Fall, in dem die +// Anbindung NICHT mehr beschwichtigen darf. +const haengt = { ...items[0], seit: Date.now() - (STARTGNADE_MS + 60_000) } + +console.log('=== Zuordnen') +zeig(panelEintrag(items, { port: 26000 })?.name === 'atm11-test', 'über den Port') +zeig(panelEintrag(items, { name: 'NALLHEIM' })?.name === 'nallheim', 'ersatzweise über den Namen, groß/klein egal') +zeig(panelEintrag(items, { port: 9999 }) === null, 'unbekannter Port -> nichts') +zeig(panelEintrag(null, { port: 26000 }) === null, 'ohne Panel-Daten -> nichts') +zeig(panelEintrag([], { port: 26000 }) === null, 'leere Liste -> nichts') + +console.log('') +console.log('=== Was im Embed steht, wenn GameDig nichts erreicht') +const startet = zustandText(items[0]) +zeig(/Startet gerade/.test(startet ?? ''), 'startender Server -> „Startet gerade"', startet) +zeig(/2 min/.test(startet ?? ''), 'mit Dauer', startet) +zeig(/Gestoppt/.test(zustandText(items[2]) ?? ''), 'gestoppter -> „Gestoppt"', zustandText(items[2])) +zeig(zustandText(items[1]) === null, 'ein bereiter Server trägt hier nichts bei — da war GameDig schon erfolgreich') +zeig(zustandText(null) === null, 'ohne Eintrag -> null, dann bleibt es beim schlichten „Offline"') + +console.log('') +console.log('=== Wann der Alarm UNTERDRÜCKT wird -- und wann nicht') +zeig(startetNoch(items[0]) === true, 'startender Server in der Frist -> unterdrücken') +zeig(startetNoch(items[1]) === false, 'ein fertiger Server ist kein Startvorgang') +zeig(startetNoch(items[2]) === false, 'ein beendeter erst recht nicht') +zeig(startetNoch(null) === false, 'ohne Panel -> nichts unterdrücken (Monitor wie vorher)') +// Die wichtigste Zeile hier: `bereit` wird nie von allein wahr. Ohne diese +// Grenze fräße die Anbindung genau den Alarm, für den es sie gibt. +zeig(startetNoch(haengt) === false, 'nach der Gnadenfrist NICHT mehr unterdrücken -- sonst kommt der Alarm nie') +zeig(/nicht fertig/.test(zustandText(haengt) ?? ''), 'und im Embed steht dann Klartext', zustandText(haengt)) +zeig(startetNoch({ ...items[0], seit: null }) === true, 'ohne Startzeit -> Nachsicht (kaputter Zeitstempel, kein hängender Server)') + +const abgestuerzt = { ...items[2], code: 1 } +const oomTot = { ...items[2], code: 137, oom: true } +const altesPanel = { name: 'x', zustand: 'exited', bereit: false, port: 1 } // ohne code/oom +zeig(gewolltAus(items[2]) === true, 'Code 0 -> jemand hat gestoppt, kein Alarm') +zeig(gewolltAus({ ...items[2], code: 143 }) === true, 'Code 143 (SIGTERM) -> auch ein Stopp') +zeig(gewolltAus(abgestuerzt) === false, 'Code 1 -> ABSTURZ, der Alarm muss durch') +zeig(gewolltAus(oomTot) === false, 'OOM-Kill -> Alarm, auch bei Code 137') +zeig(gewolltAus(altesPanel) === false, 'älteres Panel ohne `code` -> im Zweifel echter Ausfall') +zeig(gewolltAus(items[0]) === false, 'ein laufender Server ist nicht "aus"') +zeig(/Abgestürzt.*\(Code 1\)/.test(zustandText(abgestuerzt) ?? ''), 'Absturz steht mit Code im Embed', zustandText(abgestuerzt)) +zeig(/Speicher/.test(zustandText(oomTot) ?? ''), 'OOM wird benannt, nicht als Absturz getarnt', zustandText(oomTot)) + +console.log('') +console.log('=== Last') +const last = lastText(items[0]) +zeig(/4\.8 GB \/ 7\.0 GB/.test(last ?? ''), 'RAM mit Grenze', last) +// 187 / 20 = 9.35, und toFixed(1) macht daraus 9.3 -- 9.35 liegt binär knapp +// UNTER 9.35, also wird abgerundet. Hier steht der gemessene Wert, nicht der +// erwartete; für eine Auslastungsanzeige ist das Zehntel ohnehin ohne Belang. +zeig(/9\.3 % CPU/.test(last ?? ''), 'CPU als Anteil der MASCHINE, nicht Dockers 187 %', last) +zeig(lastText(items[2]) === null, 'gestoppt -> keine Last') +zeig(lastText(null) === null, 'ohne Eintrag -> nichts') + +console.log('') +console.log('=== Abschaltbarkeit') +// Ohne Panel-Eintrag muss JEDE dieser Funktionen null liefern, sonst steht +// im Embed plötzlich ein leeres Feld. +zeig([zustandText(undefined), lastText(undefined), panelEintrag(undefined, {})].every((x) => x == null), + 'undefined überall -> überall null') + +console.log('') +console.log(fehler ? `${fehler} FEHLER` : 'alles grün') +process.exitCode = fehler ? 1 : 0