feat: Server aus dem Panel uebernehmen -- und nur die auch wieder loeschen
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

Im d4rk_gameserver-Panel bekommt jeder Server einen Schalter "Im Discord
anzeigen". Das Panel schreibt dafuer NICHTS hierher: es setzt nur ein
Haekchen, und das reist in dem Statusbericht mit, den der Monitor ohnehin
alle 30 Sekunden abholt. Der Bot legt den Eintrag dann selbst an.

Der umgekehrte Weg -- Panel ruft unsere API -- braeuchte dort ein
Zeichen mit Schreibrecht. So bleibt es bei einem, das nur lesen kann.

ZWEI FALLEN, beide beim Bauen aufgefallen:

Der Abgleich lief zu spaet. In monitorTick stand `if (servers.length ===
0) return` VOR dem Panel-Abruf; beim allerersten Server haette der
Schalter also nichts getan, und zwar stillschweigend. Der Abruf steht
jetzt davor.

Loeschen braucht einen Besitzvermerk. Ohne den nimmt ein Haekchen im
Panel den handgepflegten ATM10 mit -- die einzige verfuegbare Grundlage
waere der Name, und beim ersten Server, der in beiden Werkzeugen gleich
heisst, waere der Eintrag samt Bild, Links und Zugangs-Code weg. Neue
Spalte `panel_name`, leer heisst "von Hand". Die UPDATE-Anweisung fasst
sie nicht an, damit ein Bearbeiten im Webinterface die Herkunft behaelt.

Und der Abgleich ueberschreibt nichts: angelegt und geloescht wird, mehr
nicht.

