feat: der Monitor fragt das Panel, wenn eine Abfrage nicht ausreicht
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

gamedig FRAGT einen Server. Ein startendes Modpack antwortet nicht, und
nach zwei Fehlversuchen stand im Alarm-Kanal eine Ausfallmeldung, obwohl
niemand etwas kaputtgemacht hat -- am ATM10 des Betreibers gemessen jedes
Mal 306 Sekunden Fehlalarm.

Das d4rk_gameserver-Panel SIEHT den Container, statt ihn zu fragen. Neu
ist deshalb `src/panel.js`: Zustand, Fertig-Merkmal, RAM und CPU kommen
als Zusatzauskunft dazu, einmal je Durchlauf abgerufen und 20 Sekunden
zwischengespeichert.

Was das im Alarm-Kanal aendert:

  startet gerade   kein Alarm, aber nur innerhalb einer Gnadenfrist von
                   15 Minuten. Ohne diese Grenze fraesse die Anbindung
                   genau den Alarm, fuer den es sie gibt -- `bereit`
                   wird nie von allein wahr, ein haengender Start bliebe
                   sonst fuer immer stumm.
  im Panel gestoppt  kein Alarm (Code 0/143/137)
  abgestuerzt      ALARM, mit Code im Embed
  OOM-Kill         ALARM, und beim Namen genannt statt als Absturz
                   getarnt -- bei Modpacks die haeufigste Ursache
  Container weg    ALARM

Der Zaehler wird beim Unterdruecken NICHT zurueckgesetzt: ein bereits
gemeldeter Ausfall bleibt gemeldet, sonst verschluckt ein Stopp im Panel
die spaetere "wieder online"-Entwarnung und im Kanal bliebe ein Alarm
ohne Aufloesung stehen.

ES IST DURCHGEHEND OPTIONAL. Adresse und Zeichen stehen im Webinterface
unter "Server" und NICHT in der .env; ohne Eintrag -- und ebenso, wenn
das Panel nicht antwortet -- verhaelt sich der Monitor exakt wie vorher.
Das Zeichen darf nur lesen, damit auch ein verlorenes niemandem einen
Server stoppen kann.

`tools/panel-pruefen.mjs` prueft die Logik ohne Discord und ohne
Datenbank; deshalb laedt panel.js die Einstellungen erst beim Aufruf.
This commit is contained in:
2026-08-13 18:05:24 +02:00
parent bce75f8c65
commit d6124fc2cd
6 changed files with 326 additions and 1 deletions
+1
View File
@@ -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/<server>` 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
+45 -1
View File
@@ -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() });
+7
View File
@@ -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' },
},
+164
View File
@@ -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;
}
+17
View File
@@ -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') || '';
}
+92
View File
@@ -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