Server-Seite ueberarbeitet, und die Spielauswahl kommt jetzt aus gamedig
LS25 fehlte, aber nicht nur LS25: die Auswahl im Panel war eine von Hand gepflegte Liste mit neunzehn Eintraegen, und fuenf davon gab es bei gamedig gar nicht — "arkse", "ark", "sevendaystodie", "minecraftbe" und "terraria". Wer eines davon auswaehlte, bekam einen Server, der dauerhaft offline stand, ohne dass irgendwo ein Grund stand. Die echten IDs heissen ase, sdtd, mbe und terrariatshock. Also nicht LS25 nachtragen, sondern die Liste abschaffen: sie kommt jetzt aus gamedig selbst, 360 Eintraege, sortiert, mit Suche. Jedes Update bringt neue Spiele automatisch mit. Dazu der Standard-Query-Port als Vorschlag, sobald man ein Spiel waehlt. Bestehende Eintraege mit den toten IDs werden beim Start einmalig umgebogen. Farming Simulator hat noch eine zweite Huerde: der Server antwortet nur mit Zugangs-Code, und den hat queryServer nie durchgereicht. LS25 waere also auch mit richtiger ID offline geblieben. Jetzt gibt es eine Spalte dafuer, das Feld erscheint nur bei Spielen, die einen brauchen (Farming Simulator, Terraria — bei Satisfactory optional), und Speichern ohne Code wird abgelehnt statt stillschweigend hingenommen. Der Code steht nicht in der oeffentlichen API. Nebenbei aufgefallen: input[type=password] war im CSS nirgends erfasst. Das erste solche Feld kam voellig ungestylt daher. Die /server-Seite dazu: - Auslastungs-Balken mit eigener Farbe statt des XP-Balkens vom Level-System. Gruen frei, gelb ab 80 Prozent, rot voll — die Zahl steht rechtsbuendig, mittig laege sie bei 50 Prozent genau auf der Fuellkante. - Map, Version und Ping als Chips. Vorher standen sie mit Punkten getrennt in der Statuszeile und wurden auf dem Handy zu Brei. - Wer gerade drauf ist. Die Namen holt der Monitor ohnehin schon fuers Embed, auf der Seite standen sie nur nirgends. - Welches Spiel es ueberhaupt ist. Das stand nirgends ausser im Emoji. - Offline-Karten waren bisher leer bis auf das Wort. Jetzt steht dort, seit wann — soweit der Verlauf reicht, sonst gar nichts statt einer Schaetzung. - Zeitachse und Peak-Linie am Verlauf. Eine Flaeche ohne Bezugspunkt sagt nicht, wie hoch hoch ist. - Plaetze insgesamt in der Uebersicht oben. Geprueft: Token speichern und aendern, Spielnamen inklusive unbekannter ID, Token-Bedarf pro Protokoll, Liste ohne Doppler, alle Icon-Schluessel gegen gamedig, "zuletzt online" ignoriert Offline-Proben, und die Migration mit einer Datenbank im alten Stand. Beide Seiten am laufenden Frontend angesehen, auch auf Handybreite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -100,7 +100,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
|
||||
|
||||
| Funktion | Beschreibung |
|
||||
|---|---|
|
||||
| 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Spieler-Liste, Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts mit Ausfalldauer |
|
||||
| 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Version, Spieler-Liste, Connect-Link, 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 |
|
||||
| 🚨 **Wächter** | Prüft eigene Dienste in einstellbarem Takt, DM-Alarm bei Ausfall + Entwarnung; speist die öffentliche Statusseite |
|
||||
| 💓 **Herzschlag** | Der Bot zeichnet sich selbst auf — im Dashboard steht neben der Laufzeit ein Erreichbarkeits-Balken über 7 Tage. Fehlende Stunden sind rot: dass nichts dasteht, *ist* die Aussage |
|
||||
| 🔊 **Temp-Voice** | „Join to Create": Hub-Kanal betreten → eigener Voice-Kanal mit Control-Panel (Umbenennen, Sperren, Limit, Löschen — nur für den Besitzer), löscht sich wenn leer |
|
||||
@@ -137,7 +137,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
|
||||
| `/` | öffentlich | Startseite: Hero, Live-Zahlen (Member/Server/Spieler), Bereichs-Kacheln, neuestes Devlog |
|
||||
| `/devlogs` | öffentlich | Devlog-Archiv: Timeline, Bildergalerien, Projekt-Chips, **Volltextsuche**; `/devlogs/:id` als teilbarer Permalink |
|
||||
| `/roadmap` | öffentlich | Meilensteine aus Gitea mit Fortschrittsbalken, Community-Wünsche, Contribution-Heatmap |
|
||||
| `/server` | öffentlich | Live-Status aller Game-Server: Logo, Spieler-Balken, 24h-Verlauf, Uptime & Peak, Copy-Adresse |
|
||||
| `/server` | öffentlich | Live-Status aller Game-Server: Logo, Auslastungs-Balken, Map/Version/Ping, Wer gerade drauf ist, 24h-Verlauf, Uptime & Peak, Copy-Adresse |
|
||||
| `/status` | öffentlich | Betriebsstatus: Lage-Banner, Dienste mit 90-Tage-Balken, Game-Server, Störungs-Historie |
|
||||
| `/galerie` | öffentlich | Community-Screenshots aus dem Discord |
|
||||
| `/level` | öffentlich | XP-Bestenliste + Server-Aktivitäts-Chart |
|
||||
@@ -508,7 +508,7 @@ d4rkbot/
|
||||
│ │ ├── linked-roles.js # Kennzahlen an Discord melden
|
||||
│ │ ├── giveaways.js # Ziehung + Reroll
|
||||
│ │ ├── birthdays.js # Gratulation + Tages-Rolle
|
||||
│ │ ├── server-monitor.js # Game-Server (gamedig, 300+ Spiele)
|
||||
│ │ ├── server-monitor.js # Game-Server (gamedig — Spielliste, Icons, Abfrage)
|
||||
│ │ ├── watchdog.js # URL-Checks, Störungen, DM-Alarm
|
||||
│ │ ├── heartbeat.js # Eigene Erreichbarkeit aufzeichnen
|
||||
│ │ ├── wishes.js # Ideen: einreichen, Thread, Forum-Tags
|
||||
|
||||
@@ -16,6 +16,7 @@ Alles, was zu lang für die [README](../README.md) ist.
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| [konzept-zwei-seiten.md](konzept-zwei-seiten.md) | Warum Produktseite und Community-Hub getrennt sind, und wie |
|
||||
| [idee-mermaid.md](idee-mermaid.md) | Diagramme im eigenen Markdown rendern — Umfang, Kosten, offene Frage |
|
||||
|
||||
## Textentwürfe
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Mermaid-Diagramme im eigenen Markdown rendern
|
||||
|
||||
## Worum es geht
|
||||
|
||||
Diagramme (Produktionsketten, Abläufe, Zustandsgraphen) sollen auf den eigenen
|
||||
Seiten erscheinen — nicht nur als Bild, sondern als Text, der mit dem Rest
|
||||
mitläuft und diffbar bleibt.
|
||||
|
||||
Gitea 1.26.4 rendert Mermaid bereits, die Spiel-Doku braucht also nichts.
|
||||
Was fehlt, ist der Weg nach draußen: auf den Hub, ins Devlog, auf eine
|
||||
öffentliche Seite. Das kann kein externes Werkzeug übernehmen.
|
||||
|
||||
**Ausdrücklich nicht Teil davon:** ein eigener Editor. mermaid.live ist vom
|
||||
selben Team, MIT-lizenziert, kostenlos und ohne Konto. Ihn nachzubauen bringt
|
||||
nichts außer Wartung. Geschrieben wird dort, gerendert wird hier.
|
||||
|
||||
## Was zu tun ist
|
||||
|
||||
1. **Code-Blöcke im Mini-Markdown.** `frontend/src/markdown.jsx` kennt heute
|
||||
nur `` `inline code` `` — eingezäunte Blöcke (` ``` `) gar nicht. Das ist die
|
||||
Voraussetzung für alles Weitere und für sich schon eine Lücke.
|
||||
2. **```mermaid abzweigen** und an einen eigenen Renderer geben.
|
||||
3. **Bibliothek per `import()` nachladen.** Mermaid ist als npm-Paket 83 MB
|
||||
entpackt; im Browser landet weniger, es bliebe aber der größte Brocken. Das
|
||||
Frontend ist im Ganzen gerade ~540 kB — das darf nur zahlen, wer eine Seite
|
||||
mit Diagramm öffnet.
|
||||
4. **Fehlerfall bedenken:** kaputte Syntax darf die Seite nicht killen, sondern
|
||||
zeigt den Quelltext als Code-Block.
|
||||
5. **Beide Themes.** Mermaid bringt eigene Farben mit; die müssen an die
|
||||
CSS-Variablen der Seite.
|
||||
|
||||
## Wo es wirkt
|
||||
|
||||
`markdown.jsx` hängt an neun Stellen: Changelog, DevlogDetail, Devlogs, Events,
|
||||
Home, Roadmap, Seite (freier Seiten-Editor), Settings und dem Composer-Vorschau.
|
||||
Der eigentliche Anwendungsfall ist der **Seiten-Editor** — dort entsteht der
|
||||
Text, in dem ein Diagramm Sinn ergibt.
|
||||
|
||||
## Aufwand
|
||||
|
||||
Ein Nachmittag. Kein Projekt.
|
||||
|
||||
## Offen
|
||||
|
||||
Lohnt sich das Megabyte? Nur, wenn Diagramme wirklich auf öffentliche Seiten
|
||||
sollen. Reine Eigen-Doku ist mit Gitea schon abgedeckt.
|
||||
@@ -177,6 +177,18 @@ export default {
|
||||
'server.checkedAt': 'Zuletzt geprüft: %s Uhr',
|
||||
'server.serversOnline': 'Server online',
|
||||
'server.playersNow': 'Spieler gerade drauf',
|
||||
'server.slots': 'Plätze insgesamt',
|
||||
'server.hours24': 'vor 24 h',
|
||||
'server.free': 'frei',
|
||||
'server.full': 'voll',
|
||||
'server.whoIsOn': 'Wer gerade drauf ist',
|
||||
'server.andMore': '+%s weitere',
|
||||
'server.lastOnline': 'Zuletzt online: %s',
|
||||
'server.agoMin': 'vor %s min',
|
||||
'server.agoHours': 'vor %s Std.',
|
||||
'server.agoDays': 'vor %s Tagen',
|
||||
'server.neverOnline': 'Seit Beginn der Messung nicht erreichbar.',
|
||||
'server.version': 'Version',
|
||||
|
||||
// ── Galerie ───────────────────────────────────────
|
||||
'gallery.tag': '// Community',
|
||||
|
||||
@@ -176,6 +176,18 @@ export default {
|
||||
'server.checkedAt': 'Last checked: %s',
|
||||
'server.serversOnline': 'servers online',
|
||||
'server.playersNow': 'players right now',
|
||||
'server.slots': 'slots in total',
|
||||
'server.hours24': '24 h ago',
|
||||
'server.free': 'free',
|
||||
'server.full': 'full',
|
||||
'server.whoIsOn': 'Who is playing',
|
||||
'server.andMore': '+%s more',
|
||||
'server.lastOnline': 'Last online: %s',
|
||||
'server.agoMin': '%s min ago',
|
||||
'server.agoHours': '%s h ago',
|
||||
'server.agoDays': '%s days ago',
|
||||
'server.neverOnline': 'Not reachable since measuring began.',
|
||||
'server.version': 'Version',
|
||||
|
||||
// ── Gallery ───────────────────────────────────────
|
||||
'gallery.tag': '// Community',
|
||||
|
||||
@@ -4,23 +4,39 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiGet } from '../api.js';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import { IconGame, IconMapPin, IconClock, IconCheck } from '../icons.jsx';
|
||||
import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap } from '../icons.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
import UptimeTrack from '../components/UptimeTrack.jsx';
|
||||
|
||||
const UHRZEIT = { hour: '2-digit', minute: '2-digit' };
|
||||
|
||||
/** 24h-Verlauf als SVG-Fläche; Offline-Samples als rote Punkte auf der Nulllinie */
|
||||
/** "vor 3 Std." — grob genug, dass es nicht jede Minute anders aussieht */
|
||||
function vorWieLange(iso, t) {
|
||||
// SQLite liefert "YYYY-MM-DD HH:MM:SS" ohne Zeitzone; das ist UTC.
|
||||
const ts = Date.parse(iso.includes('T') ? iso : `${iso.replace(' ', 'T')}Z`);
|
||||
if (Number.isNaN(ts)) return null;
|
||||
const min = Math.max(1, Math.round((Date.now() - ts) / 60000));
|
||||
if (min < 60) return t('server.agoMin', min);
|
||||
const std = Math.round(min / 60);
|
||||
if (std < 48) return t('server.agoHours', std);
|
||||
return t('server.agoDays', Math.round(std / 24));
|
||||
}
|
||||
|
||||
/** 24h-Verlauf als SVG-Fläche; Offline-Samples als rote Punkte auf der Nulllinie.
|
||||
* Der Peak bekommt eine eigene Linie — ohne die ist eine Fläche ohne Achse nur
|
||||
* Dekoration und man kann nicht sagen, wie hoch „hoch" eigentlich ist. */
|
||||
function HistoryChart({ history, max }) {
|
||||
if (history.length < 2) return null;
|
||||
const W = 600, H = 90, PAD = 4;
|
||||
const peak = Math.max(max ?? 0, 1, ...history.map((h) => h.players ?? 0));
|
||||
const W = 600, H = 100, PAD = 4;
|
||||
const spitze = Math.max(0, ...history.map((h) => (h.online ? h.players ?? 0 : 0)));
|
||||
const peak = Math.max(max ?? 0, 1, spitze);
|
||||
const x = (i) => PAD + (i / (history.length - 1)) * (W - PAD * 2);
|
||||
const y = (p) => H - PAD - ((p ?? 0) / peak) * (H - PAD * 2);
|
||||
|
||||
const points = history.map((h, i) => `${x(i).toFixed(1)},${y(h.online ? h.players : 0).toFixed(1)}`);
|
||||
const area = `${PAD},${H - PAD} ${points.join(' ')} ${W - PAD},${H - PAD}`;
|
||||
const offline = history.map((h, i) => (h.online ? null : i)).filter((i) => i !== null);
|
||||
const spitzeY = y(spitze);
|
||||
|
||||
return (
|
||||
<svg className="srv-chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
|
||||
@@ -30,6 +46,13 @@ function HistoryChart({ history, max }) {
|
||||
<stop offset="100%" stopColor="var(--neon)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Peak-Linie: der Bezugspunkt für alles darunter */}
|
||||
{spitze > 0 && (
|
||||
<line
|
||||
className="srv-chart-peak" x1={PAD} x2={W - PAD} y1={spitzeY} y2={spitzeY}
|
||||
strokeDasharray="3 5"
|
||||
/>
|
||||
)}
|
||||
<polygon points={area} fill="url(#srvFill)" />
|
||||
<polyline points={points.join(' ')} fill="none" stroke="var(--neon)" strokeWidth="1.5" />
|
||||
{offline.map((i) => (
|
||||
@@ -49,8 +72,14 @@ function ServerCard({ s }) {
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [alleSpieler, setAlleSpieler] = useState(false);
|
||||
const pct = s.online && s.max ? Math.round((s.players / s.max) * 100) : 0;
|
||||
const stats = historyStats(s.history);
|
||||
// Ab 80 % wird es eng, ab 100 % kommt keiner mehr rein — das soll man
|
||||
// sehen, ohne den Balken mit der Zahl daneben zu vergleichen.
|
||||
const fuellung = pct >= 100 ? 'voll' : pct >= 80 ? 'eng' : 'frei';
|
||||
const namen = s.spielerNamen ?? [];
|
||||
const gezeigt = alleSpieler ? namen : namen.slice(0, 8);
|
||||
|
||||
async function copyAddress() {
|
||||
try {
|
||||
@@ -72,10 +101,9 @@ function ServerCard({ s }) {
|
||||
{s.online === null
|
||||
? <><IconClock size={13} /> {t('server.waiting')}</>
|
||||
: <><span className="srv-dot" /> {s.online ? t('server.online') : t('server.offline')}</>}
|
||||
{s.ping != null && s.online && <span className="srv-ping"> · {s.ping}ms</span>}
|
||||
{s.map && s.online && (
|
||||
<span className="srv-map"> · <IconMapPin size={13} /> {s.map}</span>
|
||||
)}
|
||||
{/* Das Spiel gehört zum Server, nicht zum Zustand — es
|
||||
steht auch dann noch da, wenn nichts antwortet. */}
|
||||
{s.spiel && <span className="srv-spiel">{s.spiel}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{s.online && s.players != null && (
|
||||
@@ -87,8 +115,41 @@ function ServerCard({ s }) {
|
||||
</div>
|
||||
|
||||
{s.online && s.players != null && s.max > 0 && (
|
||||
<div className="xp-bar srv-bar">
|
||||
<div className="xp-fill" style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
<div className={`srv-cap ${fuellung}`}>
|
||||
<div className="srv-cap-fill" style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
<span className="srv-cap-pct">{pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Was der Server über sich verrät — nur, was auch da ist */}
|
||||
{s.online && (s.map || s.ping != null || s.version) && (
|
||||
<div className="srv-chips">
|
||||
{s.map && <span className="srv-chip"><IconMapPin size={12} /> {s.map}</span>}
|
||||
{s.version && <span className="srv-chip"><IconPackage size={12} /> {s.version}</span>}
|
||||
{s.ping != null && <span className="srv-chip"><IconZap size={12} /> {s.ping} ms</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Offline: die eine Frage, die dann zählt */}
|
||||
{s.online === false && (
|
||||
<p className="srv-weg">
|
||||
{s.zuletztOnline
|
||||
? t('server.lastOnline', vorWieLange(s.zuletztOnline, t) ?? '—')
|
||||
: t('server.neverOnline')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{namen.length > 0 && (
|
||||
<div className="srv-spieler">
|
||||
<span className="srv-spieler-kopf"><IconTeam size={13} /> {t('server.whoIsOn')}</span>
|
||||
<div className="srv-namen">
|
||||
{gezeigt.map((n) => <span className="srv-name-chip" key={n}>{n}</span>)}
|
||||
{!alleSpieler && namen.length > gezeigt.length && (
|
||||
<button className="srv-mehr" onClick={() => setAlleSpieler(true)}>
|
||||
{t('server.andMore', namen.length - gezeigt.length)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -101,6 +162,12 @@ function ServerCard({ s }) {
|
||||
<span>{t('server.peak')} <b>{stats.peak}</b></span>
|
||||
</div>
|
||||
)}
|
||||
{s.history.length > 1 && (
|
||||
<div className="srv-achse">
|
||||
<span>{t('server.hours24')}</span>
|
||||
<span>{t('server.now')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UptimeTrack buckets={s.uptime} />
|
||||
|
||||
@@ -140,6 +207,7 @@ export default function Server() {
|
||||
|
||||
const online = servers?.filter((s) => s.online) ?? [];
|
||||
const totalPlayers = online.reduce((sum, s) => sum + (s.players ?? 0), 0);
|
||||
const totalSlots = online.reduce((sum, s) => sum + (s.max ?? 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -168,6 +236,14 @@ export default function Server() {
|
||||
<span className="srv-sum-n">{totalPlayers}</span>
|
||||
<span className="srv-sum-label">{t('server.playersNow')}</span>
|
||||
</div>
|
||||
{/* Nur wenn überhaupt jemand eine Obergrenze meldet —
|
||||
ein HTTP-Check hat keine Plätze. */}
|
||||
{totalSlots > 0 && (
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">{totalSlots}</span>
|
||||
<span className="srv-sum-label">{t('server.slots')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -161,9 +161,20 @@ const TEAM_SCOPE_LABELS = {
|
||||
};
|
||||
|
||||
/** Sekunden → "2d 4h 13m" */
|
||||
// Was ohne Suche oben steht. Nur eine Startauswahl — gesucht wird über alle
|
||||
// Spiele, die gamedig kennt. Die IDs hier sind echte gamedig-IDs; wer eine
|
||||
// ergänzt, prüft sie gegen /api/gamedig-games und nicht gegen sein Gedächtnis.
|
||||
const BELIEBTE_SPIELE = [
|
||||
'fivem', 'http', 'minecraft', 'mbe', 'rust', 'valheim', 'palworld',
|
||||
'farmingsimulator25', 'projectzomboid', 'sdtd', 'ase', 'garrysmod',
|
||||
'counterstrike2', 'arma3', 'satisfactory', 'unturned',
|
||||
];
|
||||
const GAMESEL_MAX = 60;
|
||||
|
||||
/** Combobox für die Spiel-Auswahl — datalist ist nicht stylebar, das hier schon.
|
||||
* Filtert die Presets beim Tippen, freie gamedig-IDs bleiben erlaubt. */
|
||||
function GameSelect({ presets, value, onChange }) {
|
||||
* Die Liste kommt aus gamedig selbst, damit sie mit jedem Update mitwächst;
|
||||
* freie IDs bleiben trotzdem erlaubt. */
|
||||
function GameSelect({ games, value, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
@@ -176,39 +187,56 @@ function GameSelect({ presets, value, onChange }) {
|
||||
}, []);
|
||||
|
||||
const q = value.trim().toLowerCase();
|
||||
const filtered = presets.filter(([id, label]) =>
|
||||
!q || id.includes(q) || label.toLowerCase().includes(q)
|
||||
);
|
||||
// Ohne Eingabe die Startauswahl, sonst alles durchsuchen. 360 Einträge auf
|
||||
// einmal wären keine Auswahl mehr, sondern eine Wand.
|
||||
const treffer = q
|
||||
? games.filter((g) => g.id.includes(q) || g.name.toLowerCase().includes(q))
|
||||
: BELIEBTE_SPIELE.map((id) => games.find((g) => g.id === id)).filter(Boolean);
|
||||
const gezeigt = treffer.slice(0, GAMESEL_MAX);
|
||||
const rest = treffer.length - gezeigt.length;
|
||||
|
||||
return (
|
||||
<div className="gamesel" ref={wrapRef} style={{ flex: 'none', width: '13rem' }}>
|
||||
<input
|
||||
type="text" placeholder="Spiel (gamedig-ID)"
|
||||
title="Vorschläge oder beliebige gamedig-Spiel-ID"
|
||||
type="text" placeholder="Spiel suchen …"
|
||||
title="Name oder gamedig-ID — es geht auch jede ID, die nicht in der Liste steht"
|
||||
value={value}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') setOpen(false); }}
|
||||
/>
|
||||
<span className="gamesel-caret" onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}>▾</span>
|
||||
{open && filtered.length > 0 && (
|
||||
{open && gezeigt.length > 0 && (
|
||||
<div className="gamesel-list">
|
||||
{filtered.map(([id, label]) => (
|
||||
{gezeigt.map((g) => (
|
||||
<button
|
||||
type="button" key={id}
|
||||
className={`gamesel-item${id === value ? ' active' : ''}`}
|
||||
onMouseDown={(e) => { e.preventDefault(); onChange(id); setOpen(false); }}
|
||||
type="button" key={g.id}
|
||||
className={`gamesel-item${g.id === value ? ' active' : ''}`}
|
||||
onMouseDown={(e) => { e.preventDefault(); onChange(g.id); setOpen(false); }}
|
||||
>
|
||||
<span className="gamesel-label">{label}</span>
|
||||
<span className="gamesel-id">{id}</span>
|
||||
<span className="gamesel-label">
|
||||
{GAME_EMOJI[g.id] ?? (g.eigen ? '🔧' : '🎮')} {g.name}
|
||||
{g.token && <span className="gamesel-key" title="braucht einen Zugangs-Code">🔑</span>}
|
||||
</span>
|
||||
<span className="gamesel-id">{g.id}</span>
|
||||
</button>
|
||||
))}
|
||||
{rest > 0 && <div className="gamesel-rest">… und {rest} weitere — weitertippen</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nur Deko in der Auswahlliste; die Seite holt sich ihr Icon aus der API.
|
||||
const GAME_EMOJI = {
|
||||
fivem: '🚗', http: '🌐', minecraft: '⛏️', mbe: '⛏️', rust: '🪓', valheim: '🛡️',
|
||||
palworld: '🐑', farmingsimulator19: '🚜', farmingsimulator22: '🚜', farmingsimulator25: '🚜',
|
||||
projectzomboid: '🧟', sdtd: '🧟', dayz: '🧟', ase: '🦖', asa: '🦖',
|
||||
garrysmod: '🔧', counterstrike2: '🔫', csgo: '🔫', arma3: '🪖', squad: '🪖',
|
||||
satisfactory: '🏭', unturned: '🏝️', terrariatshock: '🌳', teamfortress2: '🎩',
|
||||
};
|
||||
|
||||
/**
|
||||
* Durchsuchbare Auswahl für Kanäle und Rollen — dieselbe Bedienung wie bei den
|
||||
* gamedig-IDs. Ein natives <select> lässt sich nicht durchsuchen; bei vielen
|
||||
@@ -340,19 +368,10 @@ export default function Settings({ me }) {
|
||||
// Triggers
|
||||
const [triggers, setTriggers] = useState([]);
|
||||
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
|
||||
// Game-Server
|
||||
const GAME_PRESETS = [
|
||||
['fivem', '🚗 FiveM'], ['http', '🌐 HTTP-Check'], ['minecraft', '⛏️ Minecraft (Java)'],
|
||||
['minecraftbe', '⛏️ Minecraft (Bedrock)'], ['rust', '🪓 Rust'], ['valheim', '🛡️ Valheim'],
|
||||
['palworld', '🐑 Palworld'], ['arkse', '🦖 ARK: Survival Evolved'], ['dayz', '🧟 DayZ'],
|
||||
['projectzomboid', '🧟 Project Zomboid'], ['sevendaystodie', '🧟 7 Days to Die'],
|
||||
['teamfortress2', '🎩 Team Fortress 2'], ['counterstrike2', '🔫 Counter-Strike 2'],
|
||||
['garrysmod', '🔧 Garry’s Mod'], ['satisfactory', '🏭 Satisfactory'],
|
||||
['terraria', '🌳 Terraria'], ['arma3', '🪖 Arma 3'], ['squad', '🪖 Squad'],
|
||||
['unturned', '🏝️ Unturned'],
|
||||
];
|
||||
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '', image_url: '' };
|
||||
// Game-Server — die Spielliste kommt aus gamedig (siehe /api/gamedig-games)
|
||||
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '', image_url: '', token: '' };
|
||||
const [servers, setServers] = useState([]);
|
||||
const [games, setGames] = useState([]);
|
||||
const [serverDraft, setServerDraft] = useState(emptyServer);
|
||||
// Bot-Beschreibung + Team
|
||||
const [botDesc, setBotDesc] = useState('');
|
||||
@@ -489,6 +508,7 @@ export default function Settings({ me }) {
|
||||
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
|
||||
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
||||
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
||||
apiGet('/api/gamedig-games').then((d) => setGames(d.games ?? [])).catch(() => {});
|
||||
apiGet('/api/webadmins')
|
||||
.then((d) => { setTeam(d.admins); setOwner(d.owner); })
|
||||
.catch(() => {});
|
||||
@@ -811,11 +831,26 @@ export default function Settings({ me }) {
|
||||
}
|
||||
|
||||
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
|
||||
const gewaehltesSpiel = games.find((g) => g.id === serverDraft.type) ?? null;
|
||||
/** Spiel gewählt: Standard-Port vorschlagen, solange keiner drinsteht */
|
||||
function spielWaehlen(type) {
|
||||
const spiel = games.find((g) => g.id === type);
|
||||
setServerDraft((d) => ({
|
||||
...d, type,
|
||||
port: d.port || (spiel && !spiel.eigen ? String(spiel.port ?? '') : d.port),
|
||||
}));
|
||||
}
|
||||
async function saveServer() {
|
||||
if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) {
|
||||
flash(serverNeedsUrl ? '✗ Name und Query-URL nötig' : '✗ Name und Host nötig');
|
||||
return;
|
||||
}
|
||||
// Ohne Code antwortet so ein Server gar nicht — er stünde dauerhaft auf
|
||||
// "offline", ohne dass irgendwo der Grund stünde. Lieber hier stoppen.
|
||||
if (gewaehltesSpiel?.token === 'pflicht' && !serverDraft.token.trim()) {
|
||||
flash(`✗ ${gewaehltesSpiel.name} antwortet nur mit Zugangs-Code`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (serverDraft.id) await apiPut(`/api/gameservers/${serverDraft.id}`, serverDraft);
|
||||
else await apiPost('/api/gameservers', serverDraft);
|
||||
@@ -3265,12 +3300,18 @@ export default function Settings({ me }) {
|
||||
{servers.map((s) => (
|
||||
<div className="key-row" key={s.id}>
|
||||
<span className="key-name">{s.name}</span>
|
||||
<span className="key-scopes">{s.type}</span>
|
||||
<span className="key-used" title={s.query_url}>{s.query_url}</span>
|
||||
<span className="key-scopes">
|
||||
{games.find((g) => g.id === s.type)?.name ?? s.type}
|
||||
{s.token ? ' 🔑' : ''}
|
||||
</span>
|
||||
{/* gamedig-Server haben keine URL, sondern Host + Port */}
|
||||
<span className="key-used" title={s.type}>
|
||||
{s.query_url || [s.host, s.port].filter(Boolean).join(':')}
|
||||
</span>
|
||||
<button className="btn btn-mini" onClick={() => setServerDraft({
|
||||
id: s.id, name: s.name, type: s.type, query_url: s.query_url ?? '',
|
||||
address: s.address ?? '', host: s.host ?? '', port: s.port ?? '', connect_url: s.connect_url ?? '',
|
||||
image_url: s.image_url ?? '',
|
||||
image_url: s.image_url ?? '', token: s.token ?? '',
|
||||
})}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}>✕</button>
|
||||
</div>
|
||||
@@ -3283,23 +3324,45 @@ export default function Settings({ me }) {
|
||||
<div className="field-row">
|
||||
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Name, z. B. FiveM" value={serverDraft.name} onChange={(e) => setServerDraft({ ...serverDraft, name: e.target.value })} />
|
||||
<GameSelect
|
||||
presets={GAME_PRESETS}
|
||||
games={games}
|
||||
value={serverDraft.type}
|
||||
onChange={(type) => setServerDraft({ ...serverDraft, type })}
|
||||
onChange={spielWaehlen}
|
||||
/>
|
||||
{serverNeedsUrl ? (
|
||||
<input type="text" placeholder={serverDraft.type === 'fivem' ? 'http://ip:30120' : 'https://dienst.example/health'} value={serverDraft.query_url} onChange={(e) => setServerDraft({ ...serverDraft, query_url: e.target.value })} />
|
||||
) : (
|
||||
<>
|
||||
<input type="text" placeholder="Host / IP" value={serverDraft.host} onChange={(e) => setServerDraft({ ...serverDraft, host: e.target.value })} />
|
||||
<input type="text" style={{ flex: 'none', width: '6rem' }} placeholder="Port" value={serverDraft.port} onChange={(e) => setServerDraft({ ...serverDraft, port: e.target.value })} />
|
||||
<input type="text" style={{ flex: 'none', width: '6rem' }} placeholder={gewaehltesSpiel?.port ? `Port ${gewaehltesSpiel.port}` : 'Port'} value={serverDraft.port} onChange={(e) => setServerDraft({ ...serverDraft, port: e.target.value })} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="field-hint" style={{ marginTop: '.3rem' }}>
|
||||
Neben FiveM/HTTP funktioniert jede <a href="https://github.com/gamedig/node-gamedig#games-list" target="_blank" rel="noreferrer" style={{ textDecoration: 'underline' }}>gamedig-Spiel-ID</a> (300+ Spiele) — Host + Query-Port eintragen.
|
||||
{gewaehltesSpiel
|
||||
? <>Gewählt: <b>{gewaehltesSpiel.name}</b>{!gewaehltesSpiel.eigen && gewaehltesSpiel.port ? ` — Standard-Query-Port ${gewaehltesSpiel.port}` : ''}. Die Liste kommt direkt aus gamedig, {games.length} Einträge.</>
|
||||
: <>Unbekannte ID — sie wird trotzdem versucht. Alle bekannten stehen in der <a href="https://github.com/gamedig/node-gamedig/blob/master/GAMES_LIST.md" target="_blank" rel="noreferrer" style={{ textDecoration: 'underline' }}>gamedig-Liste</a>.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nur bei Spielen, die ohne Code gar nicht erst antworten */}
|
||||
{gewaehltesSpiel?.token && (
|
||||
<div className="field">
|
||||
<label>
|
||||
Zugangs-Code {gewaehltesSpiel.token === 'optional' && <span className="field-hint">optional</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password" autoComplete="off"
|
||||
placeholder={gewaehltesSpiel.token === 'pflicht' ? 'Pflicht für dieses Spiel' : 'leer lassen, wenn nicht nötig'}
|
||||
value={serverDraft.token}
|
||||
onChange={(e) => setServerDraft({ ...serverDraft, token: e.target.value })}
|
||||
/>
|
||||
<p className="field-hint" style={{ marginTop: '.3rem' }}>
|
||||
{gewaehltesSpiel.id.startsWith('farmingsimulator')
|
||||
? <>Im Spiel heißt er <b>Code</b>. Steht im Webinterface des Servers unter <code>http://host:{gewaehltesSpiel.port ?? 8080}/settings.html</code> — er gilt nur für den eigenen Server.</>
|
||||
: <>Steht im Webinterface des Servers. Ohne ihn liefert die Abfrage nichts und der Server stünde dauerhaft auf „offline".</>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>Connect-Link + Anzeige-Adresse <span className="field-hint">Link ist im Embed klickbar (auch steam:// / fivem://); Adresse als Copy-Paste-Block</span></label>
|
||||
<div className="field-row">
|
||||
|
||||
+88
-8
@@ -1063,7 +1063,7 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
}
|
||||
.field textarea:focus { border-color: var(--neon); }
|
||||
|
||||
.field select, .field input[type="text"] {
|
||||
.field select, .field input[type="text"], .field input[type="password"] {
|
||||
width: 100%;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid rgba(255, 255, 255, .12);
|
||||
@@ -1074,9 +1074,9 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
outline: none;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.field select:focus, .field input[type="text"]:focus { border-color: var(--neon); }
|
||||
.field select:focus, .field input[type="text"]:focus, .field input[type="password"]:focus { border-color: var(--neon); }
|
||||
/* Auf breiten Schirmen sollen Eingabefelder nicht über die halbe Seite laufen */
|
||||
.field select, .field input[type="text"], .field input[type="number"] { max-width: 44rem; }
|
||||
.field select, .field input[type="text"], .field input[type="password"], .field input[type="number"] { max-width: 44rem; }
|
||||
.field select option { background: #0d0d0d; }
|
||||
|
||||
/* Schalter links, Beschriftung und Hinweis rechts gestapelt — vorher klebten
|
||||
@@ -1242,7 +1242,7 @@ label.toggle {
|
||||
.settings-section + .settings-section,
|
||||
.settings-content > .settings-section { margin-bottom: 1.25rem; }
|
||||
|
||||
.btn, .btn-login, .field select, .field input[type="text"], .field textarea,
|
||||
.btn, .btn-login, .field select, .field input[type="text"], .field input[type="password"], .field textarea,
|
||||
.search, .user-chip, .feedback-float, .sha, .card-project, .card-delete,
|
||||
.commits th:first-child, .commits th:last-child {
|
||||
border-radius: 8px;
|
||||
@@ -1257,14 +1257,14 @@ label.toggle {
|
||||
.card-images.n1 a { border-radius: 10px; }
|
||||
|
||||
/* Inputs: ruhigerer Grundzustand, klarer Fokus */
|
||||
.field select, .field input[type="text"], .field textarea {
|
||||
.field select, .field input[type="text"], .field input[type="password"], .field textarea {
|
||||
background: rgba(0, 0, 0, .35);
|
||||
border-color: rgba(255, 255, 255, .09);
|
||||
}
|
||||
.field select:hover, .field input[type="text"]:hover, .field textarea:hover {
|
||||
.field select:hover, .field input[type="text"]:hover, .field input[type="password"]:hover, .field textarea:hover {
|
||||
border-color: rgba(255, 255, 255, .2);
|
||||
}
|
||||
.field select:focus, .field input[type="text"]:focus, .field textarea:focus {
|
||||
.field select:focus, .field input[type="text"]:focus, .field input[type="password"]:focus, .field textarea:focus {
|
||||
border-color: var(--neon);
|
||||
box-shadow: 0 0 0 3px rgba(245, 197, 24, .12);
|
||||
}
|
||||
@@ -1539,6 +1539,15 @@ label.toggle {
|
||||
.gamesel-item:hover, .gamesel-item.active { background: var(--bg3); color: var(--neon); }
|
||||
.gamesel-id { color: var(--muted2); font-family: var(--mono); font-size: .75rem; }
|
||||
.gamesel-item:hover .gamesel-id { color: var(--muted); }
|
||||
.gamesel-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Schluessel = das Spiel antwortet nur mit Zugangs-Code */
|
||||
.gamesel-key { margin-left: .35rem; font-size: .8em; }
|
||||
/* Die Liste hat ueber 350 Eintraege — wer nichts findet, soll wissen, dass
|
||||
noch mehr da ist, statt die Auswahl fuer vollstaendig zu halten. */
|
||||
.gamesel-rest {
|
||||
padding: .4rem .7rem; border-top: 1px solid var(--border);
|
||||
font-family: var(--mono); font-size: .68rem; color: var(--muted2);
|
||||
}
|
||||
|
||||
/* Durchsuchbare Kanal-/Rollen-Auswahl (ersetzt die nativen Dropdowns) */
|
||||
.picksel { position: relative; max-width: 44rem; }
|
||||
@@ -1787,10 +1796,81 @@ label.toggle {
|
||||
.srv-status.on { color: var(--success); }
|
||||
.srv-status.off { color: #f23f43; }
|
||||
.srv-ping, .srv-map { color: var(--muted2); }
|
||||
/* Das Spiel gehoert zum Server, nicht zum Zustand — deshalb neutral, damit es
|
||||
die gruene/rote Statusfarbe nicht mitnimmt. Bewusst ohne Trennstrich: die
|
||||
Zeile bricht auf schmalen Schirmen um, und dann staende der Strich als
|
||||
einzelnes Zeichen am Zeilenanfang. Abstand und Farbe trennen genug. */
|
||||
.srv-spiel { color: var(--muted2); margin-left: .5rem; }
|
||||
.srv-count { font-family: var(--display); font-size: 1.9rem; line-height: 1; color: var(--neon); white-space: nowrap; }
|
||||
.srv-count-max { font-size: 1.1rem; color: var(--muted2); }
|
||||
.srv-bar { margin-top: .9rem; }
|
||||
.srv-chart { width: 100%; height: 90px; margin-top: 1rem; display: block; }
|
||||
|
||||
/* Auslastung: eigener Balken statt des XP-Balkens vom Level-System. Die Farbe
|
||||
sagt, ob noch jemand reinkommt — gruen frei, gelb eng, rot voll. */
|
||||
.srv-cap {
|
||||
position: relative; height: 22px; margin-top: .9rem;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--bg3); overflow: hidden;
|
||||
}
|
||||
.srv-cap-fill { height: 100%; transition: width .5s ease; }
|
||||
.srv-cap.frei .srv-cap-fill { background: linear-gradient(90deg, #1f7a4d, var(--success)); }
|
||||
.srv-cap.eng .srv-cap-fill { background: linear-gradient(90deg, #8a6a10, var(--neon)); }
|
||||
.srv-cap.voll .srv-cap-fill { background: linear-gradient(90deg, #8a2326, #f23f43); }
|
||||
.srv-cap-pct {
|
||||
/* Rechtsbuendig, nicht mittig: mittig laege die Zahl bei 50 % genau auf der
|
||||
Fuellkante und waere halb hell, halb dunkel unterlegt. Rechts steht sie
|
||||
fast immer auf dem ruhigen Grund. */
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: flex-end;
|
||||
padding-right: .55rem;
|
||||
font-family: var(--mono); font-size: .64rem; letter-spacing: .12em;
|
||||
color: #fff; font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, .95), 0 0 6px rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
/* Map, Version, Ping — was der Server ueber sich verraet */
|
||||
.srv-chips { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .8rem; }
|
||||
.srv-chip {
|
||||
display: inline-flex; align-items: center; gap: .3rem;
|
||||
font-family: var(--mono); font-size: .64rem; letter-spacing: .06em;
|
||||
color: var(--muted); background: var(--bg3);
|
||||
border: 1px solid var(--border); border-radius: 5px; padding: .22rem .5rem;
|
||||
}
|
||||
.srv-weg {
|
||||
font-family: var(--mono); font-size: .7rem; color: #f23f43;
|
||||
margin-top: .9rem; letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* Spielerliste — dieselben Namen, die auch im Discord-Embed stehen */
|
||||
.srv-spieler { margin-top: 1rem; }
|
||||
.srv-spieler-kopf {
|
||||
display: inline-flex; align-items: center; gap: .4rem;
|
||||
font-family: var(--mono); font-size: .62rem; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--muted2);
|
||||
}
|
||||
.srv-namen { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .5rem; }
|
||||
.srv-name-chip {
|
||||
font-family: var(--mono); font-size: .68rem; color: var(--text);
|
||||
background: var(--bg3); border: 1px solid var(--border);
|
||||
border-radius: 5px; padding: .2rem .5rem;
|
||||
max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.srv-mehr {
|
||||
font-family: var(--mono); font-size: .68rem; color: var(--neon);
|
||||
background: none; border: 1px dashed var(--border); border-radius: 5px;
|
||||
padding: .2rem .5rem; cursor: pointer;
|
||||
}
|
||||
.srv-mehr:hover { border-color: var(--neon); }
|
||||
|
||||
.srv-chart { width: 100%; height: 100px; margin-top: 1rem; display: block; }
|
||||
/* Ohne non-scaling-stroke zieht preserveAspectRatio="none" den Strich mit in
|
||||
die Breite und aus dem Strichmuster wird ein durchgezogener Balken. */
|
||||
.srv-chart-peak { stroke: var(--muted2); stroke-width: 1; opacity: .5; vector-effect: non-scaling-stroke; }
|
||||
/* Zeitachse unter dem Verlauf: ohne die weiss man nicht, welche Seite jetzt ist */
|
||||
.srv-achse {
|
||||
display: flex; justify-content: space-between;
|
||||
font-family: var(--mono); font-size: .58rem; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--muted2); margin-top: .15rem;
|
||||
}
|
||||
.srv-stats {
|
||||
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||||
font-family: var(--mono); font-size: .62rem; letter-spacing: .15em;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// pro Server ein Live-Embed (Spieler-Balken, Map, Player-Liste, Connect-Link),
|
||||
// Down/Up-Alerts mit 2-Fails-Schwelle. Verwaltet im Setup-Tab "Server".
|
||||
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
|
||||
import { GameDig } from 'gamedig';
|
||||
import { GameDig, games as gamedigGames } from 'gamedig';
|
||||
import { getSetting, listGameservers, setGameserverMessage, recordPlayerSample, prunePlayerHistory } from '../db.js';
|
||||
import { brandFooter, botStatusText } from '../runtime-settings.js';
|
||||
import { brandEmbed } from '../embeds.js';
|
||||
@@ -12,15 +12,59 @@ import { tuning, tuningMs, everyTuned } from '../tuning.js';
|
||||
const GREEN = 0x23a55a;
|
||||
const RED = 0xf23f43;
|
||||
|
||||
// Emoji pro Spieltyp (Fallback 🎮)
|
||||
// Emoji pro Spieltyp (Fallback 🎮). Die Schlüssel sind gamedig-IDs — hier
|
||||
// standen mal ausgedachte wie "arkse" oder "terraria", die es dort gar nicht
|
||||
// gibt. Beim Ändern gegen `games` aus gamedig prüfen, nicht gegen das Gefühl.
|
||||
export const GAME_ICONS = {
|
||||
fivem: '🚗', http: '🌐', minecraft: '⛏️', minecraftbe: '⛏️',
|
||||
fivem: '🚗', http: '🌐', minecraft: '⛏️', mbe: '⛏️',
|
||||
teamfortress2: '🎩', counterstrike2: '🔫', csgo: '🔫', garrysmod: '🔧',
|
||||
rust: '🪓', valheim: '🛡️', palworld: '🐑', ark: '🦖', arkse: '🦖',
|
||||
dayz: '🧟', projectzomboid: '🧟', sevendaystodie: '🧟',
|
||||
satisfactory: '🏭', terraria: '🌳', arma3: '🪖', squad: '🪖', unturned: '🏝️',
|
||||
rust: '🪓', valheim: '🛡️', palworld: '🐑', ase: '🦖', asa: '🦖', pixark: '🦖',
|
||||
dayz: '🧟', projectzomboid: '🧟', sdtd: '🧟',
|
||||
satisfactory: '🏭', terrariatshock: '🌳', arma3: '🪖', squad: '🪖', unturned: '🏝️',
|
||||
farmingsimulator19: '🚜', farmingsimulator22: '🚜', farmingsimulator25: '🚜',
|
||||
};
|
||||
|
||||
// fivem und http sind unsere eigenen Typen — gamedig kennt sie nicht.
|
||||
const EIGENE = {
|
||||
fivem: { name: 'FiveM', port: 30120 },
|
||||
http: { name: 'HTTP-Check', port: null },
|
||||
};
|
||||
|
||||
// Protokolle, die ohne Zugangs-Code gar nicht antworten. Der Code steht im
|
||||
// Webinterface des jeweiligen Servers — bei Farming Simulator zum Beispiel
|
||||
// unter http://host:8080/settings.html. Ohne ihn wirft gamedig, und der Server
|
||||
// stünde dauerhaft auf "offline", ohne dass man den Grund sähe.
|
||||
const TOKEN_PFLICHT = new Set(['farmingsimulator', 'terraria']);
|
||||
// Hier ist der Code optional: gamedig holt sich sonst selbst einen.
|
||||
const TOKEN_OPTIONAL = new Set(['satisfactory']);
|
||||
|
||||
/** Klartext-Name eines Typs — "farmingsimulator25" → "Farming Simulator 25" */
|
||||
export function spielName(type) {
|
||||
return EIGENE[type]?.name ?? gamedigGames[type]?.name ?? type;
|
||||
}
|
||||
|
||||
/** 'pflicht' | 'optional' | null — steuert das Token-Feld im Panel */
|
||||
export function tokenBedarf(type) {
|
||||
const protokoll = gamedigGames[type]?.options?.protocol;
|
||||
if (TOKEN_PFLICHT.has(protokoll)) return 'pflicht';
|
||||
if (TOKEN_OPTIONAL.has(protokoll)) return 'optional';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Alle abfragbaren Spiele für die Auswahl im Panel, nach Namen sortiert */
|
||||
export function spielListe() {
|
||||
const eigene = Object.entries(EIGENE).map(([id, e]) => ({
|
||||
id, name: e.name, port: e.port, token: null, eigen: true,
|
||||
}));
|
||||
const aus_gamedig = Object.entries(gamedigGames)
|
||||
.map(([id, g]) => ({
|
||||
id, name: g.name, port: g.options?.port ?? null,
|
||||
token: tokenBedarf(id), eigen: false,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
return [...eigene, ...aus_gamedig];
|
||||
}
|
||||
|
||||
// Alert-Zustand pro Server (in-memory)
|
||||
const alertState = new Map(); // id → { fails, down, since }
|
||||
|
||||
@@ -31,7 +75,7 @@ export const lastResults = new Map(); // id → Query-Ergebnis + checkedAt
|
||||
export async function queryServer(server) {
|
||||
const base = {
|
||||
...server, online: false, players: null, max: null, map: null,
|
||||
playerNames: [], ping: null,
|
||||
playerNames: [], ping: null, version: null,
|
||||
};
|
||||
const started = Date.now();
|
||||
try {
|
||||
@@ -62,6 +106,9 @@ export async function queryServer(server) {
|
||||
type: server.type,
|
||||
host: server.host,
|
||||
port: server.port || undefined,
|
||||
// Manche Spiele antworten nur mit Code — Farming Simulator etwa.
|
||||
// Leer weglassen, sonst schickt gamedig "token=" mit.
|
||||
token: server.token || undefined,
|
||||
maxRetries: 1,
|
||||
socketTimeout: tuningMs.seconds('monitor_timeout'),
|
||||
});
|
||||
@@ -70,6 +117,7 @@ export async function queryServer(server) {
|
||||
players: state.players?.length ?? state.numplayers ?? 0,
|
||||
max: state.maxplayers ?? null,
|
||||
map: state.map || null,
|
||||
version: state.version || null,
|
||||
playerNames: (state.players ?? []).map((p) => p?.name).filter(Boolean).slice(0, 15),
|
||||
};
|
||||
} catch {
|
||||
@@ -103,6 +151,7 @@ export function buildServerEmbed(r) {
|
||||
});
|
||||
}
|
||||
if (r.map) embed.addFields({ name: 'Map', value: r.map, inline: true });
|
||||
if (r.version) embed.addFields({ name: 'Version', value: String(r.version).slice(0, 60), inline: true });
|
||||
if (r.connect_url) {
|
||||
// steam:// & Co. sind in Embeds klickbar (Link-Buttons erlauben nur https)
|
||||
embed.addFields({ name: 'Connect', value: `[${r.connect_url}](${r.connect_url})`, inline: false });
|
||||
|
||||
@@ -922,14 +922,27 @@ db.exec(`
|
||||
if (!cols.includes('port')) db.exec('ALTER TABLE gameservers ADD COLUMN port INTEGER');
|
||||
if (!cols.includes('connect_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN connect_url TEXT NOT NULL DEFAULT ''`);
|
||||
if (!cols.includes('image_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`);
|
||||
// Zugangs-Code für Spiele, die ohne nicht antworten (Farming Simulator, Terraria)
|
||||
if (!cols.includes('token')) db.exec(`ALTER TABLE gameservers ADD COLUMN token 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,
|
||||
// ohne dass irgendwo ein Grund stand. Einmalig auf die echten IDs umbiegen.
|
||||
{
|
||||
const echteIds = {
|
||||
arkse: 'ase', ark: 'ase', sevendaystodie: 'sdtd',
|
||||
minecraftbe: 'mbe', terraria: 'terrariatshock',
|
||||
};
|
||||
const um = db.prepare('UPDATE gameservers SET type = ? WHERE type = ?');
|
||||
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)
|
||||
VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url)
|
||||
INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token)
|
||||
VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token)
|
||||
`);
|
||||
const updateGameserverStmt = db.prepare(`
|
||||
UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address,
|
||||
host = @host, port = @port, connect_url = @connect_url, image_url = @image_url
|
||||
host = @host, port = @port, connect_url = @connect_url, image_url = @image_url, token = @token
|
||||
WHERE id = @id
|
||||
`);
|
||||
const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?');
|
||||
@@ -972,8 +985,15 @@ const uptimeBucketsStmt = db.prepare(`
|
||||
WHERE server_id = ? AND ts >= datetime('now', ?)
|
||||
GROUP BY stunde ORDER BY stunde
|
||||
`);
|
||||
// Wann war der Server zuletzt erreichbar? Nur so lange beantwortbar, wie der
|
||||
// Verlauf reicht (7 Tage) — davor gibt es keine Zeile mehr, und dann steht auf
|
||||
// der Seite bewusst nichts statt einer erfundenen Zahl.
|
||||
const letztesOnlineStmt = db.prepare(
|
||||
'SELECT max(ts) AS ts FROM player_history WHERE server_id = ? AND online = 1'
|
||||
);
|
||||
export const recordPlayerSample = (serverId, online, players) =>
|
||||
insertPlayerSample.run(serverId, online ? 1 : 0, players);
|
||||
export const lastOnlineAt = (serverId) => letztesOnlineStmt.get(serverId)?.ts ?? null;
|
||||
export const playerHistory = (serverId, hours = 24) => playerHistoryStmt.all(serverId, `-${hours} hours`);
|
||||
export const uptimeBuckets = (serverId, hours = 168) => uptimeBucketsStmt.all(serverId, `-${hours} hours`);
|
||||
export const prunePlayerHistory = () => prunePlayerHistoryStmt.run();
|
||||
|
||||
+18
-2
@@ -11,7 +11,7 @@ import {
|
||||
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
|
||||
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm, clearOtherPlaytesterForms,
|
||||
saveTrigger, listTriggers, deleteTrigger, activityRange,
|
||||
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
|
||||
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver, lastOnlineAt,
|
||||
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
||||
saveTemplate, listTemplates, deleteTemplate,
|
||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||
@@ -32,7 +32,7 @@ import crypto from 'node:crypto';
|
||||
import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js';
|
||||
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 { lastResults, GAME_ICONS, spielName, spielListe } from '../bot/server-monitor.js';
|
||||
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
||||
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
||||
import {
|
||||
@@ -220,6 +220,7 @@ export function registerApiRoutes(app, client) {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
spiel: spielName(s.type),
|
||||
icon: GAME_ICONS[s.type] ?? '🎮',
|
||||
image_url: s.image_url || null,
|
||||
address: s.address || null,
|
||||
@@ -228,8 +229,13 @@ export function registerApiRoutes(app, client) {
|
||||
players: r?.players ?? null,
|
||||
max: r?.max ?? null,
|
||||
map: r?.map ?? null,
|
||||
version: r?.version ?? null,
|
||||
ping: r?.ping ?? null,
|
||||
// Namen im Spiel, so wie sie im Discord-Embed auch stehen
|
||||
spielerNamen: r?.online ? (r.playerNames ?? []) : [],
|
||||
checkedAt: r?.checkedAt ?? null,
|
||||
// Für offline Server: seit wann. Ohne Verlauf bleibt es null.
|
||||
zuletztOnline: r?.online === false ? lastOnlineAt(s.id) : null,
|
||||
history: playerHistory(s.id, 24),
|
||||
// Erreichbarkeit der letzten 7 Tage, stundenweise. Länger geht
|
||||
// nicht: prunePlayerHistory räumt alles Ältere weg.
|
||||
@@ -1569,6 +1575,7 @@ ${rssItems}
|
||||
image_url: /^https:\/\/.+/.test(String(body.image_url ?? '').trim())
|
||||
? String(body.image_url).trim().slice(0, 300)
|
||||
: '',
|
||||
token: String(body.token ?? '').trim().slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1577,6 +1584,15 @@ ${rssItems}
|
||||
return { servers: listGameservers() };
|
||||
});
|
||||
|
||||
// Alle Spiele, die gamedig abfragen kann — damit die Auswahl im Panel nicht
|
||||
// an einer von Hand gepflegten Liste hängt und jedes Update automatisch
|
||||
// neue Spiele mitbringt. Steht in der Liste auch, welcher Standard-Port
|
||||
// gilt und ob ein Zugangs-Code nötig ist.
|
||||
app.get('/api/gamedig-games', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'server')) return;
|
||||
return { games: spielListe() };
|
||||
});
|
||||
|
||||
app.post('/api/gameservers', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'server')) return;
|
||||
const server = parseServerBody(request, reply);
|
||||
|
||||
Reference in New Issue
Block a user