tools/panel-abgleich-pruefen.mjs prueft das auf einer EIGENEN Datenbank
in /tmp und bricht ab, wenn sie es nicht ist -- eine Probe, die loeschen
kann, darf nicht dort loeschen, wo es zaehlt. Alles gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 19:08:43 +02:00
co-authored by Claude Opus 5
parent 21eab627b9
commit 0982e8cd1b
5 changed files with 241 additions and 4 deletions
+1 -1
View File
@@ -101,7 +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 **Config → Game-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 |
| 🖥️ **Panel-Anbindung** *(optional)* | Trägt der Betreiber unter **Config → Game-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. **Umgekehrt gilt außerdem:** im Panel hat jeder Server einen Schalter *„Im Discord anzeigen"* — wird er umgelegt, legt der Bot den Monitor-Eintrag binnen einer halben Minute selbst an und räumt ihn beim Ausschalten wieder weg. Das Panel schreibt dafür nichts hierher; das Häkchen reist im Statusbericht mit, den der Bot ohnehin abholt. Bild, Links und Zugangs-Code trägt man weiterhin hier ein — der Abgleich legt nur an und löscht, er überschreibt nie |
| 🚜 **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
+23 -1
View File
@@ -9,6 +9,7 @@ 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';
import { abgleichen, embedWegraeumen } from '../panel-abgleich.js';
const GREEN = 0x23a55a;
const RED = 0xf23f43;
@@ -376,6 +377,28 @@ async function handleAlerts(client, r) {
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
export async function monitorTick(client) {
if (!moduleEnabled('server_monitor')) return;
// DER PANEL-BERICHT WIRD VOR ALLEM ANDEREN GEHOLT, und der Abgleich läuft
// VOR der Prüfung auf einen leeren Monitor.
//
// Vorher stand hier zuerst `if (servers.length === 0) return`. Damit hätte
// der Schalter im Panel beim ALLERERSTEN Server nichts getan -- der
// Durchlauf wäre ausgestiegen, bevor er das Panel überhaupt fragt. Und
// zwar stillschweigend, was der schlechteste aller Fehlermodi ist: der
// Schalter steht auf an, im Kanal passiert nichts, und es gibt nirgends
// eine Meldung, an der man sich entlanghangeln könnte.
//
// Der Aufruf ist billig: `panelStatus` hält seine Antwort 20 Sekunden und
// gibt ohne eingetragene Adresse sofort null zurück.
const panelItems = await panelStatus();
if (panelItems) {
const { angelegt, entfernt } = abgleichen(panelItems);
if (entfernt.length > 0) await embedWegraeumen(client, entfernt);
if (angelegt.length > 0 || entfernt.length > 0) {
console.log(`[panel] übernommen: ${angelegt.join(', ') || '—'} | entfernt: ${entfernt.map((e) => e.name).join(', ') || '—'}`);
}
}
const servers = listGameservers();
if (servers.length === 0) return;
@@ -384,7 +407,6 @@ export async function monitorTick(client) {
// 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
+19 -2
View File
@@ -934,6 +934,18 @@ db.exec(`
// Wer darf drauf: 'auto' nimmt, was die Abfrage sagt. Nur die wenigsten
// Spiele melden das — und eine Whitelist meldet gar keins.
if (!cols.includes('zugang')) db.exec(`ALTER TABLE gameservers ADD COLUMN zugang TEXT NOT NULL DEFAULT 'auto'`);
// Migration v4: Wer hat diesen Eintrag angelegt?
//
// Leer = ein Mensch, über das Webinterface. Steht ein Name drin, kommt der
// Eintrag aus dem d4rk_gameserver-Panel und wird von dort auch wieder
// weggeräumt.
//
// DIESE SPALTE IST DIE GANZE SICHERUNG DAGEGEN, dass ein Häkchen im Panel
// einen von Hand gepflegten Server löscht. Ohne sie müsste der Abgleich
// raten, und die einzige verfügbare Grundlage wäre der Name — bei dem
// ersten Server, der in beiden Werkzeugen gleich heißt, wäre der
// handgepflegte Eintrag samt Bild, Links und Zugangs-Code weg.
if (!cols.includes('panel_name')) db.exec(`ALTER TABLE gameservers ADD COLUMN panel_name TEXT NOT NULL DEFAULT ''`);
}
// Die Auswahl im Panel bot mal Spiel-IDs an, die es bei gamedig gar nicht gibt.
// Wer eine davon gewählt hat, hatte einen Server, der dauerhaft offline stand,
@@ -947,8 +959,8 @@ db.exec(`
for (const [alt, neu] of Object.entries(echteIds)) um.run(neu, alt);
}
const insertGameserver = db.prepare(`
INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token, mods_url, links, zugang)
VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token, @mods_url, @links, @zugang)
INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token, mods_url, links, zugang, panel_name)
VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token, @mods_url, @links, @zugang, @panel_name)
`);
const updateGameserverStmt = db.prepare(`
UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address,
@@ -966,6 +978,11 @@ const deleteGameserverStmt = db.prepare('DELETE FROM gameservers WHERE id = ?');
const gameserverFelder = {
name: '', type: 'fivem', query_url: '', address: '', host: '', port: null,
connect_url: '', image_url: '', token: '', mods_url: '', links: '[]', zugang: 'auto',
// Leer = von Hand angelegt. Die UPDATE-Anweisung oben fasst die Spalte
// absichtlich NICHT an: wer einen Panel-Server im Webinterface bearbeitet,
// soll ihm damit nicht die Herkunft nehmen. (Ein überzähliger benannter
// Parameter stört better-sqlite3 nicht -- nachgemessen.)
panel_name: '',
};
// Links dürfen als Liste hereinkommen — so gibt getGameserver sie ja auch
// heraus. better-sqlite3 kann kein Array binden und würde sonst werfen.
+91
View File
@@ -0,0 +1,91 @@
// Server, die im Panel das Häkchen „Im Discord anzeigen" tragen, hier anlegen
// — und wieder wegräumen, wenn es fällt.
//
// WARUM DIESE RICHTUNG. Der naheliegende Weg wäre: das Panel ruft die
// Bot-API und legt selbst an. Dann müsste das Panel aber ein Zeichen halten,
// das SCHREIBEN darf — und damit läge das mächtigere der beiden Geheimnisse
// auf der Seite, die es nicht braucht. So bleibt es bei einem Zeichen, das nur
// lesen kann, und der Bot holt den Statusbericht ohnehin alle 30 Sekunden ab.
// Das Häkchen reist einfach mit.
//
// DER PREIS IST EINE HALBE MINUTE. Deshalb sagt das Panel beim Umlegen des
// Schalters dazu, wie lange es dauert. Ein Knopf, der scheinbar nichts tut,
// wird dreimal gedrückt.
//
// WAS HIER NICHT PASSIERT: ein bestehender Eintrag wird nicht angefasst. Wer
// im Webinterface ein Bild, einen Verbinden-Link oder Regeln ergänzt hat, soll
// das nicht beim nächsten Durchlauf verlieren. Angelegt und gelöscht wird,
// mehr nicht.
import { listGameservers, createGameserver, deleteGameserver, getSetting } from './db.js';
/**
* Taugt dieser Panel-Eintrag für einen Monitor-Eintrag?
*
* Drei Bedingungen, und alle drei sind im Panel schon geprüft — hier stehen
* sie noch einmal, weil ein Bericht von einem älteren Panel kommen kann und
* ein halber Eintrag schlimmer ist als keiner: er stünde dauerhaft auf
* offline und schlüge Alarm.
*/
export function taugt(x) {
return Boolean(x?.discord && x.gamedig && x.adresse && x.port);
}
/**
* Einmal abgleichen.
*
* @returns {{angelegt: string[], entfernt: Array<{id: number, name: string, message_id: string|null}>}}
* Die Entfernten kommen mit ihrer Nachrichten-Kennung zurück, damit
* der Aufrufer das Embed löschen kann — hier gibt es keinen Client.
*/
export function abgleichen(items) {
const ergebnis = { angelegt: [], entfernt: [] };
if (!Array.isArray(items)) return ergebnis;
const vorhanden = listGameservers();
const ausPanel = new Map(vorhanden.filter((s) => s.panel_name).map((s) => [s.panel_name, s]));
const gewollt = new Set(items.filter(taugt).map((x) => x.name));
for (const x of items) {
if (!taugt(x) || ausPanel.has(x.name)) continue;
// Ein von Hand angelegter Server auf demselben Port ist schon da —
// dann NICHT noch einmal. Sonst stünde derselbe Server zweimal im
// Kanal, und niemand wüsste, welches Embed das richtige ist.
const dasselbe = vorhanden.find(
(s) => Number(s.port) === Number(x.port) && String(s.host) === String(x.adresse),
);
if (dasselbe) continue;
createGameserver({
name: x.name,
type: x.gamedig,
host: x.adresse,
port: x.port,
address: `${x.adresse}:${x.port}`,
query_url: '',
// Die Herkunft. Nur was hier steht, wird später auch wieder
// weggeräumt — der handgepflegte ATM10 ist davon nicht berührt.
panel_name: x.name,
});
ergebnis.angelegt.push(x.name);
}
for (const [name, s] of ausPanel) {
if (gewollt.has(name)) continue;
deleteGameserver(s.id);
ergebnis.entfernt.push({ id: s.id, name, message_id: s.message_id ?? null });
}
return ergebnis;
}
/** Das Status-Embed eines entfernten Servers mit wegräumen. */
export async function embedWegraeumen(client, weg) {
const channelId = getSetting('status_channel_id');
if (!channelId) return;
for (const e of weg) {
if (!e.message_id) continue;
const channel = await client.channels.fetch(channelId).catch(() => null);
const message = await channel?.messages?.fetch(e.message_id).catch(() => null);
await message?.delete().catch(() => {});
}
}
+107
View File
@@ -0,0 +1,107 @@
// Der Abgleich mit dem Panel -- vor allem, was er NICHT anfassen darf.
//
// Diese Probe braucht better-sqlite3 und läuft deshalb im Container:
//
// docker exec d4rkbot node tools/panel-abgleich-pruefen.mjs
//
// Sie arbeitet auf einer EIGENEN Datenbank in /tmp, nicht auf der echten --
// eine Probe, die löschen kann, darf nicht dort löschen, wo es zählt.
// VOR dem Import setzen: db.js loest den Pfad beim Laden auf.
const PROBE = '/tmp/abgleich-probe.sqlite';
process.env.DB_PATH = PROBE;
import { unlinkSync } from 'node:fs';
for (const e of ['', '-wal', '-shm']) {
try { unlinkSync(PROBE + e); } catch { /* gab es noch nicht */ }
}
const db = await import('../src/db.js');
const { abgleichen, taugt } = await import('../src/panel-abgleich.js');
let fehler = 0;
const zeig = (gut, was, dazu = '') => {
if (!gut) fehler++;
console.log(` ${gut ? 'ok ' : 'FEHL'} ${was}${dazu ? ' ' + dazu : ''}`);
};
// Die echte Datenbank waere hier fatal -- diese Probe LOESCHT Server. Also
// nachsehen, worauf sie wirklich zeigt, statt es zu glauben.
{
const datei = db.db.prepare('PRAGMA database_list').all()[0]?.file ?? '';
if (!datei.includes('abgleich-probe')) {
console.log(`ABBRUCH: laeuft auf ${datei}, nicht auf der Probedatenbank.`);
process.exit(1);
}
console.log(`(Probedatenbank: ${datei})`);
console.log('');
}
const panel = (o) => ({
name: 'p1', discord: true, gamedig: 'minecraft', adresse: '1.2.3.4', port: 26000, ...o,
});
console.log('=== Was überhaupt taugt');
zeig(taugt(panel()) === true, 'vollständiger Eintrag');
zeig(taugt(panel({ discord: false })) === false, 'ohne Häkchen -> nein');
zeig(taugt(panel({ gamedig: null })) === false, 'nicht abfragbares Spiel -> nein (sonst dauerhaft offline)');
zeig(taugt(panel({ adresse: '' })) === false, 'ohne Adresse für Spieler -> nein');
zeig(taugt(panel({ port: null })) === false, 'ohne Port -> nein');
zeig(taugt(undefined) === false, 'gar nichts -> nein');
console.log('');
console.log('=== Anlegen');
let a = abgleichen([panel()]);
zeig(a.angelegt.length === 1, 'ein Server mit Häkchen wird angelegt', a.angelegt.join());
const angelegt = db.listGameservers().find((s) => s.name === 'p1');
zeig(angelegt?.type === 'minecraft', 'mit der gamedig-Kennung, nicht der Vorlagen-Kennung', angelegt?.type);
zeig(angelegt?.host === '1.2.3.4' && Number(angelegt?.port) === 26000, 'Adresse und Port stehen drin');
zeig(angelegt?.address === '1.2.3.4:26000', 'und die Adresse zum Kopieren', angelegt?.address);
zeig(angelegt?.panel_name === 'p1', 'die Herkunft ist vermerkt -- daran hängt alles Weitere');
a = abgleichen([panel()]);
zeig(a.angelegt.length === 0, 'ein zweiter Durchlauf legt ihn NICHT noch einmal an');
console.log('');
console.log('=== Was der Abgleich NICHT anfassen darf');
// Von Hand angelegt: kein panel_name.
const handId = db.createGameserver({
name: 'ATM10', type: 'minecraft', host: '9.9.9.9', port: 25565,
address: '9.9.9.9:25565', image_url: 'https://bild', mods_url: 'https://mods',
links: [{ label: 'Regeln', url: 'https://regeln' }],
});
a = abgleichen([]); // Panel meldet gar nichts mehr
const hand = db.getGameserver(handId);
zeig(Boolean(hand), 'ein von Hand angelegter Server überlebt einen leeren Bericht');
zeig(hand?.image_url === 'https://bild', 'samt Bild');
zeig(hand?.links?.length === 1, 'samt Links');
zeig(a.entfernt.length === 1 && a.entfernt[0].name === 'p1', 'entfernt wird nur, was aus dem Panel kam', a.entfernt.map((e) => e.name).join());
zeig(!db.listGameservers().some((s) => s.name === 'p1'), 'und der ist wirklich weg');
console.log('');
console.log('=== Ein bestehender Eintrag wird nicht verdoppelt');
// Derselbe Server, den es von Hand schon gibt: gleiche Adresse, gleicher Port.
a = abgleichen([panel({ name: 'ATM10 im Panel', adresse: '9.9.9.9', port: 25565 })]);
zeig(a.angelegt.length === 0, 'gleiche Adresse und gleicher Port -> kein zweites Embed im Kanal');
console.log('');
console.log('=== Bearbeiten im Webinterface nimmt die Herkunft nicht');
abgleichen([panel()]);
const p1 = db.listGameservers().find((s) => s.name === 'p1');
db.updateGameserver({ ...p1, image_url: 'https://neu' });
const danach = db.getGameserver(p1.id);
zeig(danach.panel_name === 'p1', 'panel_name steht nach dem Speichern noch da', danach.panel_name);
zeig(danach.image_url === 'https://neu', 'und die Änderung ist drin');
// Und der nächste Abgleich darf sie nicht überschreiben.
abgleichen([panel()]);
zeig(db.getGameserver(p1.id).image_url === 'https://neu', 'der nächste Durchlauf plättet sie nicht');
console.log('');
console.log('=== Kaputte Berichte');
zeig(abgleichen(null).angelegt.length === 0, 'null -> nichts passiert');
zeig(abgleichen([]).entfernt.length >= 0, 'leere Liste -> kein Absturz');
zeig(abgleichen([{}]).angelegt.length === 0, 'Eintrag ohne Felder -> nichts angelegt');
console.log('');
console.log(fehler ? `${fehler} FEHLER` : 'alles grün');
process.exitCode = fehler ? 1 : 0;