diff --git a/README.md b/README.md index 56f65b8..96fba0e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/README.md b/docs/README.md index 45d7010..e804b06 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/idee-mermaid.md b/docs/idee-mermaid.md new file mode 100644 index 0000000..6485442 --- /dev/null +++ b/docs/idee-mermaid.md @@ -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. diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index 44b63ba..4ceaec9 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -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', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index 2a632b5..2ac1ed7 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -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', diff --git a/frontend/src/pages/Server.jsx b/frontend/src/pages/Server.jsx index b374414..efc091a 100644 --- a/frontend/src/pages/Server.jsx +++ b/frontend/src/pages/Server.jsx @@ -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 ( {s.online ? t('server.online') : t('server.offline')}} - {s.ping != null && s.online && · {s.ping}ms} - {s.map && s.online && ( - · {s.map} - )} + {/* Das Spiel gehört zum Server, nicht zum Zustand — es + steht auch dann noch da, wenn nichts antwortet. */} + {s.spiel && {s.spiel}} {s.online && s.players != null && ( @@ -87,8 +115,41 @@ function ServerCard({ s }) { {s.online && s.players != null && s.max > 0 && ( -
-
+
+
+ {pct}% +
+ )} + + {/* Was der Server über sich verrät — nur, was auch da ist */} + {s.online && (s.map || s.ping != null || s.version) && ( +
+ {s.map && {s.map}} + {s.version && {s.version}} + {s.ping != null && {s.ping} ms} +
+ )} + + {/* Offline: die eine Frage, die dann zählt */} + {s.online === false && ( +

+ {s.zuletztOnline + ? t('server.lastOnline', vorWieLange(s.zuletztOnline, t) ?? '—') + : t('server.neverOnline')} +

+ )} + + {namen.length > 0 && ( +
+ {t('server.whoIsOn')} +
+ {gezeigt.map((n) => {n})} + {!alleSpieler && namen.length > gezeigt.length && ( + + )} +
)} @@ -101,6 +162,12 @@ function ServerCard({ s }) { {t('server.peak')} {stats.peak}
)} + {s.history.length > 1 && ( +
+ {t('server.hours24')} + {t('server.now')} +
+ )} @@ -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() { {totalPlayers} {t('server.playersNow')}
+ {/* Nur wenn überhaupt jemand eine Obergrenze meldet — + ein HTTP-Check hat keine Plätze. */} + {totalSlots > 0 && ( +
+ {totalSlots} + {t('server.slots')} +
+ )}
)} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 6dcf9f9..41846c4 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 (
setOpen(true)} onChange={(e) => { onChange(e.target.value); setOpen(true); }} onKeyDown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') setOpen(false); }} /> { e.preventDefault(); setOpen((o) => !o); }}>▾ - {open && filtered.length > 0 && ( + {open && gezeigt.length > 0 && (
- {filtered.map(([id, label]) => ( + {gezeigt.map((g) => ( ))} + {rest > 0 &&
… und {rest} weitere — weitertippen
}
)}
); } +// 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 setServerDraft({ ...serverDraft, name: e.target.value })} /> setServerDraft({ ...serverDraft, type })} + onChange={spielWaehlen} /> {serverNeedsUrl ? ( setServerDraft({ ...serverDraft, query_url: e.target.value })} /> ) : ( <> setServerDraft({ ...serverDraft, host: e.target.value })} /> - setServerDraft({ ...serverDraft, port: e.target.value })} /> + setServerDraft({ ...serverDraft, port: e.target.value })} /> )}

- Neben FiveM/HTTP funktioniert jede gamedig-Spiel-ID (300+ Spiele) — Host + Query-Port eintragen. + {gewaehltesSpiel + ? <>Gewählt: {gewaehltesSpiel.name}{!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 gamedig-Liste.}

+ + {/* Nur bei Spielen, die ohne Code gar nicht erst antworten */} + {gewaehltesSpiel?.token && ( +
+ + setServerDraft({ ...serverDraft, token: e.target.value })} + /> +

+ {gewaehltesSpiel.id.startsWith('farmingsimulator') + ? <>Im Spiel heißt er Code. Steht im Webinterface des Servers unter http://host:{gewaehltesSpiel.port ?? 8080}/settings.html — 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".} +

+
+ )}
diff --git a/frontend/src/style.css b/frontend/src/style.css index 5be7b73..bb8b4c4 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js index f2b8e1d..ecba9af 100644 --- a/src/bot/server-monitor.js +++ b/src/bot/server-monitor.js @@ -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 }); diff --git a/src/db.js b/src/db.js index fa465a9..66ae9ed 100644 --- a/src/db.js +++ b/src/db.js @@ -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(); diff --git a/src/web/api.js b/src/web/api.js index e53b90b..cbba81c 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -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);