Compare commits
10 Commits
be3e915980
...
2d9a5910fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9a5910fa | ||
| f2f9e02fb2 | |||
|
|
1010fe7d11 | ||
| 20ba93b26f | |||
|
|
4fcc111def | ||
|
|
ff3fa0752e | ||
|
|
c74ac543aa | ||
| 2bb4dd723c | |||
| 4194f68968 | |||
|
|
5049e7791f |
File diff suppressed because one or more lines are too long
63
.gitea/workflows/deploy.yml
Normal file
63
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Deploy GSM
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'gsm-backend/**'
|
||||
- 'gsm-frontend/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
cd gsm-backend
|
||||
npm ci --production
|
||||
|
||||
- name: Install frontend dependencies and build
|
||||
run: |
|
||||
cd gsm-frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Deploy Backend
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: 192.168.2.30
|
||||
username: root
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
source: "gsm-backend/"
|
||||
target: "/opt/gameserver-monitor/"
|
||||
strip_components: 1
|
||||
|
||||
- name: Deploy Frontend
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: 192.168.2.30
|
||||
username: root
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
source: "gsm-frontend/dist/"
|
||||
target: "/opt/gameserver-monitor/frontend/"
|
||||
strip_components: 2
|
||||
|
||||
- name: Restart Services
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: 192.168.2.30
|
||||
username: root
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
script: |
|
||||
cd /opt/gameserver-monitor
|
||||
pm2 restart gsm-backend || pm2 start backend/server.js --name gsm-backend
|
||||
echo "Deploy complete!"
|
||||
22
CLAUDE.md
22
CLAUDE.md
@@ -14,6 +14,10 @@ The homelab consists of:
|
||||
- **Gameserver Monitor (192.168.2.30)**: React/Node.js webapp for monitoring game servers (LXC)
|
||||
- **Factorio Server (192.168.2.50)**: Docker-based game server (LXC)
|
||||
- **Minecraft Server (192.168.2.51)**: ATM10 modded server running via screen (VM)
|
||||
- **V Rising Server (192.168.2.52)**: Dedicated server (LXC)
|
||||
- **Palworld Server (192.168.2.53)**: Dedicated server with systemd (LXC)
|
||||
- **Project Zomboid Server (10.0.30.66)**: Dedicated server (external VM)
|
||||
- **Terraria Server (10.0.30.202)**: Vanilla server mit PM2 (external VM, VPN)
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
@@ -21,12 +25,22 @@ The homelab consists of:
|
||||
|
||||
**Gameserver Monitor Rollensystem**:
|
||||
- `user`: Kann nur Server-Metriken sehen (CPU, RAM, Players, Uptime)
|
||||
- `moderator`: Zusätzlich Konsole, RCON, Server Start/Stop/Restart
|
||||
- `superadmin`: Zusätzlich Nutzerverwaltung (User anlegen/löschen, Rollen ändern)
|
||||
- `moderator`: Zusätzlich Konsole, RCON, Server Start/Stop/Restart, Auto-Shutdown, Config-Editoren
|
||||
- `superadmin`: Zusätzlich Nutzerverwaltung, Activity Log, Anzeigeeinstellungen (Verbindungsadresse/Hinweis pro Server)
|
||||
|
||||
**Domain**: dimension47.de with subdomains managed via Cloudflare DDNS
|
||||
**GSM Features**:
|
||||
- Dashboard mit Server-Karten (Status, Metriken, Spieleranzahl)
|
||||
- Server-Detailansicht mit Tabs (Übersicht, Metriken, Konsole, Einstellungen, etc.)
|
||||
- Auto-Shutdown: Server stoppt automatisch wenn keine Spieler online sind
|
||||
- Config-Editoren: Palworld (INI), Project Zomboid (INI/Lua)
|
||||
- Factorio Weltverwaltung: Spielstände erstellen/löschen/laden
|
||||
- Minecraft Whitelist-Verwaltung via RCON
|
||||
- Anzeigeeinstellungen: Superadmins können Verbindungsadresse und Hinweis pro Server anpassen
|
||||
- Activity Log: Protokolliert alle Aktionen mit Discord-Avatar
|
||||
|
||||
**SSH Access**: The monitor server (.30) has SSH key access to Proxmox and both game servers for remote management.
|
||||
**Domain**: zeasy.dev with subdomains managed via Cloudflare DDNS (gsm.zeasy.dev, factorio.zeasy.dev, palworld.zeasy.dev, pz.zeasy.dev)
|
||||
|
||||
**SSH Access**: The monitor server (.30) has SSH key access to Proxmox and all game servers for remote management.
|
||||
|
||||
## Language Note
|
||||
|
||||
|
||||
69
docs/HANDOFF-SATISFACTORY.md
Normal file
69
docs/HANDOFF-SATISFACTORY.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Handoff: Satisfactory Server Setup
|
||||
|
||||
**Datum**: 2026-01-08
|
||||
**Status**: Windows VM Installation blockiert
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
### Was passiert ist
|
||||
1. Linux LXC mit Docker für Satisfactory aufgesetzt
|
||||
2. Festgestellt: **SatisfactoryPlus Mod ist nur für Windows** verfügbar (kein LinuxServer Build)
|
||||
3. LXC 105 wieder gelöscht
|
||||
4. Windows Server 2022 VM Erstellung gestartet
|
||||
5. **Problem**: Windows Installer findet keine Festplatte (VirtIO Treiber werden nicht erkannt)
|
||||
|
||||
### Das Problem
|
||||
- Windows Installer zeigt "no signed device drivers were found"
|
||||
- VirtIO ISO eingebunden, aber Treiber werden nicht akzeptiert
|
||||
- Vermutlich Secure Boot oder falscher Controller-Typ
|
||||
|
||||
## Nächste Schritte morgen
|
||||
|
||||
### 1. VM-Konfiguration prüfen
|
||||
```bash
|
||||
ssh root@192.168.2.20 "qm config <VM-ID>"
|
||||
```
|
||||
- Welche VM-ID wurde verwendet? (nicht mehr 105)
|
||||
- Prüfen: BIOS-Typ (SeaBIOS vs OVMF/UEFI)
|
||||
- Prüfen: Secure Boot aktiviert?
|
||||
|
||||
### 2. Lösungsoptionen
|
||||
|
||||
**Option A: SeaBIOS statt UEFI**
|
||||
- VM → Options → BIOS → SeaBIOS
|
||||
- Kein Secure Boot Problem
|
||||
|
||||
**Option B: IDE Controller verwenden**
|
||||
- Hard Disk detachen
|
||||
- Neu hinzufügen mit Bus: IDE
|
||||
- Langsamer aber funktioniert ohne Treiber
|
||||
|
||||
**Option C: VirtIO mit korrektem Treiber**
|
||||
- Neueste VirtIO ISO: https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso
|
||||
- Im Installer: Treiber laden → `viostor\2k22\amd64` (nicht vioscsi!)
|
||||
|
||||
### 3. Nach Windows Installation
|
||||
- [ ] VirtIO Guest Tools installieren (`virtio-win-guest-tools.exe`)
|
||||
- [ ] QEMU Guest Agent aktivieren
|
||||
- [ ] Statische IP 192.168.2.54 setzen
|
||||
- [ ] OpenSSH Server installieren (für GSM)
|
||||
- [ ] Node Exporter für Windows installieren
|
||||
- [ ] Satisfactory Dedicated Server installieren (Steam)
|
||||
- [ ] Satisfactory Mod Manager (SMM) installieren
|
||||
- [ ] SatisfactoryPlus Mod installieren
|
||||
- [ ] GSM config.json erweitern
|
||||
- [ ] Firewall Ports öffnen (7777, 15777, 15000)
|
||||
|
||||
## VM Empfohlene Specs
|
||||
| Setting | Wert |
|
||||
|---------|------|
|
||||
| OS | Windows Server 2022 mit GUI |
|
||||
| CPU | 4-6 vCPU |
|
||||
| RAM | 20-24 GB |
|
||||
| Disk | 100 GB |
|
||||
| IP | 192.168.2.54 |
|
||||
| BIOS | SeaBIOS (einfacher) |
|
||||
|
||||
## Relevante Dateien
|
||||
- GSM Config: `/opt/gameserver-monitor/backend/config.json` (auf 192.168.2.30)
|
||||
- Doku: `docs/satisfactory.md` (muss neu erstellt werden nach Setup)
|
||||
338
docs/discord-bot.md
Normal file
338
docs/discord-bot.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Discord Bot
|
||||
|
||||
Der GSM Discord Bot bietet Live-Status-Updates für alle Gameserver direkt in Discord. Der Bot kann auf mehreren Discord-Servern gleichzeitig laufen.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live-Status**: Automatisch aktualisiertes Embed mit Server-Status, Spielerzahlen und Metriken
|
||||
- **Alerts**: Benachrichtigungen wenn Server online/offline gehen oder Spieler joinen/leaven
|
||||
- **Multi-Guild**: Kann auf beliebig vielen Discord-Servern eingesetzt werden
|
||||
- **Auto-Setup**: Erstellt automatisch alle nötigen Channels beim Beitreten
|
||||
|
||||
## Bot einladen
|
||||
|
||||
### Invite-Link
|
||||
|
||||
```
|
||||
https://discord.com/oauth2/authorize?client_id=1458251194806833306&permissions=34359831568&integration_type=0&scope=bot+applications.commands
|
||||
```
|
||||
|
||||
Der Link ist auch im GSM Dashboard unter den Server-Cards verfügbar.
|
||||
|
||||
### Benötigte Permissions
|
||||
|
||||
| Permission | Verwendung |
|
||||
|------------|------------|
|
||||
| View Channels | Channels sehen |
|
||||
| Manage Channels | Channels erstellen |
|
||||
| Send Messages | Nachrichten senden |
|
||||
| Manage Messages | Eigene Nachrichten bearbeiten |
|
||||
| Embed Links | Rich Embeds für Status |
|
||||
| Read Message History | Alte Nachrichten lesen |
|
||||
| Create Public Threads | Forum-Threads erstellen |
|
||||
|
||||
## Automatisch erstellte Channel-Struktur
|
||||
|
||||
Wenn der Bot einem Server beitritt, erstellt er automatisch folgende Struktur:
|
||||
|
||||
```
|
||||
🎮 Gameserver (Kategorie)
|
||||
├── ℹ️│info - Informationen zum GSM System
|
||||
├── 📊│status - Live-Status aller Gameserver (Auto-Update)
|
||||
├── 📢│alerts - Server-Events und Spieler-Benachrichtigungen
|
||||
├── 📰│updates - Ankündigungen zu neuen Gameservern
|
||||
├── 💬│diskussion - Diskussions-Channel (User können schreiben)
|
||||
└── 💡│requests - Forum für Gameserver-Vorschläge
|
||||
```
|
||||
|
||||
### Channel-Permissions
|
||||
|
||||
| Channel | @everyone | Bot |
|
||||
|---------|-----------|-----|
|
||||
| Kategorie | Lesen | ViewChannel + Schreiben |
|
||||
| info | Lesen (kein Schreiben) | ViewChannel + Schreiben |
|
||||
| status | Lesen (kein Schreiben) | ViewChannel + Schreiben |
|
||||
| alerts | Lesen (kein Schreiben) | ViewChannel + Schreiben |
|
||||
| updates | Lesen (kein Schreiben) | ViewChannel + Schreiben |
|
||||
| diskussion | Lesen + Schreiben | ViewChannel + Schreiben |
|
||||
| requests | Lesen + Threads erstellen | ViewChannel + Schreiben |
|
||||
|
||||
**Wichtig**: Der Bot braucht explizit `ViewChannel` Permission für jeden Channel, auch wenn @everyone den Channel sehen kann. Ohne `ViewChannel` kann der Bot nicht in den Channel schreiben ("Missing Access" Fehler).
|
||||
|
||||
## Datenbank
|
||||
|
||||
### guild_settings Tabelle
|
||||
|
||||
Speichert die Channel-IDs für jeden Discord-Server:
|
||||
|
||||
```sql
|
||||
CREATE TABLE guild_settings (
|
||||
guild_id TEXT PRIMARY KEY,
|
||||
category_id TEXT,
|
||||
info_channel_id TEXT,
|
||||
status_channel_id TEXT,
|
||||
status_message_id TEXT,
|
||||
alerts_channel_id TEXT,
|
||||
updates_channel_id TEXT,
|
||||
discussion_channel_id TEXT,
|
||||
requests_channel_id TEXT,
|
||||
requests_info_thread_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
Die Datenbank liegt in `backend/db/users.sqlite`.
|
||||
|
||||
### DB-Funktionen
|
||||
|
||||
In `backend/db/init.js`:
|
||||
|
||||
```javascript
|
||||
initGuildSettings() // Tabelle erstellen
|
||||
getGuildSettings(guildId) // Settings für einen Server
|
||||
getAllGuildSettings() // Alle Server-Settings
|
||||
setGuildSettings(guildId, {}) // Settings speichern
|
||||
deleteGuildSettings(guildId) // Settings löschen (bei Bot-Kick)
|
||||
```
|
||||
|
||||
## Bot-Events
|
||||
|
||||
### guildCreate
|
||||
|
||||
Wird ausgelöst wenn der Bot einem neuen Server beitritt:
|
||||
|
||||
1. Erstellt Kategorie und alle Channels
|
||||
2. Postet Info-Nachricht im `ℹ️│info` Channel
|
||||
3. Erstellt Info-Thread im `💡│requests` Forum
|
||||
4. Speichert alle Channel-IDs in der Datenbank
|
||||
5. Sendet erste Status-Nachricht
|
||||
|
||||
### guildDelete
|
||||
|
||||
Wird ausgelöst wenn der Bot von einem Server entfernt wird:
|
||||
|
||||
1. Löscht alle Settings aus der Datenbank
|
||||
|
||||
## Status-Updates
|
||||
|
||||
Der Bot aktualisiert die Status-Nachricht in allen registrierten Guilds alle 30 Sekunden:
|
||||
|
||||
```javascript
|
||||
async function updateAllGuildStatus() {
|
||||
const guilds = getAllGuildSettings();
|
||||
for (const guild of guilds) {
|
||||
await updateGuildStatus(guild);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Jeder Server bekommt ein eigenes Embed mit:
|
||||
- Server-Name und Status (Online/Offline)
|
||||
- Aktuelle Spielerzahl
|
||||
- CPU und RAM Auslastung
|
||||
- Verbindungsadresse
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Umgebungsvariablen (.env)
|
||||
|
||||
```env
|
||||
DISCORD_CLIENT_ID=1458251194806833306
|
||||
DISCORD_CLIENT_SECRET=xxx
|
||||
DISCORD_BOT_TOKEN=xxx
|
||||
|
||||
# Multi-Server OAuth Login
|
||||
# User muss nur in EINEM der Server Mitglied sein
|
||||
# Rollen werden pro Server geprüft, höchste Berechtigung zählt
|
||||
|
||||
# Server 1: Bacanaks
|
||||
DISCORD_GUILD_ID_1=729865854329815051
|
||||
DISCORD_ADMIN_ROLE_ID_1=1024693717434650736
|
||||
DISCORD_MOD_ROLE_ID_1=1024693170958766141
|
||||
|
||||
# Server 2: Piccadilly
|
||||
DISCORD_GUILD_ID_2=730907665802330224
|
||||
DISCORD_ADMIN_ROLE_ID_2=1458595551514988584
|
||||
DISCORD_MOD_ROLE_ID_2=1458591909210488914
|
||||
```
|
||||
|
||||
**Hinweis**: Die Guild-IDs werden nur für den Discord OAuth Login verwendet, nicht für den Bot selbst.
|
||||
|
||||
### OAuth Login-Logik
|
||||
|
||||
```
|
||||
1. User loggt sich via Discord OAuth ein
|
||||
2. Für jeden konfigurierten Server:
|
||||
- Ist User Mitglied? → Rollen prüfen
|
||||
- Admin-Rolle → superadmin
|
||||
- Mod-Rolle → moderator
|
||||
- Nur Mitglied → user
|
||||
3. Höchste Berechtigung aus allen Servern wird verwendet
|
||||
4. Nicht in mindestens einem Server → Login verweigert
|
||||
```
|
||||
|
||||
| User ist in... | Bacanaks Rolle | Piccadilly Rolle | GSM Rolle |
|
||||
|----------------|----------------|------------------|-----------|
|
||||
| Nur Bacanaks | Admin | - | superadmin |
|
||||
| Nur Piccadilly | - | Mod | moderator |
|
||||
| Beide | Mitglied | Admin | superadmin |
|
||||
| Keinem | - | - | ❌ Kein Login |
|
||||
|
||||
### Developer Portal Einstellungen
|
||||
|
||||
1. Gehe zu https://discord.com/developers/applications
|
||||
2. Wähle die Bot-Application
|
||||
3. **Bot** Tab:
|
||||
- "Public Bot" aktivieren (damit andere einladen können)
|
||||
- Privileged Gateway Intents:
|
||||
- Server Members Intent: Optional
|
||||
- Message Content Intent: Nicht benötigt
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `backend/services/discord.js` | OAuth-Logik, Multi-Guild Membership-Prüfung |
|
||||
| `backend/services/discordBot.js` | Bot-Logik und Event-Handler |
|
||||
| `backend/routes/auth.js` | Auth-Endpoints (Login, Callback, Refresh) |
|
||||
| `backend/db/init.js` | Guild-Settings DB-Funktionen |
|
||||
| `frontend/src/pages/Dashboard.jsx` | Invite-Button im Dashboard |
|
||||
|
||||
## Implementierungsdetails
|
||||
|
||||
### Multi-Guild OAuth
|
||||
|
||||
Die OAuth-Implementierung in `discord.js` verwendet **lazy initialization** für die Guild-Konfigurationen:
|
||||
|
||||
```javascript
|
||||
let _guildConfigs = null;
|
||||
|
||||
function getGuildConfigs() {
|
||||
if (_guildConfigs === null) {
|
||||
_guildConfigs = [
|
||||
{ name: 'Bacanaks', guildId: process.env.DISCORD_GUILD_ID_1, ... },
|
||||
{ name: 'Piccadilly', guildId: process.env.DISCORD_GUILD_ID_2, ... }
|
||||
].filter(config => config.guildId);
|
||||
}
|
||||
return _guildConfigs;
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtig**: Die Konfiguration darf NICHT beim Modul-Import initialisiert werden, da zu diesem Zeitpunkt dotenv die `.env` noch nicht geladen hat. Die lazy initialization stellt sicher, dass die Umgebungsvariablen verfügbar sind.
|
||||
|
||||
### Funktionen
|
||||
|
||||
| Funktion | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `getGuildMemberships(userId)` | Prüft alle konfigurierten Server, gibt Array von Memberships zurück |
|
||||
| `getUserRoleFromMemberships(memberships)` | Bestimmt höchste Rolle aus allen Memberships |
|
||||
| `getGuildMember(userId)` | Legacy-Funktion, gibt ersten Match zurück |
|
||||
| `getUserRole(memberRoles)` | Legacy-Funktion für einzelne Rollen-Liste |
|
||||
|
||||
### Rollen-Priorität
|
||||
|
||||
```javascript
|
||||
const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 };
|
||||
```
|
||||
|
||||
Bei mehreren Memberships wird immer die höchste Rolle verwendet.
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Der Bot muss auf **allen** konfigurierten Discord-Servern sein
|
||||
- Der Bot braucht Zugriff auf die Guild Members API
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Login schlägt fehl mit "nicht Mitglied"
|
||||
|
||||
1. **Bot auf allen Servern?** Der Bot muss auf Bacanaks UND Piccadilly eingeladen sein
|
||||
2. **Env-Variablen prüfen**:
|
||||
```bash
|
||||
grep GUILD /opt/gameserver-monitor/backend/.env
|
||||
```
|
||||
3. **Mit --update-env neustarten**:
|
||||
```bash
|
||||
pm2 restart gameserver-backend --update-env
|
||||
```
|
||||
4. **Logs prüfen**:
|
||||
```bash
|
||||
pm2 logs gameserver-backend --lines 30 | grep -i discord
|
||||
```
|
||||
|
||||
### Rolle wird nicht erkannt
|
||||
|
||||
1. **Rollen-IDs prüfen** - Discord Developer Mode aktivieren, Rechtsklick auf Rolle → ID kopieren
|
||||
2. **User-Rollen abfragen**:
|
||||
```bash
|
||||
curl -s -H "Authorization: Bot BOT_TOKEN" \
|
||||
"https://discord.com/api/v10/guilds/GUILD_ID/members/USER_ID" | jq '.roles'
|
||||
```
|
||||
3. **Konfigurierte IDs vergleichen** mit den tatsächlichen Rollen des Users
|
||||
|
||||
### Bot erstellt keine Channels
|
||||
|
||||
- Prüfen ob Bot "Manage Channels" Permission hat
|
||||
- Prüfen ob Bot-Rolle hoch genug in der Rollen-Hierarchie ist
|
||||
|
||||
### Status-Nachricht wird nicht aktualisiert
|
||||
|
||||
```bash
|
||||
pm2 logs gameserver-backend --lines 50
|
||||
```
|
||||
|
||||
Suche nach `[DiscordBot]` Log-Einträgen.
|
||||
|
||||
### Bot aus Datenbank entfernen
|
||||
|
||||
```bash
|
||||
cd /opt/gameserver-monitor/backend
|
||||
node -e "
|
||||
import Database from 'better-sqlite3';
|
||||
const db = new Database('./db/users.sqlite');
|
||||
db.prepare('DELETE FROM guild_settings WHERE guild_id = ?').run('GUILD_ID_HIER');
|
||||
console.log('Deleted');
|
||||
"
|
||||
```
|
||||
|
||||
Alternativ mit sqlite3 (falls installiert):
|
||||
```bash
|
||||
sqlite3 /opt/gameserver-monitor/backend/db/users.sqlite
|
||||
DELETE FROM guild_settings WHERE guild_id = 'xxx';
|
||||
```
|
||||
|
||||
### Missing Access Fehler
|
||||
|
||||
Wenn der Bot "Missing Access" meldet obwohl er eingeladen wurde:
|
||||
|
||||
1. **ViewChannel Permission prüfen**: Der Bot braucht explizit `ViewChannel` für jeden Channel
|
||||
2. Im Discord: Rechtsklick auf Channel → Bearbeiten → Berechtigungen → Bot auswählen → "Kanal ansehen" aktivieren
|
||||
3. Logs prüfen: `pm2 logs gameserver-backend --lines 50 | grep -i "missing\|access"`
|
||||
|
||||
### Status-Nachricht wurde gelöscht
|
||||
|
||||
Wenn die Status-Nachricht manuell gelöscht wurde, erscheint "Unknown Message" in den Logs. Fix:
|
||||
|
||||
```bash
|
||||
cd /opt/gameserver-monitor/backend
|
||||
node -e "
|
||||
import Database from 'better-sqlite3';
|
||||
const db = new Database('./db/users.sqlite');
|
||||
// Status Message ID auf NULL setzen, Bot erstellt neue beim nächsten Update
|
||||
db.prepare('UPDATE guild_settings SET status_message_id = NULL WHERE guild_id = ?').run('GUILD_ID_HIER');
|
||||
console.log('Reset status_message_id');
|
||||
"
|
||||
```
|
||||
|
||||
## Login vs. Bot
|
||||
|
||||
| Feature | Login (OAuth) | Bot |
|
||||
|---------|--------------|-----|
|
||||
| Erfordert Mitgliedschaft | Bacanaks oder Piccadilly | Nein |
|
||||
| Server-Steuerung | Ja (je nach Rolle) | Nein |
|
||||
| Status sehen | Ja | Ja |
|
||||
| Alerts erhalten | Nein | Ja |
|
||||
| Verfügbar für | Mitglieder beider Discord-Server | Alle mit Bot |
|
||||
|
||||
Der Login zur Webapp erfordert Mitgliedschaft in mindestens einem der konfigurierten Discord-Server (Bacanaks oder Piccadilly). Die höchste Rolle aus beiden Servern bestimmt die GSM-Berechtigung. Der Bot ist davon unabhängig und zeigt nur passive Status-Updates.
|
||||
385
docs/gameserver-hinzufuegen.md
Normal file
385
docs/gameserver-hinzufuegen.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# Neuen Gameserver zum GSM hinzufügen
|
||||
|
||||
Diese Anleitung beschreibt alle Schritte, um einen neuen Gameserver in das Gameserver Management (GSM) System zu integrieren.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Proxmox LXC Container oder VM für den Gameserver
|
||||
- SSH-Zugang vom GSM Server (192.168.2.30) zum neuen Server
|
||||
- Freie IP-Adresse im Netzwerk (192.168.2.x)
|
||||
|
||||
## Schritt 1: Server aufsetzen
|
||||
|
||||
### 1.1 LXC Container / VM erstellen
|
||||
|
||||
Auf Proxmox (192.168.2.20) einen neuen Container oder VM erstellen:
|
||||
- **LXC** für leichtgewichtige Server (Factorio, Palworld, etc.)
|
||||
- **VM** für Server die spezielle Kernel-Features brauchen (Minecraft mit hohem RAM)
|
||||
|
||||
Empfohlene Ressourcen je nach Spiel:
|
||||
| Spiel | CPU Cores | RAM | Speicher |
|
||||
|-------|-----------|-----|----------|
|
||||
| Factorio | 2 | 2 GB | 20 GB |
|
||||
| Minecraft (Modded) | 4-6 | 8-16 GB | 50 GB |
|
||||
| Palworld | 4 | 16 GB | 30 GB |
|
||||
| Project Zomboid | 4 | 8 GB | 30 GB |
|
||||
| V Rising | 2 | 4 GB | 20 GB |
|
||||
|
||||
### 1.2 Basis-Setup
|
||||
|
||||
```bash
|
||||
# System updaten
|
||||
apt update && apt upgrade -y
|
||||
|
||||
# Grundlegende Pakete
|
||||
apt install -y curl wget screen htop
|
||||
```
|
||||
|
||||
### 1.3 SteamCMD installieren (für Steam-Spiele)
|
||||
|
||||
```bash
|
||||
# 32-bit Bibliotheken
|
||||
apt install -y lib32gcc-s1
|
||||
|
||||
# Steam User erstellen
|
||||
useradd -m -s /bin/bash steam
|
||||
|
||||
# SteamCMD installieren
|
||||
su - steam
|
||||
mkdir steamcmd && cd steamcmd
|
||||
wget https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz
|
||||
tar -xvzf steamcmd_linux.tar.gz
|
||||
```
|
||||
|
||||
### 1.4 Dedicated Server installieren
|
||||
|
||||
```bash
|
||||
# Als steam User
|
||||
./steamcmd.sh +force_install_dir /opt/<spielname> +login anonymous +app_update <APP_ID> validate +quit
|
||||
```
|
||||
|
||||
Steam App IDs:
|
||||
- Palworld: `2394010`
|
||||
- Project Zomboid: `380870`
|
||||
- V Rising: `1829350`
|
||||
- Factorio: `427520` (oder manuell von factorio.com)
|
||||
|
||||
## Schritt 2: Service erstellen (systemd oder PM2)
|
||||
|
||||
### Option A: Systemd Service (empfohlen für root-Zugang)
|
||||
|
||||
Erstelle `/etc/systemd/system/<spielname>.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=<Spielname> Dedicated Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=steam
|
||||
Group=steam
|
||||
WorkingDirectory=/opt/<spielname>
|
||||
ExecStart=/opt/<spielname>/start.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Aktivieren:
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable <spielname>
|
||||
```
|
||||
|
||||
### Option B: PM2 (für User ohne root-Zugang)
|
||||
|
||||
PM2 eignet sich für Server, wo kein Root-Zugang verfügbar ist (z.B. externe VMs).
|
||||
|
||||
```bash
|
||||
# NVM installieren
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
||||
source ~/.nvm/nvm.sh
|
||||
|
||||
# Node.js und PM2 installieren
|
||||
nvm install --lts
|
||||
npm install -g pm2
|
||||
|
||||
# Server starten (Beispiel Terraria)
|
||||
cd /home/<user>/<spielordner>
|
||||
pm2 start ./StartServer.sh --name <spielname>
|
||||
|
||||
# Speichern für Autostart
|
||||
pm2 save
|
||||
```
|
||||
|
||||
Für Autostart nach Reboot muss ein Admin den folgenden Befehl mit sudo ausführen:
|
||||
```bash
|
||||
sudo env PATH=$PATH:/home/<user>/.nvm/versions/node/<version>/bin pm2 startup systemd -u <user> --hp /home/<user>
|
||||
```
|
||||
|
||||
In der GSM config.json `"runtime": "pm2"` und `"serviceName": "<pm2-prozessname>"` setzen.
|
||||
|
||||
## Schritt 3: Node Exporter installieren
|
||||
|
||||
Für Metriken im GSM Dashboard:
|
||||
|
||||
```bash
|
||||
apt install -y prometheus-node-exporter
|
||||
systemctl enable prometheus-node-exporter
|
||||
systemctl start prometheus-node-exporter
|
||||
```
|
||||
|
||||
Prüfen ob Port 9100 erreichbar ist:
|
||||
```bash
|
||||
curl http://localhost:9100/metrics | head
|
||||
```
|
||||
|
||||
## Schritt 4: SSH-Zugang einrichten
|
||||
|
||||
Auf dem GSM Server (192.168.2.30):
|
||||
|
||||
```bash
|
||||
# Public Key kopieren
|
||||
ssh-copy-id -i /root/.ssh/id_ed25519.pub root@192.168.2.XX
|
||||
```
|
||||
|
||||
Testen:
|
||||
```bash
|
||||
ssh root@192.168.2.XX "hostname"
|
||||
```
|
||||
|
||||
## Schritt 5: DNS Eintrag erstellen
|
||||
|
||||
### 5.1 Cloudflare DDNS Container aktualisieren
|
||||
|
||||
Auf dem Raspberry Pi (192.168.2.10):
|
||||
|
||||
```bash
|
||||
cd /home/alex
|
||||
nano docker-compose.yml
|
||||
```
|
||||
|
||||
Neue Domain zur `CF_DOMAINS` Umgebungsvariable hinzufügen:
|
||||
```yaml
|
||||
environment:
|
||||
- CF_DOMAINS=gsm.zeasy.dev,factorio.zeasy.dev,<neuer-name>.zeasy.dev
|
||||
```
|
||||
|
||||
Container neu starten:
|
||||
```bash
|
||||
docker-compose up -d cloudflare-ddns
|
||||
```
|
||||
|
||||
### 5.2 Port Forwarding im Router
|
||||
|
||||
Benötigte Ports im Router freigeben:
|
||||
| Spiel | Ports |
|
||||
|-------|-------|
|
||||
| Palworld | 8211/UDP, 27015/UDP |
|
||||
| Minecraft | 25565/TCP |
|
||||
| Factorio | 34197/UDP |
|
||||
| Project Zomboid | 16261/UDP, 16262/UDP |
|
||||
| V Rising | 9876/UDP, 9877/UDP |
|
||||
|
||||
## Schritt 6: GSM Backend konfigurieren
|
||||
|
||||
### 6.1 config.json erweitern
|
||||
|
||||
Auf dem GSM Server `/opt/gameserver-monitor/backend/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<spielname>",
|
||||
"name": "<Anzeigename>",
|
||||
"host": "192.168.2.XX",
|
||||
"type": "<spielname>",
|
||||
"runtime": "systemd",
|
||||
"serviceName": "<service-name>",
|
||||
"rconPort": XXXXX,
|
||||
"rconPassword": "<passwort>",
|
||||
"workDir": "/opt/<spielname>",
|
||||
"configPath": "/pfad/zur/config"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 SSH-Funktionen hinzufügen (optional)
|
||||
|
||||
Falls Config-Editor benötigt wird, in `backend/services/ssh.js`:
|
||||
|
||||
```javascript
|
||||
const <SPIELNAME>_CONFIG_PATH = "/pfad/zu/configs";
|
||||
const <SPIELNAME>_ALLOWED_FILES = ["config1.ini", "config2.ini"];
|
||||
|
||||
export async function list<Spielname>Configs(server) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
export async function read<Spielname>Config(server, filename) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
export async function write<Spielname>Config(server, filename, content) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Routes hinzufügen (optional)
|
||||
|
||||
In `backend/routes/servers.js` neue Endpoints für Config-Editor.
|
||||
|
||||
### 6.4 Backend neustarten
|
||||
|
||||
```bash
|
||||
pm2 restart gameserver-backend
|
||||
pm2 logs gameserver-backend --lines 20
|
||||
```
|
||||
|
||||
## Schritt 7: Prometheus konfigurieren
|
||||
|
||||
Auf dem GSM Server `/etc/prometheus/prometheus.yml`:
|
||||
|
||||
```yaml
|
||||
- job_name: "<spielname>"
|
||||
static_configs:
|
||||
- targets: ["192.168.2.XX:9100"]
|
||||
```
|
||||
|
||||
Prometheus neustarten:
|
||||
```bash
|
||||
systemctl restart prometheus
|
||||
```
|
||||
|
||||
## Schritt 8: Frontend erweitern
|
||||
|
||||
### 8.1 Server-Info in ServerCard.jsx
|
||||
|
||||
```javascript
|
||||
const serverInfo = {
|
||||
// ...bestehende Server
|
||||
<spielname>: {
|
||||
address: '<spielname>.zeasy.dev:<port>',
|
||||
logo: '/<spielname>.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/XXXXXX' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// In getServerInfo():
|
||||
if (name.includes('<spielname>')) return serverInfo.<spielname>
|
||||
```
|
||||
|
||||
### 8.2 Logo in ServerDetail.jsx
|
||||
|
||||
```javascript
|
||||
if (name.includes("<spielname>")) return "/<spielname>.png"
|
||||
```
|
||||
|
||||
### 8.3 ActivityLog Labels
|
||||
|
||||
```javascript
|
||||
const serverLabels = {
|
||||
// ...
|
||||
<spielname>: '<Anzeigename>'
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 API-Funktionen (falls Config-Editor)
|
||||
|
||||
In `frontend/src/api.js`:
|
||||
|
||||
```javascript
|
||||
export async function get<Spielname>Configs(token) {
|
||||
return fetchAPI('/servers/<spielname>/config', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
// etc.
|
||||
```
|
||||
|
||||
### 8.5 Config-Editor Komponente (optional)
|
||||
|
||||
Neue Datei `frontend/src/components/<Spielname>ConfigEditor.jsx` erstellen (basierend auf bestehenden Editoren).
|
||||
|
||||
### 8.6 Icon hinzufügen
|
||||
|
||||
PNG-Datei nach `frontend/public/<spielname>.png` kopieren (idealerweise 64x64 oder 128x128 Pixel).
|
||||
|
||||
### 8.7 Frontend bauen und deployen
|
||||
|
||||
```bash
|
||||
cd /opt/gameserver-monitor/frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Schritt 9: Anzeigeeinstellungen konfigurieren (optional)
|
||||
|
||||
Nach dem Hinzufügen eines Servers können Superadmins die Anzeigeeinstellungen direkt im Web-Interface bearbeiten.
|
||||
|
||||
### 9.1 Anzeigeeinstellungen im Frontend
|
||||
|
||||
1. Als Superadmin einloggen
|
||||
2. Server-Detailansicht öffnen
|
||||
3. Tab **"Anzeige"** auswählen
|
||||
4. Folgende Felder können bearbeitet werden:
|
||||
- **Verbindungsadresse**: Die Adresse die Spielern zum Verbinden angezeigt wird (z.B. `palworld.zeasy.dev:8211`)
|
||||
- **Hinweis**: Zusätzliche Informationen wie Passwort, Version, etc. (z.B. `Passwort: geheim123`)
|
||||
5. Speichern klicken
|
||||
|
||||
### 9.2 Verhalten der Anzeigeeinstellungen
|
||||
|
||||
- **Datenbankwerte haben Priorität**: Wenn Anzeigeeinstellungen in der Datenbank gesetzt sind, überschreiben diese die Standardwerte aus dem Code
|
||||
- **Fallback auf Standardwerte**: Wenn keine Datenbankeinstellungen existieren, werden die hartkodierten Standardwerte aus `ServerCard.jsx` verwendet
|
||||
- **Minecraft Sonderfall**: Der Standard-Hinweis "Whitelist erforderlich" wird nur angezeigt, wenn kein eigener Hinweis gesetzt wurde
|
||||
|
||||
### 9.3 Technische Details
|
||||
|
||||
**Datenbank-Tabelle** (`users.sqlite`):
|
||||
```sql
|
||||
CREATE TABLE server_display_settings (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
address TEXT,
|
||||
hint TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**API-Endpoints**:
|
||||
- `GET /api/servers/display-settings` - Alle Einstellungen abrufen (für Dashboard)
|
||||
- `GET /api/servers/:id/display-settings` - Einstellungen für einen Server (Superadmin)
|
||||
- `PUT /api/servers/:id/display-settings` - Einstellungen speichern (Superadmin)
|
||||
|
||||
**Dateien**:
|
||||
- Backend: `backend/routes/servers.js`, `backend/db/init.js`
|
||||
- Frontend: `frontend/src/pages/ServerDetail.jsx`, `frontend/src/components/ServerCard.jsx`, `frontend/src/api.js`
|
||||
|
||||
## Checkliste
|
||||
|
||||
- [ ] Server aufgesetzt und Spiel installiert
|
||||
- [ ] Systemd Service erstellt und aktiviert
|
||||
- [ ] Node Exporter installiert
|
||||
- [ ] SSH-Zugang vom GSM Server eingerichtet
|
||||
- [ ] DNS Eintrag erstellt
|
||||
- [ ] Port Forwarding konfiguriert
|
||||
- [ ] Backend config.json erweitert
|
||||
- [ ] Prometheus Target hinzugefügt
|
||||
- [ ] Frontend ServerCard/ServerDetail erweitert
|
||||
- [ ] Server-Icon hinzugefügt
|
||||
- [ ] Frontend gebaut
|
||||
- [ ] Backend neugestartet
|
||||
- [ ] Anzeigeeinstellungen konfiguriert (Verbindungsadresse, Hinweis)
|
||||
|
||||
## Aktuelle Server-Übersicht
|
||||
|
||||
| Server | IP | Typ | Ports |
|
||||
|--------|-----|-----|-------|
|
||||
| GSM Monitor | 192.168.2.30 | LXC | 3000, 9090 |
|
||||
| Factorio | 192.168.2.50 | LXC | 34197/UDP |
|
||||
| Minecraft | 192.168.2.51 | VM | 25565/TCP |
|
||||
| V Rising | 192.168.2.52 | LXC | 9876-9877/UDP |
|
||||
| Palworld | 192.168.2.53 | LXC | 8211/UDP, 27015/UDP |
|
||||
| Project Zomboid | 10.0.30.66 | VM (extern) | 16261-16262/UDP |
|
||||
| Terraria | 10.0.30.202 | VM (extern/VPN) | 7777/TCP |
|
||||
417
docs/gitea-setup.md
Normal file
417
docs/gitea-setup.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Gitea Setup mit CI/CD Runner
|
||||
|
||||
Lokale Git-Instanz auf Proxmox LXC mit automatischem Deployment via Gitea Actions.
|
||||
|
||||
## Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────┐ git push ┌─────────────────┐
|
||||
│ Lokaler PC │ ───────────────▶ │ Gitea LXC │
|
||||
│ (Development) │ │ 192.168.2.40 │
|
||||
└─────────────────┘ └────────┬────────┘
|
||||
│ trigger
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Gitea Runner │
|
||||
│ (act_runner) │
|
||||
└────────┬────────┘
|
||||
│ SSH deploy
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ GSM Server │
|
||||
│ 192.168.2.30 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Teil 1: LXC Container erstellen
|
||||
|
||||
### Proxmox Web UI
|
||||
1. CT Template herunterladen: `Datacenter → pve → local → CT Templates → Templates`
|
||||
- Debian 12 (Bookworm) empfohlen
|
||||
2. Neuen Container erstellen:
|
||||
- **CT ID:** 104 (oder nächste freie)
|
||||
- **Hostname:** gitea
|
||||
- **Password:** sicheres Root-Passwort
|
||||
- **Template:** debian-12-standard
|
||||
- **Disk:** 16 GB
|
||||
- **CPU:** 2 Cores
|
||||
- **RAM:** 1024 MB
|
||||
- **Network:** vmbr0, DHCP oder statisch 192.168.2.40
|
||||
|
||||
### Oder per CLI auf Proxmox Host
|
||||
```bash
|
||||
# Template herunterladen falls nicht vorhanden
|
||||
pveam download local debian-12-standard_12.2-1_amd64.tar.zst
|
||||
|
||||
# Container erstellen
|
||||
pct create 104 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst \
|
||||
--hostname gitea \
|
||||
--memory 1024 \
|
||||
--cores 2 \
|
||||
--rootfs local-lvm:16 \
|
||||
--net0 name=eth0,bridge=vmbr0,ip=192.168.2.40/24,gw=192.168.2.1 \
|
||||
--password \
|
||||
--unprivileged 1 \
|
||||
--features nesting=1
|
||||
|
||||
# Container starten
|
||||
pct start 104
|
||||
```
|
||||
|
||||
## Teil 2: Gitea Installation
|
||||
|
||||
### System vorbereiten
|
||||
```bash
|
||||
# In den Container einloggen
|
||||
pct enter 104
|
||||
|
||||
# System updaten
|
||||
apt update && apt upgrade -y
|
||||
|
||||
# Abhängigkeiten installieren
|
||||
apt install -y git curl wget sudo sqlite3
|
||||
```
|
||||
|
||||
### Git-User erstellen
|
||||
```bash
|
||||
adduser --system --shell /bin/bash --group --disabled-password --home /home/git git
|
||||
```
|
||||
|
||||
### Gitea herunterladen
|
||||
```bash
|
||||
# Aktuelle Version prüfen: https://github.com/go-gitea/gitea/releases
|
||||
GITEA_VERSION="1.21.4"
|
||||
|
||||
wget -O /usr/local/bin/gitea https://dl.gitea.io/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64
|
||||
chmod +x /usr/local/bin/gitea
|
||||
|
||||
# Version prüfen
|
||||
gitea --version
|
||||
```
|
||||
|
||||
### Verzeichnisse erstellen
|
||||
```bash
|
||||
mkdir -p /var/lib/gitea/{custom,data,log}
|
||||
mkdir -p /etc/gitea
|
||||
chown -R git:git /var/lib/gitea
|
||||
chown root:git /etc/gitea
|
||||
chmod 770 /etc/gitea
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
```bash
|
||||
cat > /etc/systemd/system/gitea.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Gitea (Git with a cup of tea)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
RestartSec=2s
|
||||
Type=simple
|
||||
User=git
|
||||
Group=git
|
||||
WorkingDirectory=/var/lib/gitea/
|
||||
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
|
||||
Restart=always
|
||||
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable gitea
|
||||
systemctl start gitea
|
||||
```
|
||||
|
||||
### Web-Setup abschließen
|
||||
1. Browser öffnen: `http://192.168.2.40:3000`
|
||||
2. Initial-Setup:
|
||||
- **Database:** SQLite3
|
||||
- **SSH Port:** 22
|
||||
- **HTTP Port:** 3000
|
||||
- **Base URL:** `http://192.168.2.40:3000/` (später ändern für Domain)
|
||||
- **Admin Account erstellen**
|
||||
|
||||
### Berechtigungen nach Setup fixieren
|
||||
```bash
|
||||
chmod 750 /etc/gitea
|
||||
chmod 640 /etc/gitea/app.ini
|
||||
```
|
||||
|
||||
## Teil 3: Gitea Actions aktivieren
|
||||
|
||||
### app.ini anpassen
|
||||
```bash
|
||||
nano /etc/gitea/app.ini
|
||||
```
|
||||
|
||||
Folgende Sektion hinzufügen/anpassen:
|
||||
```ini
|
||||
[actions]
|
||||
ENABLED = true
|
||||
DEFAULT_ACTIONS_URL = github
|
||||
```
|
||||
|
||||
Gitea neustarten:
|
||||
```bash
|
||||
systemctl restart gitea
|
||||
```
|
||||
|
||||
## Teil 4: Gitea Actions Runner
|
||||
|
||||
### Runner installieren
|
||||
```bash
|
||||
# Als root auf dem Gitea-Server (oder separater Server)
|
||||
cd /opt
|
||||
RUNNER_VERSION="0.2.6"
|
||||
wget https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64
|
||||
mv act_runner-${RUNNER_VERSION}-linux-amd64 act_runner
|
||||
chmod +x act_runner
|
||||
```
|
||||
|
||||
### Runner Token generieren
|
||||
1. Gitea Web UI → `Site Administration → Actions → Runners`
|
||||
2. `Create new Runner` → Token kopieren
|
||||
|
||||
### Runner registrieren
|
||||
```bash
|
||||
cd /opt
|
||||
./act_runner register --no-interactive \
|
||||
--instance http://192.168.2.40:3000 \
|
||||
--token <DEIN_TOKEN> \
|
||||
--name homelab-runner \
|
||||
--labels ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://ubuntu:22.04
|
||||
```
|
||||
|
||||
### Runner als Systemd Service
|
||||
```bash
|
||||
cat > /etc/systemd/system/gitea-runner.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner
|
||||
After=network.target gitea.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt
|
||||
ExecStart=/opt/act_runner daemon
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable gitea-runner
|
||||
systemctl start gitea-runner
|
||||
```
|
||||
|
||||
### Docker für Runner installieren (falls Labels mit docker:// genutzt)
|
||||
```bash
|
||||
apt install -y docker.io
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
```
|
||||
|
||||
## Teil 5: SSH Deploy Key einrichten
|
||||
|
||||
### Auf dem Gitea-Server (Runner)
|
||||
```bash
|
||||
# SSH Key für Deployments erstellen
|
||||
ssh-keygen -t ed25519 -C "gitea-deploy" -f /root/.ssh/deploy_key -N ""
|
||||
|
||||
# Public Key anzeigen
|
||||
cat /root/.ssh/deploy_key.pub
|
||||
```
|
||||
|
||||
### Auf dem GSM-Server (192.168.2.30)
|
||||
```bash
|
||||
# Public Key zu authorized_keys hinzufügen
|
||||
echo "ssh-ed25519 AAAA... gitea-deploy" >> /root/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
### In Gitea als Secret speichern
|
||||
1. Repository → `Settings → Actions → Secrets`
|
||||
2. Neues Secret: `SSH_DEPLOY_KEY`
|
||||
3. Inhalt: Private Key (`cat /root/.ssh/deploy_key`)
|
||||
|
||||
## Teil 6: GSM Repository einrichten
|
||||
|
||||
### Auf dem GSM-Server (192.168.2.30)
|
||||
```bash
|
||||
cd /opt/gameserver-monitor
|
||||
|
||||
# Falls noch kein Git-Repo
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial commit"
|
||||
|
||||
# Gitea als Remote hinzufügen
|
||||
git remote add origin http://192.168.2.40:3000/<user>/gameserver-monitor.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### Workflow-Datei erstellen
|
||||
Im Repository `.gitea/workflows/deploy.yml` erstellen:
|
||||
|
||||
```yaml
|
||||
name: Deploy GSM
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: # Manueller Trigger
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to Server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: 192.168.2.30
|
||||
username: root
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
script: |
|
||||
set -e
|
||||
cd /opt/gameserver-monitor
|
||||
|
||||
echo "=== Pulling latest changes ==="
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
|
||||
echo "=== Installing backend dependencies ==="
|
||||
cd backend
|
||||
npm ci --production
|
||||
|
||||
echo "=== Building frontend ==="
|
||||
cd ../frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
echo "=== Restarting services ==="
|
||||
pm2 restart gsm-backend
|
||||
|
||||
echo "=== Deploy complete ==="
|
||||
```
|
||||
|
||||
### Alternative: Separater Frontend/Backend Deploy
|
||||
```yaml
|
||||
name: Deploy GSM
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
|
||||
jobs:
|
||||
deploy-backend:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.head_commit.modified, 'backend/')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Deploy Backend
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: 192.168.2.30
|
||||
username: root
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
script: |
|
||||
cd /opt/gameserver-monitor/backend
|
||||
git pull origin main
|
||||
npm ci --production
|
||||
pm2 restart gsm-backend
|
||||
|
||||
deploy-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.head_commit.modified, 'frontend/')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Deploy Frontend
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: 192.168.2.30
|
||||
username: root
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
script: |
|
||||
cd /opt/gameserver-monitor/frontend
|
||||
git pull origin main
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Teil 7: Reverse Proxy (Optional)
|
||||
|
||||
### Nginx Proxy Manager Konfiguration
|
||||
Falls Gitea über Domain erreichbar sein soll (z.B. `git.zeasy.dev`):
|
||||
|
||||
1. Nginx Proxy Manager → Proxy Hosts → Add
|
||||
2. **Domain:** git.zeasy.dev
|
||||
3. **Forward Hostname:** 192.168.2.40
|
||||
4. **Forward Port:** 3000
|
||||
5. **SSL:** Let's Encrypt aktivieren
|
||||
|
||||
### Gitea app.ini anpassen
|
||||
```ini
|
||||
[server]
|
||||
DOMAIN = git.zeasy.dev
|
||||
ROOT_URL = https://git.zeasy.dev/
|
||||
SSH_DOMAIN = git.zeasy.dev
|
||||
```
|
||||
|
||||
## Teil 8: Lokale Git-Konfiguration
|
||||
|
||||
### Remote für lokales Entwickeln
|
||||
```bash
|
||||
# Im lokalen Projekt
|
||||
cd E:/Projects/homelab-docs/gsm-frontend
|
||||
git remote add gitea http://192.168.2.40:3000/<user>/gameserver-monitor.git
|
||||
|
||||
# Oder mit SSH (wenn SSH-Key eingerichtet)
|
||||
git remote add gitea git@192.168.2.40:<user>/gameserver-monitor.git
|
||||
```
|
||||
|
||||
### Workflow
|
||||
```bash
|
||||
# Entwickeln...
|
||||
git add .
|
||||
git commit -m "Feature: xyz"
|
||||
git push gitea main # → Triggert automatisch Deploy
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Runner-Status prüfen
|
||||
```bash
|
||||
systemctl status gitea-runner
|
||||
journalctl -u gitea-runner -f
|
||||
```
|
||||
|
||||
### Gitea Logs
|
||||
```bash
|
||||
journalctl -u gitea -f
|
||||
# oder
|
||||
tail -f /var/lib/gitea/log/gitea.log
|
||||
```
|
||||
|
||||
### Actions Debug
|
||||
- In Gitea Web UI: Repository → Actions → Job auswählen → Logs ansehen
|
||||
|
||||
### SSH-Verbindung testen
|
||||
```bash
|
||||
# Vom Runner aus
|
||||
ssh -i /root/.ssh/deploy_key root@192.168.2.30 "echo 'Connection OK'"
|
||||
```
|
||||
|
||||
## Ressourcen
|
||||
|
||||
- Gitea Docs: https://docs.gitea.io/
|
||||
- Gitea Actions: https://docs.gitea.io/en-us/actions/overview/
|
||||
- Act Runner: https://gitea.com/gitea/act_runner
|
||||
@@ -77,13 +77,14 @@ Frontend Backend
|
||||
| | +-- ssh.js # SSH-Verbindungen, Status, Uptime
|
||||
| | +-- rcon.js # RCON-Kommunikation
|
||||
| | +-- prometheus.js # Prometheus Queries
|
||||
| | +-- factorio.js # Factorio Map-Gen Presets/Defaults
|
||||
| |
|
||||
| +-- middleware/
|
||||
| | +-- auth.js # JWT Middleware
|
||||
| |
|
||||
| +-- db/
|
||||
| +-- init.js # DB-Schema, Whitelist-Cache
|
||||
| +-- users.sqlite # User-Datenbank
|
||||
| +-- init.js # DB-Schema, Caches, Templates
|
||||
| +-- users.sqlite # User-Datenbank (users, whitelist_cache, factorio_templates, factorio_world_settings)
|
||||
|
|
||||
+-- frontend/
|
||||
+-- src/
|
||||
@@ -101,6 +102,8 @@ Frontend Backend
|
||||
| | +-- LoginModal.jsx
|
||||
| | +-- SettingsModal.jsx
|
||||
| | +-- UserManagement.jsx
|
||||
| | +-- FactorioWorldManager.jsx # Factorio Save-Verwaltung
|
||||
| | +-- WorldGenForm.jsx # Map-Generation-Formular
|
||||
| |
|
||||
| +-- context/
|
||||
| +-- UserContext.jsx
|
||||
@@ -142,7 +145,7 @@ Frontend Backend
|
||||
|--------|----------|------|--------------|
|
||||
| GET | /api/servers | optional | Alle Server mit Status/Metrics |
|
||||
| GET | /api/servers/:id | optional | Einzelner Server |
|
||||
| POST | /api/servers/:id/start | moderator | Server starten |
|
||||
| POST | /api/servers/:id/start | moderator | Server starten (body: `{save: "name"}` fuer Factorio) |
|
||||
| POST | /api/servers/:id/stop | moderator | Server stoppen |
|
||||
| POST | /api/servers/:id/restart | moderator | Server neustarten |
|
||||
| GET | /api/servers/:id/logs | moderator | Console Logs |
|
||||
@@ -150,6 +153,21 @@ Frontend Backend
|
||||
| GET | /api/servers/:id/whitelist | optional | Whitelist (gecached) |
|
||||
| GET | /api/servers/:id/metrics/history | optional | Prometheus History |
|
||||
|
||||
### Factorio World Management
|
||||
|
||||
| Method | Endpoint | Auth | Beschreibung |
|
||||
|--------|----------|------|--------------|
|
||||
| GET | /api/servers/factorio/saves | moderator | Liste aller Saves |
|
||||
| GET | /api/servers/factorio/current-save | JWT | Aktuell geladener Save |
|
||||
| GET | /api/servers/factorio/presets | moderator | Map-Gen Presets + Defaults |
|
||||
| GET | /api/servers/factorio/presets/:name | moderator | Einzelnes Preset |
|
||||
| GET | /api/servers/factorio/templates | moderator | Gespeicherte Templates |
|
||||
| POST | /api/servers/factorio/templates | moderator | Template speichern |
|
||||
| DELETE | /api/servers/factorio/templates/:id | moderator | Template loeschen |
|
||||
| POST | /api/servers/factorio/create-world | moderator | Neue Welt erstellen |
|
||||
| DELETE | /api/servers/factorio/saves/:name | moderator | Save loeschen |
|
||||
| GET | /api/servers/factorio/saves/:name/settings | moderator | Erstellungs-Settings einer Welt |
|
||||
|
||||
---
|
||||
|
||||
## Rollensystem
|
||||
@@ -234,6 +252,20 @@ Frontend Backend
|
||||
- Bearbeitung nur wenn Server online
|
||||
- Cache wird bei jeder Aenderung aktualisiert
|
||||
|
||||
### Factorio World Management
|
||||
- **Worlds-Tab** in Server-Detail fuer Factorio-Server
|
||||
- **Gesperrte Verwaltung** wenn Server laeuft/startet/stoppt
|
||||
- **Save-Auswahl beim Start**: Klick auf "Start" bei einem Save startet den Server mit diesem Save
|
||||
- **Current-Save-Anzeige**: Im Overview-Tab wird angezeigt, welcher Save beim Starten geladen wird
|
||||
- **Neue Welt erstellen**:
|
||||
- Vollstaendiges Map-Generation-Formular (Terrain, Ressourcen, Gegner, Evolution)
|
||||
- Presets (Default, Rich Resources, Marathon, Death World, etc.)
|
||||
- Templates speichern und laden
|
||||
- Seed-Eingabe (optional)
|
||||
- **World Settings anzeigen**:
|
||||
- Info-Button bei jeder Welt zeigt Erstellungs-Einstellungen
|
||||
- Legacy-Fallback fuer Welten vor diesem Feature
|
||||
|
||||
### Navbar-Logo
|
||||
- Grau (navbarlogograuer.png) im Normalzustand
|
||||
- Weiss (navbarlogoweiss.png) bei Hover
|
||||
@@ -109,6 +109,9 @@ Internet
|
||||
- Minecraft Whitelist-Verwaltung mit serverseitigem Caching
|
||||
- Rollensystem: user, moderator, superadmin
|
||||
|
||||
**Todos:**
|
||||
- Auto-Shutdown: Gameserver automatisch abschalten wenn zu lange kein Spieler online war
|
||||
|
||||
**Prometheus Targets:**
|
||||
- localhost:9100 (monitor)
|
||||
- 192.168.2.50:9100 (factorio)
|
||||
@@ -156,6 +159,39 @@ Internet
|
||||
|
||||
---
|
||||
|
||||
### Project Zomboid Server (pzuser@10.0.30.66) - Extern via WireGuard
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|-------------|------|
|
||||
| Runtime | screen |
|
||||
| Screen Name | zomboid |
|
||||
| RCON Port | 27015 |
|
||||
| Pfad | /opt/pzserver |
|
||||
| Log | /home/pzuser/Zomboid/server-console.txt |
|
||||
| Netzwerk | Erreichbar via WireGuard-Tunnel "melih" |
|
||||
|
||||
---
|
||||
|
||||
## WireGuard VPN
|
||||
|
||||
Der Gameserver-Monitor (.30) nutzt einen WireGuard-Tunnel um externe Server (z.B. Zomboid auf 10.0.30.66) zu erreichen.
|
||||
|
||||
**Interface:** melih
|
||||
**Lokale IP:** 10.0.200.201/32
|
||||
|
||||
### WireGuard Watchdog
|
||||
|
||||
Ein Cronjob prueft alle 2 Minuten ob der Tunnel aktiv ist und startet ihn bei Bedarf neu.
|
||||
|
||||
| Komponente | Wert |
|
||||
|------------|------|
|
||||
| Script | /usr/local/bin/wg-watchdog.sh |
|
||||
| Cronjob | */2 * * * * |
|
||||
| Timeout | 5 Minuten ohne Handshake |
|
||||
| Log | /var/log/wg-watchdog.log |
|
||||
|
||||
---
|
||||
|
||||
## SSH-Zugang
|
||||
|
||||
Der Gameserver-Monitor (.30) hat SSH-Key-Zugang zu:
|
||||
@@ -170,12 +206,19 @@ Key: /root/.ssh/id_ed25519
|
||||
|
||||
## Wartung
|
||||
|
||||
### Backend neu starten
|
||||
```
|
||||
ssh root@192.168.2.30 "pkill -f 'node server.js'; cd /opt/gameserver-monitor/backend && node server.js &"
|
||||
### Backend (PM2)
|
||||
```bash
|
||||
# Status pruefen
|
||||
ssh root@192.168.2.30 "pm2 status"
|
||||
|
||||
# Logs anschauen
|
||||
ssh root@192.168.2.30 "pm2 logs gameserver-backend --lines 50"
|
||||
|
||||
# Neu starten
|
||||
ssh root@192.168.2.30 "pm2 restart gameserver-backend"
|
||||
```
|
||||
|
||||
### Frontend neu bauen
|
||||
```
|
||||
```bash
|
||||
ssh root@192.168.2.30 "cd /opt/gameserver-monitor/frontend && npm run build && nginx -s reload"
|
||||
```
|
||||
@@ -2,15 +2,6 @@
|
||||
|
||||
## Prioritaet Hoch
|
||||
|
||||
- [ ] **Pentest fuer Server durchfuehren**
|
||||
- [ ] Portscan aller Server (nmap)
|
||||
- [ ] SSH-Konfiguration pruefen (fail2ban, Key-Only)
|
||||
- [ ] RCON-Passwoerter auf Staerke pruefen
|
||||
- [ ] Firewall-Regeln auditieren
|
||||
- [ ] SSL/TLS-Konfiguration testen
|
||||
- [ ] Nginx-Sicherheitsheader pruefen
|
||||
- [ ] JWT-Secret Rotation implementieren
|
||||
|
||||
- [ ] **GSM Modularisierung & Wiederverwendbarkeit**
|
||||
- [ ] Server-Typen als Plugins auslagern (minecraft, factorio, vrising, ...)
|
||||
- [ ] Generisches Interface fuer neue Gameserver-Typen
|
||||
@@ -44,17 +35,3 @@
|
||||
- [ ] Player-Statistiken (Spielzeit, Join-History)
|
||||
- [ ] Changelog/Audit-Log fuer Admin-Aktionen
|
||||
|
||||
## Erledigt
|
||||
|
||||
- [x] ~~Admin-Passwort im GSM aenderbar~~ (UI)
|
||||
- [x] ~~JWT_SECRET sicher setzen~~
|
||||
- [x] ~~Prometheus + Grafana installieren~~
|
||||
- [x] ~~Grafana extern erreichbar~~
|
||||
- [x] ~~Benutzer-Verwaltung (Rollensystem)~~
|
||||
- [x] ~~Ressourcen-Graphen (CPU/RAM Historie)~~
|
||||
- [x] ~~Oeffentliches Dashboard~~
|
||||
- [x] ~~Whitelist-Caching serverseitig~~
|
||||
- [x] ~~Gameserver-Uptime statt Host-Uptime~~
|
||||
- [x] ~~Game-Logos in UI~~
|
||||
- [x] ~~Navbar-Logo mit Hover-Effekt~~
|
||||
- [x] ~~V Rising Server hinzugefuegt~~
|
||||
95
gsm-backend/config.json
Normal file
95
gsm-backend/config.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"ramBudget": 30,
|
||||
"servers": [
|
||||
{
|
||||
"id": "minecraft",
|
||||
"name": "All the Mods 10 | Minecraft",
|
||||
"host": "192.168.2.51",
|
||||
"type": "minecraft",
|
||||
"runtime": "screen",
|
||||
"maxRam": 12,
|
||||
"rconPort": 25575,
|
||||
"rconPassword": "gsm-mc-2026",
|
||||
"screenName": "minecraft",
|
||||
"workDir": "/opt/minecraft",
|
||||
"startCmd": "./run.sh"
|
||||
},
|
||||
{
|
||||
"id": "factorio",
|
||||
"name": "Factorio",
|
||||
"host": "192.168.2.50",
|
||||
"type": "factorio",
|
||||
"runtime": "docker",
|
||||
"maxRam": 4,
|
||||
"containerName": "factorio",
|
||||
"rconPort": 27015,
|
||||
"rconPassword": "jieTig6IkixaKuu"
|
||||
},
|
||||
{
|
||||
"id": "vrising",
|
||||
"name": "V Rising",
|
||||
"host": "192.168.2.52",
|
||||
"type": "vrising",
|
||||
"runtime": "systemd",
|
||||
"maxRam": 12,
|
||||
"serviceName": "vrising",
|
||||
"rconPort": 25575,
|
||||
"rconPassword": "changeme",
|
||||
"workDir": "/home/steam/vrising"
|
||||
},
|
||||
{
|
||||
"id": "zomboid",
|
||||
"name": "Project Zomboid",
|
||||
"host": "10.0.30.66",
|
||||
"type": "zomboid",
|
||||
"runtime": "screen",
|
||||
"external": true,
|
||||
"rconPort": 27015,
|
||||
"rconPassword": "ShkeloAufNettoParkplatzSchlagen47139",
|
||||
"screenName": "zomboid",
|
||||
"workDir": "/opt/pzserver",
|
||||
"startCmd": "./start-server.sh -servername Project",
|
||||
"sshUser": "pzuser",
|
||||
"logFile": "/home/pzuser/Zomboid/server-console.txt"
|
||||
},
|
||||
{
|
||||
"id": "palworld",
|
||||
"name": "Palworld",
|
||||
"host": "192.168.2.53",
|
||||
"type": "palworld",
|
||||
"runtime": "systemd",
|
||||
"maxRam": 12,
|
||||
"serviceName": "palworld",
|
||||
"rconPort": 25575,
|
||||
"rconPassword": "gsm-pal-admin-2026",
|
||||
"restApiPort": 8212,
|
||||
"workDir": "/opt/palworld",
|
||||
"configPath": "/opt/palworld/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini"
|
||||
},
|
||||
{
|
||||
"id": "terraria",
|
||||
"name": "Terraria",
|
||||
"host": "10.0.30.202",
|
||||
"type": "terraria",
|
||||
"runtime": "pm2",
|
||||
"external": true,
|
||||
"serviceName": "terraria",
|
||||
"sshUser": "terraria",
|
||||
"workDir": "/home/terraria/1449/Linux",
|
||||
"configPath": "/home/terraria/serverconfig.txt",
|
||||
"port": 7777
|
||||
},
|
||||
{
|
||||
"id": "openttd",
|
||||
"name": "OpenTTD",
|
||||
"host": "10.0.30.203",
|
||||
"type": "openttd",
|
||||
"runtime": "systemd",
|
||||
"external": true,
|
||||
"serviceName": "openttd",
|
||||
"sshUser": "openttd",
|
||||
"workDir": "/opt/openttd",
|
||||
"port": 3979
|
||||
}
|
||||
]
|
||||
}
|
||||
0
gsm-backend/config.tmp
Normal file
0
gsm-backend/config.tmp
Normal file
0
gsm-backend/db/database.sqlite
Normal file
0
gsm-backend/db/database.sqlite
Normal file
294
gsm-backend/db/init.js
Normal file
294
gsm-backend/db/init.js
Normal file
@@ -0,0 +1,294 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const db = new Database(join(__dirname, 'users.sqlite'));
|
||||
|
||||
const VALID_ROLES = ['user', 'moderator', 'superadmin'];
|
||||
|
||||
export function initDb() {
|
||||
// Create users table with role
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'moderator', 'superadmin')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: add role column if it doesn't exist
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
const hasRole = columns.some(col => col.name === 'role');
|
||||
|
||||
if (!hasRole) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'");
|
||||
// Upgrade existing admin user to superadmin
|
||||
db.prepare("UPDATE users SET role = 'superadmin' WHERE username = 'admin'").run();
|
||||
console.log('Migration: Added role column, admin upgraded to superadmin');
|
||||
}
|
||||
|
||||
// Create default admin if no users exist
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
if (count.count === 0) {
|
||||
const hash = bcrypt.hashSync('admin', 10);
|
||||
db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('admin', hash, 'superadmin');
|
||||
console.log('Default superadmin user created (username: admin, password: admin)');
|
||||
}
|
||||
}
|
||||
|
||||
export { db, VALID_ROLES };
|
||||
|
||||
// Whitelist cache table
|
||||
export function initWhitelistCache() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS whitelist_cache (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
players TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function getCachedWhitelist(serverId) {
|
||||
const row = db.prepare('SELECT players FROM whitelist_cache WHERE server_id = ?').get(serverId);
|
||||
return row ? JSON.parse(row.players) : [];
|
||||
}
|
||||
|
||||
export function setCachedWhitelist(serverId, players) {
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO whitelist_cache (server_id, players, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
`).run(serverId, JSON.stringify(players));
|
||||
}
|
||||
|
||||
// Factorio templates table
|
||||
export function initFactorioTemplates() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS factorio_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
settings TEXT NOT NULL,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function getFactorioTemplates() {
|
||||
return db.prepare(`
|
||||
SELECT t.id, t.name, t.settings, t.created_at, u.username as created_by_name
|
||||
FROM factorio_templates t
|
||||
LEFT JOIN users u ON t.created_by = u.id
|
||||
ORDER BY t.name
|
||||
`).all();
|
||||
}
|
||||
|
||||
export function getFactorioTemplate(id) {
|
||||
return db.prepare("SELECT * FROM factorio_templates WHERE id = ?").get(id);
|
||||
}
|
||||
|
||||
export function createFactorioTemplate(name, settings, userId) {
|
||||
const result = db.prepare(
|
||||
"INSERT INTO factorio_templates (name, settings, created_by) VALUES (?, ?, ?)"
|
||||
).run(name, JSON.stringify(settings), userId);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function deleteFactorioTemplate(id) {
|
||||
return db.prepare("DELETE FROM factorio_templates WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// Factorio world settings table (stores settings used when creating worlds)
|
||||
export function initFactorioWorldSettings() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS factorio_world_settings (
|
||||
save_name TEXT PRIMARY KEY,
|
||||
settings TEXT NOT NULL,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function getFactorioWorldSettings(saveName) {
|
||||
return db.prepare(
|
||||
"SELECT ws.*, u.username as created_by_name FROM factorio_world_settings ws LEFT JOIN users u ON ws.created_by = u.id WHERE ws.save_name = ?"
|
||||
).get(saveName);
|
||||
}
|
||||
|
||||
export function saveFactorioWorldSettings(saveName, settings, userId) {
|
||||
return db.prepare(
|
||||
"INSERT OR REPLACE INTO factorio_world_settings (save_name, settings, created_by, created_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)"
|
||||
).run(saveName, JSON.stringify(settings), userId);
|
||||
}
|
||||
|
||||
export function deleteFactorioWorldSettings(saveName) {
|
||||
return db.prepare("DELETE FROM factorio_world_settings WHERE save_name = ?").run(saveName);
|
||||
}
|
||||
|
||||
// Auto-shutdown settings table
|
||||
export function initAutoShutdownSettings() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS autoshutdown_settings (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
timeout_minutes INTEGER DEFAULT 15,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function getAutoShutdownSettings(serverId) {
|
||||
return db.prepare('SELECT * FROM autoshutdown_settings WHERE server_id = ?').get(serverId);
|
||||
}
|
||||
|
||||
export function getAllAutoShutdownSettings() {
|
||||
return db.prepare('SELECT * FROM autoshutdown_settings WHERE enabled = 1').all();
|
||||
}
|
||||
|
||||
export function setAutoShutdownSettings(serverId, enabled, timeoutMinutes) {
|
||||
return db.prepare(`
|
||||
INSERT OR REPLACE INTO autoshutdown_settings (server_id, enabled, timeout_minutes, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`).run(serverId, enabled ? 1 : 0, timeoutMinutes);
|
||||
}
|
||||
|
||||
// Discord users table
|
||||
export function initDiscordUsers() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS discord_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
discord_id TEXT UNIQUE NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
discriminator TEXT DEFAULT '0',
|
||||
avatar TEXT,
|
||||
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'moderator', 'superadmin')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// Activity Log
|
||||
export function initActivityLog() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
username TEXT NOT NULL,
|
||||
discord_id TEXT,
|
||||
avatar TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
details TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function logActivity(userId, username, action, target = null, details = null, discordId = null, avatar = null) {
|
||||
db.prepare(`
|
||||
INSERT INTO activity_log (user_id, username, discord_id, avatar, action, target, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, username, discordId, avatar, action, target, details);
|
||||
}
|
||||
|
||||
export function getActivityLog(limit = 100) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM activity_log ORDER BY created_at DESC LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
// Server display settings table
|
||||
export function initServerDisplaySettings() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS server_display_settings (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
address TEXT,
|
||||
hint TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function getServerDisplaySettings(serverId) {
|
||||
return db.prepare('SELECT * FROM server_display_settings WHERE server_id = ?').get(serverId);
|
||||
}
|
||||
|
||||
export function getAllServerDisplaySettings() {
|
||||
return db.prepare('SELECT * FROM server_display_settings').all();
|
||||
}
|
||||
|
||||
export function setServerDisplaySettings(serverId, address, hint) {
|
||||
return db.prepare(`
|
||||
INSERT OR REPLACE INTO server_display_settings (server_id, address, hint, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`).run(serverId, address, hint);
|
||||
}
|
||||
|
||||
// Guild settings for multi-server Discord bot
|
||||
export function initGuildSettings() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS guild_settings (
|
||||
guild_id TEXT PRIMARY KEY,
|
||||
category_id TEXT,
|
||||
info_channel_id TEXT,
|
||||
status_channel_id TEXT,
|
||||
status_message_id TEXT,
|
||||
alerts_channel_id TEXT,
|
||||
updates_channel_id TEXT,
|
||||
discussion_channel_id TEXT,
|
||||
requests_channel_id TEXT,
|
||||
requests_info_thread_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function getGuildSettings(guildId) {
|
||||
return db.prepare('SELECT * FROM guild_settings WHERE guild_id = ?').get(guildId);
|
||||
}
|
||||
|
||||
export function getAllGuildSettings() {
|
||||
return db.prepare('SELECT * FROM guild_settings').all();
|
||||
}
|
||||
|
||||
export function setGuildSettings(guildId, settings) {
|
||||
const existing = getGuildSettings(guildId);
|
||||
if (existing) {
|
||||
return db.prepare(`
|
||||
UPDATE guild_settings SET
|
||||
category_id = ?, info_channel_id = ?, status_channel_id = ?, status_message_id = ?,
|
||||
alerts_channel_id = ?, updates_channel_id = ?, discussion_channel_id = ?,
|
||||
requests_channel_id = ?, requests_info_thread_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE guild_id = ?
|
||||
`).run(
|
||||
settings.category_id, settings.info_channel_id, settings.status_channel_id,
|
||||
settings.status_message_id, settings.alerts_channel_id, settings.updates_channel_id,
|
||||
settings.discussion_channel_id, settings.requests_channel_id, settings.requests_info_thread_id,
|
||||
guildId
|
||||
);
|
||||
} else {
|
||||
return db.prepare(`
|
||||
INSERT INTO guild_settings (guild_id, category_id, info_channel_id, status_channel_id,
|
||||
status_message_id, alerts_channel_id, updates_channel_id, discussion_channel_id,
|
||||
requests_channel_id, requests_info_thread_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
guildId, settings.category_id, settings.info_channel_id, settings.status_channel_id,
|
||||
settings.status_message_id, settings.alerts_channel_id, settings.updates_channel_id,
|
||||
settings.discussion_channel_id, settings.requests_channel_id, settings.requests_info_thread_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteGuildSettings(guildId) {
|
||||
return db.prepare('DELETE FROM guild_settings WHERE guild_id = ?').run(guildId);
|
||||
}
|
||||
BIN
gsm-backend/db/users.sqlite
Normal file
BIN
gsm-backend/db/users.sqlite
Normal file
Binary file not shown.
57
gsm-backend/middleware/auth.js
Normal file
57
gsm-backend/middleware/auth.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const ROLE_HIERARCHY = {
|
||||
'user': 1,
|
||||
'moderator': 2,
|
||||
'superadmin': 3
|
||||
};
|
||||
|
||||
export function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Token required' });
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ error: 'Invalid token' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Optional authentication - doesn't fail if no token
|
||||
export function optionalAuth(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
|
||||
if (err) {
|
||||
req.user = null;
|
||||
} else {
|
||||
req.user = user;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
export function requireRole(minRole) {
|
||||
return (req, res, next) => {
|
||||
const userRole = req.user?.role || 'user';
|
||||
const userLevel = ROLE_HIERARCHY[userRole] || 0;
|
||||
const requiredLevel = ROLE_HIERARCHY[minRole] || 0;
|
||||
|
||||
if (userLevel < requiredLevel) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
2461
gsm-backend/package-lock.json
generated
Normal file
2461
gsm-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
gsm-backend/package.json
Normal file
22
gsm-backend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "gameserver-monitor-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"node-ssh": "^13.2.0",
|
||||
"rcon-client": "^4.2.4"
|
||||
}
|
||||
}
|
||||
244
gsm-backend/routes/auth.js
Normal file
244
gsm-backend/routes/auth.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Router } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db, VALID_ROLES, initDiscordUsers } from '../db/init.js';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth.js';
|
||||
import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getGuildMemberships, getUserRole, getUserRoleFromMemberships } from '../services/discord.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Initialize Discord users table
|
||||
initDiscordUsers();
|
||||
|
||||
// ===== Guest Login =====
|
||||
|
||||
// Create guest token (view-only, expires in 24h)
|
||||
router.post('/guest', (req, res) => {
|
||||
const guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: guestId,
|
||||
username: 'Gast',
|
||||
role: 'guest',
|
||||
isGuest: true
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
// ===== Discord OAuth2 =====
|
||||
|
||||
// Start Discord OAuth2 flow
|
||||
router.get('/discord', (req, res) => {
|
||||
res.redirect(getDiscordAuthUrl());
|
||||
});
|
||||
|
||||
// Discord OAuth2 callback
|
||||
router.get('/discord/callback', async (req, res) => {
|
||||
const { code, error } = req.query;
|
||||
|
||||
// Redirect URL for frontend
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://gsm.dimension47.de';
|
||||
|
||||
if (error) {
|
||||
return res.redirect(`${frontendUrl}/login?error=discord_denied`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return res.redirect(`${frontendUrl}/login?error=no_code`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for access token
|
||||
const tokenData = await exchangeCode(code);
|
||||
|
||||
// Get Discord user info
|
||||
const discordUser = await getDiscordUser(tokenData.access_token);
|
||||
|
||||
// Check if user is in any of the configured guilds
|
||||
const memberships = await getGuildMemberships(discordUser.id);
|
||||
|
||||
if (!memberships) {
|
||||
return res.redirect(`${frontendUrl}/login?error=not_in_guild`);
|
||||
}
|
||||
|
||||
// Determine role based on Discord roles (highest role from all servers)
|
||||
const role = getUserRoleFromMemberships(memberships);
|
||||
|
||||
// Use first membership for display name
|
||||
const member = memberships[0].member;
|
||||
|
||||
// Get display name (nickname or username)
|
||||
const displayName = member.nick || discordUser.global_name || discordUser.username;
|
||||
|
||||
// Upsert user in database
|
||||
const existingUser = db.prepare('SELECT * FROM discord_users WHERE discord_id = ?').get(discordUser.id);
|
||||
|
||||
let userId;
|
||||
if (existingUser) {
|
||||
// Update existing user
|
||||
db.prepare(`
|
||||
UPDATE discord_users
|
||||
SET username = ?, discriminator = ?, avatar = ?, role = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE discord_id = ?
|
||||
`).run(displayName, discordUser.discriminator || '0', discordUser.avatar, role, discordUser.id);
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
// Create new user
|
||||
const result = db.prepare(`
|
||||
INSERT INTO discord_users (discord_id, username, discriminator, avatar, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(discordUser.id, displayName, discordUser.discriminator || '0', discordUser.avatar, role);
|
||||
userId = result.lastInsertRowid;
|
||||
}
|
||||
|
||||
// Create JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: userId,
|
||||
discordId: discordUser.id,
|
||||
username: displayName,
|
||||
role,
|
||||
avatar: discordUser.avatar
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
// Redirect to frontend with token
|
||||
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Discord OAuth error:', err);
|
||||
res.redirect(`${frontendUrl}/login?error=oauth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user info
|
||||
router.get('/me', authenticateToken, (req, res) => {
|
||||
// Check if it's a guest user
|
||||
if (req.user.isGuest) {
|
||||
return res.json({
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
role: req.user.role,
|
||||
isGuest: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check if it's a Discord user
|
||||
if (req.user.discordId) {
|
||||
const user = db.prepare('SELECT id, discord_id, username, avatar, role FROM discord_users WHERE id = ?').get(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
return res.json({
|
||||
id: user.id,
|
||||
discordId: user.discord_id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
role: user.role
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback for old users (shouldn't happen after migration)
|
||||
const user = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
res.json({ id: user.id, username: user.username, role: user.role });
|
||||
});
|
||||
|
||||
// Refresh user role from Discord (useful if roles changed)
|
||||
router.post('/refresh-role', authenticateToken, async (req, res) => {
|
||||
if (!req.user.discordId) {
|
||||
return res.status(400).json({ error: 'Not a Discord user' });
|
||||
}
|
||||
|
||||
try {
|
||||
const memberships = await getGuildMemberships(req.user.discordId);
|
||||
|
||||
if (!memberships) {
|
||||
return res.status(403).json({ error: 'No longer in any guild' });
|
||||
}
|
||||
|
||||
const newRole = getUserRoleFromMemberships(memberships);
|
||||
|
||||
db.prepare('UPDATE discord_users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE discord_id = ?')
|
||||
.run(newRole, req.user.discordId);
|
||||
|
||||
// Generate new token with updated role
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: req.user.id,
|
||||
discordId: req.user.discordId,
|
||||
username: req.user.username,
|
||||
role: newRole,
|
||||
avatar: req.user.avatar
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({ token, role: newRole });
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh role:', err);
|
||||
res.status(500).json({ error: 'Failed to refresh role' });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== User Management (superadmin only) =====
|
||||
|
||||
// Get all Discord users
|
||||
router.get('/users', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT id, discord_id, username, avatar, role, created_at, updated_at
|
||||
FROM discord_users
|
||||
ORDER BY created_at DESC
|
||||
`).all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// Update user role (override Discord role)
|
||||
router.patch('/users/:id/role', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||
const userId = parseInt(req.params.id);
|
||||
const { role } = req.body;
|
||||
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot change your own role' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT id FROM discord_users WHERE id = ?').get(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE discord_users SET role = ? WHERE id = ?').run(role, userId);
|
||||
res.json({ message: 'Role updated' });
|
||||
});
|
||||
|
||||
// Delete user
|
||||
router.delete('/users/:id', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||
const userId = parseInt(req.params.id);
|
||||
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete yourself' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT id FROM discord_users WHERE id = ?').get(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM discord_users WHERE id = ?').run(userId);
|
||||
res.json({ message: 'User deleted' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
828
gsm-backend/routes/servers.js
Normal file
828
gsm-backend/routes/servers.js
Normal file
@@ -0,0 +1,828 @@
|
||||
import { Router } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js';
|
||||
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig } from '../services/ssh.js';
|
||||
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
|
||||
import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js';
|
||||
import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings, initGuildSettings } from '../db/init.js';
|
||||
import { getEmptySince } from '../services/autoshutdown.js';
|
||||
import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
|
||||
}
|
||||
// RAM Budget Checkasync function checkRamBudget(serverToStart) { const config = loadConfig(); const ramBudget = config.ramBudget || 30; let usedRam = 0; for (const server of config.servers) { if (server.id === serverToStart.id) continue; try { const status = await getServerStatus(server); if (status === "online") usedRam += server.maxRam || 0; } catch (err) {} } const serverRam = serverToStart.maxRam || 0; const availableRam = ramBudget - usedRam; return { canStart: availableRam >= serverRam, usedRam, serverRam, availableRam, ramBudget };}
|
||||
|
||||
// Initialize tables
|
||||
initWhitelistCache();
|
||||
initActivityLog();
|
||||
initFactorioTemplates();
|
||||
initFactorioWorldSettings();
|
||||
initServerDisplaySettings();
|
||||
initGuildSettings();
|
||||
|
||||
const router = Router();
|
||||
|
||||
function formatBytes(bytes, forceUnit = null) {
|
||||
if (bytes === 0) return { value: 0, unit: forceUnit || "B" };
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (forceUnit === "GB") return { value: gb, unit: "GB" };
|
||||
if (forceUnit === "MB") return { value: mb, unit: "MB" };
|
||||
if (gb >= 1) return { value: gb, unit: "GB" };
|
||||
return { value: mb, unit: "MB" };
|
||||
}
|
||||
|
||||
// ============ FACTORIO ROUTES ============
|
||||
|
||||
// Factorio: List saves
|
||||
router.get("/factorio/saves", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const saves = await listFactorioSaves(server);
|
||||
res.json({ saves });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get presets and default settings
|
||||
router.get("/factorio/presets", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const presets = getPresetNames();
|
||||
const defaultSettings = getDefaultMapGenSettings();
|
||||
res.json({ presets, defaultSettings });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get preset by name
|
||||
router.get("/factorio/presets/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const preset = getPreset(req.params.name);
|
||||
res.json({ settings: preset });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: List templates
|
||||
router.get("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const templates = getFactorioTemplates();
|
||||
res.json({ templates: templates.map(t => ({ ...t, settings: JSON.parse(t.settings) })) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Create template
|
||||
router.post("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { name, settings } = req.body;
|
||||
if (!name || !settings) {
|
||||
return res.status(400).json({ error: "Name and settings required" });
|
||||
}
|
||||
const id = createFactorioTemplate(name, settings, req.user.id);
|
||||
res.json({ id, message: "Template created" });
|
||||
} catch (err) {
|
||||
if (err.message.includes("UNIQUE constraint")) {
|
||||
return res.status(400).json({ error: "Template name already exists" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Delete template
|
||||
router.delete("/factorio/templates/:id", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const result = deleteFactorioTemplate(parseInt(req.params.id));
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Template not found" });
|
||||
}
|
||||
res.json({ message: "Template deleted" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Create new world
|
||||
router.post("/factorio/create-world", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { saveName, settings } = req.body;
|
||||
if (!saveName) {
|
||||
return res.status(400).json({ error: "Save name required" });
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const finalSettings = settings || getDefaultMapGenSettings();
|
||||
await createFactorioWorld(server, saveName, finalSettings);
|
||||
|
||||
// Save settings to database for later reference
|
||||
saveFactorioWorldSettings(saveName, finalSettings, req.user.id);
|
||||
logActivity(req.user.id, req.user.username, 'factorio_world_create', 'factorio', saveName, req.user.discordId, req.user.avatar);
|
||||
|
||||
res.json({ message: "World created", saveName });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Delete save
|
||||
router.delete("/factorio/saves/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
await deleteFactorioSave(server, req.params.name);
|
||||
// Also delete stored settings if they exist
|
||||
deleteFactorioWorldSettings(req.params.name);
|
||||
logActivity(req.user.id, req.user.username, 'factorio_world_delete', 'factorio', req.params.name, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Save deleted" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get world settings
|
||||
router.get("/factorio/saves/:name/settings", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const settings = getFactorioWorldSettings(req.params.name);
|
||||
if (!settings) {
|
||||
return res.json({
|
||||
legacy: true,
|
||||
message: "This is a legacy world created before settings tracking was implemented"
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
legacy: false,
|
||||
settings: JSON.parse(settings.settings),
|
||||
createdBy: settings.created_by,
|
||||
createdAt: settings.created_at
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get current/default save
|
||||
router.get("/factorio/current-save", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const result = await getFactorioCurrentSave(server);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ ZOMBOID CONFIG ROUTES ============
|
||||
|
||||
// Zomboid: List config files
|
||||
router.get("/zomboid/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
if (isHostFailed(server.host, server.sshUser)) {
|
||||
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
|
||||
}
|
||||
|
||||
const files = await listZomboidConfigs(server);
|
||||
res.json({ files });
|
||||
} catch (err) {
|
||||
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Zomboid: Read config file
|
||||
router.get("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
const content = await readZomboidConfig(server, req.params.filename);
|
||||
res.json({ filename: req.params.filename, content });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Zomboid: Write config file
|
||||
router.put("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
const { content } = req.body;
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({ error: "Content required" });
|
||||
}
|
||||
|
||||
await writeZomboidConfig(server, req.params.filename, content);
|
||||
logActivity(req.user.id, req.user.username, 'zomboid_config', 'zomboid', req.params.filename, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Config saved", filename: req.params.filename });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Palworld: List config files
|
||||
router.get("/palworld/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const files = await listPalworldConfigs(server);
|
||||
res.json({ files });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Palworld: Read config file
|
||||
router.get("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const content = await readPalworldConfig(server, req.params.filename);
|
||||
res.json({ filename: req.params.filename, content });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Palworld: Write config file
|
||||
router.put("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const { content } = req.body;
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({ error: "Content required" });
|
||||
}
|
||||
await writePalworldConfig(server, req.params.filename, content);
|
||||
logActivity(req.user.id, req.user.username, "palworld_config", "palworld", req.params.filename, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Config saved", filename: req.params.filename });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ GENERAL ROUTES ============
|
||||
|
||||
// Get all servers with status
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const servers = await Promise.all(config.servers.map(async (server) => {
|
||||
// Quick check if host is unreachable - skip expensive operations
|
||||
const hostUnreachable = isHostFailed(server.host, server.sshUser);
|
||||
// If host is unreachable, return immediately with minimal data
|
||||
if (hostUnreachable) {
|
||||
const metrics = await getCurrentMetrics(server.id).catch(() => ({
|
||||
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||
}));
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status: "unreachable",
|
||||
running: false,
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: 0
|
||||
},
|
||||
players: { online: 0, max: null, list: [] },
|
||||
hasRcon: !!server.rconPassword
|
||||
};
|
||||
}
|
||||
|
||||
const [status, metrics, players, playerList, processUptime] = await Promise.all([
|
||||
getServerStatus(server),
|
||||
getCurrentMetrics(server.id).catch(() => ({
|
||||
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||
})),
|
||||
server.rconPassword ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null },
|
||||
server.rconPassword ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] },
|
||||
getProcessUptime(server).catch(() => 0)
|
||||
]);
|
||||
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
|
||||
// Get auto-shutdown info
|
||||
const shutdownSettings = getAutoShutdownSettings(server.id);
|
||||
const emptySince = getEmptySince(server.id);
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status,
|
||||
running: status === 'online',
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: processUptime
|
||||
},
|
||||
players: {
|
||||
...players,
|
||||
list: playerList.players
|
||||
},
|
||||
hasRcon: !!server.rconPassword,
|
||||
autoShutdown: {
|
||||
enabled: shutdownSettings?.enabled === 1 || false,
|
||||
timeoutMinutes: shutdownSettings?.timeout_minutes || 15,
|
||||
emptySinceMinutes: emptySince
|
||||
}
|
||||
};
|
||||
}));
|
||||
|
||||
res.json(servers);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Activity Log (superadmin only)
|
||||
router.get('/activity-log', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const logs = getActivityLog(limit);
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all server display settings (for ServerCard)
|
||||
router.get("/display-settings", optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = getAllServerDisplaySettings();
|
||||
const result = {};
|
||||
settings.forEach(s => {
|
||||
result[s.server_id] = { address: s.address, hint: s.hint };
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ TERRARIA ROUTES ============
|
||||
|
||||
// Get Terraria config
|
||||
router.get("/terraria/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === "terraria");
|
||||
if (!server) return res.status(404).json({ error: "Server not found" });
|
||||
|
||||
if (isHostFailed(server.host, server.sshUser)) {
|
||||
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
|
||||
}
|
||||
|
||||
const content = await readTerrariaConfig(server);
|
||||
res.json({ content });
|
||||
} catch (error) {
|
||||
console.error("Error reading Terraria config:", error);
|
||||
if (error.message.includes('unreachable') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Save Terraria config
|
||||
router.put("/terraria/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === "terraria");
|
||||
if (!server) return res.status(404).json({ error: "Server not found" });
|
||||
const { content } = req.body;
|
||||
if (!content) return res.status(400).json({ error: "Content required" });
|
||||
await writeTerrariaConfig(server, content);
|
||||
logActivity(req.user.id, req.user.username, "terraria_config", "terraria", "serverconfig.txt", req.user.discordId, req.user.avatar);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error writing Terraria config:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ OPENTTD ROUTES ============
|
||||
|
||||
// Get OpenTTD config
|
||||
router.get("/openttd/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === "openttd");
|
||||
if (!server) return res.status(404).json({ error: "Server not found" });
|
||||
|
||||
if (isHostFailed(server.host, server.sshUser)) {
|
||||
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
|
||||
}
|
||||
|
||||
const content = await readOpenTTDConfig(server);
|
||||
res.json({ content });
|
||||
} catch (error) {
|
||||
console.error("Error reading OpenTTD config:", error);
|
||||
if (error.message.includes('unreachable') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Save OpenTTD config
|
||||
router.put("/openttd/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === "openttd");
|
||||
if (!server) return res.status(404).json({ error: "Server not found" });
|
||||
const { content } = req.body;
|
||||
if (!content) return res.status(400).json({ error: "Content required" });
|
||||
await writeOpenTTDConfig(server, content);
|
||||
logActivity(req.user.id, req.user.username, "openttd_config", "openttd", "openttd.cfg", req.user.discordId, req.user.avatar);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error writing OpenTTD config:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Get single server
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if host is unreachable
|
||||
const hostUnreachable = isHostFailed(server.host, server.sshUser);
|
||||
if (hostUnreachable) {
|
||||
const metrics = await getCurrentMetrics(server.id).catch(() => ({
|
||||
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||
}));
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
return res.json({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status: "unreachable",
|
||||
running: false,
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: 0
|
||||
},
|
||||
players: { online: 0, max: null, list: [] },
|
||||
hasRcon: !!server.rconPassword
|
||||
});
|
||||
}
|
||||
|
||||
const [status, metrics, players, playerList, processUptime] = await Promise.all([
|
||||
getServerStatus(server),
|
||||
getCurrentMetrics(server.id).catch(() => ({
|
||||
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||
})),
|
||||
server.rconPassword ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null },
|
||||
server.rconPassword ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] },
|
||||
getProcessUptime(server).catch(() => 0)
|
||||
]);
|
||||
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
|
||||
res.json({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status,
|
||||
running: status === 'online',
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: processUptime
|
||||
},
|
||||
players: {
|
||||
...players,
|
||||
list: playerList.players
|
||||
},
|
||||
hasRcon: !!server.rconPassword
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error fetching server ${req.params.id}:`, err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get metrics history from Prometheus
|
||||
router.get('/:id/metrics/history', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const range = req.query.range || '1h';
|
||||
const validRanges = ['15m', '1h', '6h', '24h'];
|
||||
if (!validRanges.includes(range)) {
|
||||
return res.status(400).json({ error: 'Invalid range. Valid: 15m, 1h, 6h, 24h' });
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await getServerMetricsHistory(server.id, range);
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get player list
|
||||
router.get('/:id/players', authenticateToken, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.status(400).json({ error: 'RCON not configured for this server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [count, list] = await Promise.all([
|
||||
getPlayers(server),
|
||||
getPlayerList(server)
|
||||
]);
|
||||
res.json({ ...count, list: list.players });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get console logs (moderator+)
|
||||
router.get('/:id/logs', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (isHostFailed(server.host, server.sshUser)) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = parseInt(req.query.lines) || 100;
|
||||
const logs = await getConsoleLog(server, lines);
|
||||
res.json({ logs });
|
||||
} catch (err) {
|
||||
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Power actions (moderator+)
|
||||
router.post('/:id/start', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (isHostFailed(server.host, server.sshUser)) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const { save } = req.body || {};
|
||||
await startServer(server, { save });
|
||||
logActivity(req.user.id, req.user.username, 'server_start', server.id, save ? 'Save: ' + save : null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server starting' });
|
||||
} catch (err) {
|
||||
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/stop', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (isHostFailed(server.host, server.sshUser)) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await stopServer(server);
|
||||
logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server stopping' });
|
||||
} catch (err) {
|
||||
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (isHostFailed(server.host, server.sshUser)) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await restartServer(server);
|
||||
logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server restarting' });
|
||||
} catch (err) {
|
||||
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
|
||||
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get whitelist (with server-side caching)
|
||||
router.get('/:id/whitelist', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.json({ players: [], cached: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendRconCommand(server, 'whitelist list');
|
||||
const match = response.trim().match(/:\s*(.+)$/);
|
||||
let players = [];
|
||||
if (match && match[1]) {
|
||||
players = match[1].split(',').map(p => p.trim()).filter(p => p.length > 0);
|
||||
}
|
||||
setCachedWhitelist(server.id, players);
|
||||
res.json({ players, cached: false });
|
||||
} catch (err) {
|
||||
const players = getCachedWhitelist(server.id);
|
||||
res.json({ players, cached: true });
|
||||
}
|
||||
});
|
||||
|
||||
// RCON command (moderator+)
|
||||
router.post('/:id/rcon', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.status(400).json({ error: 'RCON not configured for this server' });
|
||||
}
|
||||
|
||||
const { command } = req.body;
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendRconCommand(server, command);
|
||||
logActivity(req.user.id, req.user.username, 'rcon_command', server.id, command, req.user.discordId, req.user.avatar);
|
||||
if (command.startsWith("whitelist ")) {
|
||||
try {
|
||||
const listResponse = await sendRconCommand(server, "whitelist list");
|
||||
const match = listResponse.trim().match(/:\s*(.+)$/);
|
||||
let players = [];
|
||||
if (match && match[1]) {
|
||||
players = match[1].split(",").map(p => p.trim()).filter(p => p.length > 0);
|
||||
}
|
||||
setCachedWhitelist(server.id, players);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
res.json({ response });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Initialize auto-shutdown settings table
|
||||
initAutoShutdownSettings();
|
||||
|
||||
// ============ AUTO-SHUTDOWN ROUTES ============
|
||||
|
||||
// Get auto-shutdown settings for a server
|
||||
router.get('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const settings = getAutoShutdownSettings(req.params.id);
|
||||
const emptySince = getEmptySince(req.params.id);
|
||||
|
||||
res.json({
|
||||
enabled: settings?.enabled === 1 || false,
|
||||
timeoutMinutes: settings?.timeout_minutes || 15,
|
||||
emptySinceMinutes: emptySince
|
||||
});
|
||||
});
|
||||
|
||||
// Update auto-shutdown settings for a server
|
||||
router.put('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const { enabled, timeoutMinutes } = req.body;
|
||||
const timeout = Math.max(1, Math.min(1440, timeoutMinutes || 15));
|
||||
|
||||
setAutoShutdownSettings(req.params.id, enabled, timeout);
|
||||
logActivity(req.user.id, req.user.username, 'autoshutdown_config', req.params.id, 'Enabled: ' + enabled + ', Timeout: ' + timeout + ' min', req.user.discordId, req.user.avatar);
|
||||
console.log('[AutoShutdown] Settings updated for ' + req.params.id + ': enabled=' + enabled + ', timeout=' + timeout + 'min');
|
||||
|
||||
res.json({ message: 'Auto-shutdown settings updated', enabled, timeoutMinutes: timeout });
|
||||
});
|
||||
|
||||
|
||||
// Get display settings for a specific server
|
||||
router.get("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
|
||||
try {
|
||||
const settings = getServerDisplaySettings(req.params.id);
|
||||
res.json(settings || { server_id: req.params.id, address: "", hint: "" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update display settings for a server (superadmin only)
|
||||
router.put("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
|
||||
const { address, hint } = req.body;
|
||||
try {
|
||||
setServerDisplaySettings(req.params.id, address || "", hint || "");
|
||||
res.json({ message: "Display settings updated", address, hint });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
679
gsm-backend/routes/servers.js.bak
Normal file
679
gsm-backend/routes/servers.js.bak
Normal file
@@ -0,0 +1,679 @@
|
||||
import { Router } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js';
|
||||
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig } from '../services/ssh.js';
|
||||
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
|
||||
import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js';
|
||||
import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings, initGuildSettings } from '../db/init.js';
|
||||
import { getEmptySince } from '../services/autoshutdown.js';
|
||||
import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
|
||||
}
|
||||
// RAM Budget Checkasync function checkRamBudget(serverToStart) { const config = loadConfig(); const ramBudget = config.ramBudget || 30; let usedRam = 0; for (const server of config.servers) { if (server.id === serverToStart.id) continue; try { const status = await getServerStatus(server); if (status === "online") usedRam += server.maxRam || 0; } catch (err) {} } const serverRam = serverToStart.maxRam || 0; const availableRam = ramBudget - usedRam; return { canStart: availableRam >= serverRam, usedRam, serverRam, availableRam, ramBudget };}
|
||||
|
||||
// Initialize tables
|
||||
initWhitelistCache();
|
||||
initActivityLog();
|
||||
initFactorioTemplates();
|
||||
initFactorioWorldSettings();
|
||||
initServerDisplaySettings();
|
||||
initGuildSettings();
|
||||
|
||||
const router = Router();
|
||||
|
||||
function formatBytes(bytes, forceUnit = null) {
|
||||
if (bytes === 0) return { value: 0, unit: forceUnit || "B" };
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (forceUnit === "GB") return { value: gb, unit: "GB" };
|
||||
if (forceUnit === "MB") return { value: mb, unit: "MB" };
|
||||
if (gb >= 1) return { value: gb, unit: "GB" };
|
||||
return { value: mb, unit: "MB" };
|
||||
}
|
||||
|
||||
// ============ FACTORIO ROUTES ============
|
||||
|
||||
// Factorio: List saves
|
||||
router.get("/factorio/saves", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const saves = await listFactorioSaves(server);
|
||||
res.json({ saves });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get presets and default settings
|
||||
router.get("/factorio/presets", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const presets = getPresetNames();
|
||||
const defaultSettings = getDefaultMapGenSettings();
|
||||
res.json({ presets, defaultSettings });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get preset by name
|
||||
router.get("/factorio/presets/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const preset = getPreset(req.params.name);
|
||||
res.json({ settings: preset });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: List templates
|
||||
router.get("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const templates = getFactorioTemplates();
|
||||
res.json({ templates: templates.map(t => ({ ...t, settings: JSON.parse(t.settings) })) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Create template
|
||||
router.post("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { name, settings } = req.body;
|
||||
if (!name || !settings) {
|
||||
return res.status(400).json({ error: "Name and settings required" });
|
||||
}
|
||||
const id = createFactorioTemplate(name, settings, req.user.id);
|
||||
res.json({ id, message: "Template created" });
|
||||
} catch (err) {
|
||||
if (err.message.includes("UNIQUE constraint")) {
|
||||
return res.status(400).json({ error: "Template name already exists" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Delete template
|
||||
router.delete("/factorio/templates/:id", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const result = deleteFactorioTemplate(parseInt(req.params.id));
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Template not found" });
|
||||
}
|
||||
res.json({ message: "Template deleted" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Create new world
|
||||
router.post("/factorio/create-world", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { saveName, settings } = req.body;
|
||||
if (!saveName) {
|
||||
return res.status(400).json({ error: "Save name required" });
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const finalSettings = settings || getDefaultMapGenSettings();
|
||||
await createFactorioWorld(server, saveName, finalSettings);
|
||||
|
||||
// Save settings to database for later reference
|
||||
saveFactorioWorldSettings(saveName, finalSettings, req.user.id);
|
||||
logActivity(req.user.id, req.user.username, 'factorio_world_create', 'factorio', saveName, req.user.discordId, req.user.avatar);
|
||||
|
||||
res.json({ message: "World created", saveName });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Delete save
|
||||
router.delete("/factorio/saves/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
await deleteFactorioSave(server, req.params.name);
|
||||
// Also delete stored settings if they exist
|
||||
deleteFactorioWorldSettings(req.params.name);
|
||||
logActivity(req.user.id, req.user.username, 'factorio_world_delete', 'factorio', req.params.name, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Save deleted" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get world settings
|
||||
router.get("/factorio/saves/:name/settings", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const settings = getFactorioWorldSettings(req.params.name);
|
||||
if (!settings) {
|
||||
return res.json({
|
||||
legacy: true,
|
||||
message: "This is a legacy world created before settings tracking was implemented"
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
legacy: false,
|
||||
settings: JSON.parse(settings.settings),
|
||||
createdBy: settings.created_by,
|
||||
createdAt: settings.created_at
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get current/default save
|
||||
router.get("/factorio/current-save", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const result = await getFactorioCurrentSave(server);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ ZOMBOID CONFIG ROUTES ============
|
||||
|
||||
// Zomboid: List config files
|
||||
router.get("/zomboid/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
const files = await listZomboidConfigs(server);
|
||||
res.json({ files });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Zomboid: Read config file
|
||||
router.get("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
const content = await readZomboidConfig(server, req.params.filename);
|
||||
res.json({ filename: req.params.filename, content });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Zomboid: Write config file
|
||||
router.put("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
const { content } = req.body;
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({ error: "Content required" });
|
||||
}
|
||||
|
||||
await writeZomboidConfig(server, req.params.filename, content);
|
||||
logActivity(req.user.id, req.user.username, 'zomboid_config', 'zomboid', req.params.filename, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Config saved", filename: req.params.filename });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Palworld: List config files
|
||||
router.get("/palworld/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const files = await listPalworldConfigs(server);
|
||||
res.json({ files });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Palworld: Read config file
|
||||
router.get("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const content = await readPalworldConfig(server, req.params.filename);
|
||||
res.json({ filename: req.params.filename, content });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Palworld: Write config file
|
||||
router.put("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const { content } = req.body;
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({ error: "Content required" });
|
||||
}
|
||||
await writePalworldConfig(server, req.params.filename, content);
|
||||
logActivity(req.user.id, req.user.username, "palworld_config", "palworld", req.params.filename, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Config saved", filename: req.params.filename });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ GENERAL ROUTES ============
|
||||
|
||||
// Get all servers with status
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const servers = await Promise.all(config.servers.map(async (server) => {
|
||||
// Quick check if host is unreachable - skip expensive operations
|
||||
const hostUnreachable = isHostFailed(server.host, server.sshUser);
|
||||
// If host is unreachable, return immediately with minimal data
|
||||
if (hostUnreachable) {
|
||||
const metrics = await getCurrentMetrics(server.id).catch(() => ({
|
||||
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||
}));
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status: "unreachable",
|
||||
running: false,
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: 0
|
||||
},
|
||||
players: { online: 0, max: null, list: [] },
|
||||
hasRcon: !!server.rconPassword
|
||||
};
|
||||
}
|
||||
|
||||
const [status, metrics, players, playerList, processUptime] = await Promise.all([
|
||||
getServerStatus(server),
|
||||
getCurrentMetrics(server.id).catch(() => ({
|
||||
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||
})),
|
||||
server.rconPassword ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null },
|
||||
server.rconPassword ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] },
|
||||
getProcessUptime(server).catch(() => 0)
|
||||
]);
|
||||
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
|
||||
// Get auto-shutdown info
|
||||
const shutdownSettings = getAutoShutdownSettings(server.id);
|
||||
const emptySince = getEmptySince(server.id);
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status,
|
||||
running: status === 'online',
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: processUptime
|
||||
},
|
||||
players: {
|
||||
...players,
|
||||
list: playerList.players
|
||||
},
|
||||
hasRcon: !!server.rconPassword,
|
||||
autoShutdown: {
|
||||
enabled: shutdownSettings?.enabled === 1 || false,
|
||||
timeoutMinutes: shutdownSettings?.timeout_minutes || 15,
|
||||
emptySinceMinutes: emptySince
|
||||
}
|
||||
};
|
||||
}));
|
||||
|
||||
res.json(servers);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Activity Log (superadmin only)
|
||||
router.get('/activity-log', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const logs = getActivityLog(limit);
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all server display settings (for ServerCard)
|
||||
router.get("/display-settings", optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = getAllServerDisplaySettings();
|
||||
const result = {};
|
||||
settings.forEach(s => {
|
||||
result[s.server_id] = { address: s.address, hint: s.hint };
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single server
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [status, metrics, players, playerList, processUptime] = await Promise.all([
|
||||
getServerStatus(server),
|
||||
getCurrentMetrics(server.id),
|
||||
server.rconPassword ? getPlayers(server) : { online: 0, max: null },
|
||||
server.rconPassword ? getPlayerList(server) : { players: [] },
|
||||
getProcessUptime(server).catch(() => 0)
|
||||
]);
|
||||
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
|
||||
res.json({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status,
|
||||
running: status === 'online',
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: processUptime
|
||||
},
|
||||
players: {
|
||||
...players,
|
||||
list: playerList.players
|
||||
},
|
||||
hasRcon: !!server.rconPassword
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get metrics history from Prometheus
|
||||
router.get('/:id/metrics/history', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const range = req.query.range || '1h';
|
||||
const validRanges = ['15m', '1h', '6h', '24h'];
|
||||
if (!validRanges.includes(range)) {
|
||||
return res.status(400).json({ error: 'Invalid range. Valid: 15m, 1h, 6h, 24h' });
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await getServerMetricsHistory(server.id, range);
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get player list
|
||||
router.get('/:id/players', authenticateToken, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.status(400).json({ error: 'RCON not configured for this server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [count, list] = await Promise.all([
|
||||
getPlayers(server),
|
||||
getPlayerList(server)
|
||||
]);
|
||||
res.json({ ...count, list: list.players });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get console logs (moderator+)
|
||||
router.get('/:id/logs', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
try {
|
||||
const lines = parseInt(req.query.lines) || 100;
|
||||
const logs = await getConsoleLog(server, lines);
|
||||
res.json({ logs });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Power actions (moderator+)
|
||||
router.post('/:id/start', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
try {
|
||||
const { save } = req.body || {};
|
||||
await startServer(server, { save });
|
||||
logActivity(req.user.id, req.user.username, 'server_start', server.id, save ? 'Save: ' + save : null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server starting' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/stop', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
try {
|
||||
await stopServer(server);
|
||||
logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server stopping' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
try {
|
||||
await restartServer(server);
|
||||
logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server restarting' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get whitelist (with server-side caching)
|
||||
router.get('/:id/whitelist', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.json({ players: [], cached: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendRconCommand(server, 'whitelist list');
|
||||
const match = response.trim().match(/:\s*(.+)$/);
|
||||
let players = [];
|
||||
if (match && match[1]) {
|
||||
players = match[1].split(',').map(p => p.trim()).filter(p => p.length > 0);
|
||||
}
|
||||
setCachedWhitelist(server.id, players);
|
||||
res.json({ players, cached: false });
|
||||
} catch (err) {
|
||||
const players = getCachedWhitelist(server.id);
|
||||
res.json({ players, cached: true });
|
||||
}
|
||||
});
|
||||
|
||||
// RCON command (moderator+)
|
||||
router.post('/:id/rcon', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.status(400).json({ error: 'RCON not configured for this server' });
|
||||
}
|
||||
|
||||
const { command } = req.body;
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendRconCommand(server, command);
|
||||
logActivity(req.user.id, req.user.username, 'rcon_command', server.id, command, req.user.discordId, req.user.avatar);
|
||||
if (command.startsWith("whitelist ")) {
|
||||
try {
|
||||
const listResponse = await sendRconCommand(server, "whitelist list");
|
||||
const match = listResponse.trim().match(/:\s*(.+)$/);
|
||||
let players = [];
|
||||
if (match && match[1]) {
|
||||
players = match[1].split(",").map(p => p.trim()).filter(p => p.length > 0);
|
||||
}
|
||||
setCachedWhitelist(server.id, players);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
res.json({ response });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Initialize auto-shutdown settings table
|
||||
initAutoShutdownSettings();
|
||||
|
||||
// ============ AUTO-SHUTDOWN ROUTES ============
|
||||
|
||||
// Get auto-shutdown settings for a server
|
||||
router.get('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const settings = getAutoShutdownSettings(req.params.id);
|
||||
const emptySince = getEmptySince(req.params.id);
|
||||
|
||||
res.json({
|
||||
enabled: settings?.enabled === 1 || false,
|
||||
timeoutMinutes: settings?.timeout_minutes || 15,
|
||||
emptySinceMinutes: emptySince
|
||||
});
|
||||
});
|
||||
|
||||
// Update auto-shutdown settings for a server
|
||||
router.put('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const { enabled, timeoutMinutes } = req.body;
|
||||
const timeout = Math.max(1, Math.min(1440, timeoutMinutes || 15));
|
||||
|
||||
setAutoShutdownSettings(req.params.id, enabled, timeout);
|
||||
logActivity(req.user.id, req.user.username, 'autoshutdown_config', req.params.id, 'Enabled: ' + enabled + ', Timeout: ' + timeout + ' min', req.user.discordId, req.user.avatar);
|
||||
console.log('[AutoShutdown] Settings updated for ' + req.params.id + ': enabled=' + enabled + ', timeout=' + timeout + 'min');
|
||||
|
||||
res.json({ message: 'Auto-shutdown settings updated', enabled, timeoutMinutes: timeout });
|
||||
});
|
||||
|
||||
|
||||
// Get display settings for a specific server
|
||||
router.get("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
|
||||
try {
|
||||
const settings = getServerDisplaySettings(req.params.id);
|
||||
res.json(settings || { server_id: req.params.id, address: "", hint: "" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update display settings for a server (superadmin only)
|
||||
router.put("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
|
||||
const { address, hint } = req.body;
|
||||
try {
|
||||
setServerDisplaySettings(req.params.id, address || "", hint || "");
|
||||
res.json({ message: "Display settings updated", address, hint });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
37
gsm-backend/server.js
Normal file
37
gsm-backend/server.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { config } from 'dotenv';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import serverRoutes from './routes/servers.js';
|
||||
import { initDb } from './db/init.js';
|
||||
import { startAutoShutdownService } from './services/autoshutdown.js';
|
||||
import { initDiscordBot } from './services/discordBot.js';
|
||||
|
||||
config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize database
|
||||
initDb();
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/servers', serverRoutes);
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
|
||||
// Start auto-shutdown service
|
||||
startAutoShutdownService();
|
||||
|
||||
// Start Discord bot
|
||||
initDiscordBot();
|
||||
});
|
||||
113
gsm-backend/services/autoshutdown.js
Normal file
113
gsm-backend/services/autoshutdown.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getServerStatus, stopServer } from './ssh.js';
|
||||
import { getPlayers } from './rcon.js';
|
||||
import { getAllAutoShutdownSettings, getAutoShutdownSettings } from '../db/init.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Track when each server became empty
|
||||
const emptyPlayersSince = new Map();
|
||||
|
||||
// Check interval in ms (60 seconds)
|
||||
const CHECK_INTERVAL = 60000;
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
|
||||
}
|
||||
|
||||
async function checkServers() {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const enabledSettings = getAllAutoShutdownSettings();
|
||||
|
||||
// Create a map for quick lookup
|
||||
const settingsMap = new Map(enabledSettings.map(s => [s.server_id, s]));
|
||||
|
||||
for (const server of config.servers) {
|
||||
const settings = settingsMap.get(server.id);
|
||||
|
||||
// Skip if auto-shutdown not enabled for this server
|
||||
if (!settings || !settings.enabled) {
|
||||
emptyPlayersSince.delete(server.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if server is online
|
||||
const status = await getServerStatus(server);
|
||||
|
||||
if (status !== 'online') {
|
||||
// Server not running, clear timer
|
||||
emptyPlayersSince.delete(server.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get player count
|
||||
let playerCount = 0;
|
||||
if (server.rconPassword) {
|
||||
const players = await getPlayers(server);
|
||||
playerCount = players.online || 0;
|
||||
}
|
||||
|
||||
if (playerCount === 0) {
|
||||
// No players online
|
||||
if (!emptyPlayersSince.has(server.id)) {
|
||||
// Start tracking empty time
|
||||
emptyPlayersSince.set(server.id, Date.now());
|
||||
console.log(`[AutoShutdown] ${server.id}: Keine Spieler online, Timer gestartet`);
|
||||
}
|
||||
|
||||
const emptyMs = Date.now() - emptyPlayersSince.get(server.id);
|
||||
const emptyMinutes = emptyMs / 60000;
|
||||
|
||||
if (emptyMinutes >= settings.timeout_minutes) {
|
||||
console.log(`[AutoShutdown] ${server.id}: Timeout erreicht (${settings.timeout_minutes} Min), stoppe Server...`);
|
||||
await stopServer(server);
|
||||
emptyPlayersSince.delete(server.id);
|
||||
console.log(`[AutoShutdown] ${server.id}: Server gestoppt`);
|
||||
}
|
||||
} else {
|
||||
// Players online, reset timer
|
||||
if (emptyPlayersSince.has(server.id)) {
|
||||
console.log(`[AutoShutdown] ${server.id}: Spieler online (${playerCount}), Timer zurückgesetzt`);
|
||||
emptyPlayersSince.delete(server.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[AutoShutdown] Fehler bei ${server.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AutoShutdown] Fehler beim Laden der Config:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function startAutoShutdownService() {
|
||||
console.log('[AutoShutdown] Service gestartet, prüfe alle 60 Sekunden');
|
||||
|
||||
// Initial check after 10 seconds (give server time to start)
|
||||
setTimeout(() => {
|
||||
checkServers();
|
||||
}, 10000);
|
||||
|
||||
// Then check every 60 seconds
|
||||
setInterval(checkServers, CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
// Get how long a server has been empty (for status display)
|
||||
export function getEmptySince(serverId) {
|
||||
const since = emptyPlayersSince.get(serverId);
|
||||
if (!since) return null;
|
||||
return Math.floor((Date.now() - since) / 60000); // Return minutes
|
||||
}
|
||||
|
||||
// Get all empty-since times
|
||||
export function getAllEmptySince() {
|
||||
const result = {};
|
||||
for (const [serverId, since] of emptyPlayersSince) {
|
||||
result[serverId] = Math.floor((Date.now() - since) / 60000);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
167
gsm-backend/services/discord.js
Normal file
167
gsm-backend/services/discord.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// Discord OAuth2 Service
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
|
||||
// Lazy initialization - wird erst bei Verwendung geladen (nach dotenv)
|
||||
let _guildConfigs = null;
|
||||
|
||||
function getGuildConfigs() {
|
||||
if (_guildConfigs === null) {
|
||||
_guildConfigs = [
|
||||
{
|
||||
name: 'Bacanaks',
|
||||
guildId: process.env.DISCORD_GUILD_ID_1,
|
||||
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_1,
|
||||
modRoleId: process.env.DISCORD_MOD_ROLE_ID_1
|
||||
},
|
||||
{
|
||||
name: 'Piccadilly',
|
||||
guildId: process.env.DISCORD_GUILD_ID_2,
|
||||
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_2,
|
||||
modRoleId: process.env.DISCORD_MOD_ROLE_ID_2
|
||||
}
|
||||
].filter(config => config.guildId);
|
||||
}
|
||||
return _guildConfigs;
|
||||
}
|
||||
|
||||
export function getDiscordAuthUrl() {
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
redirect_uri: process.env.DISCORD_REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'identify guilds.members.read'
|
||||
});
|
||||
return `https://discord.com/oauth2/authorize?${params}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(code) {
|
||||
const response = await fetch(`${DISCORD_API}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: process.env.DISCORD_REDIRECT_URI
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to exchange code: ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getDiscordUser(accessToken) {
|
||||
const response = await fetch(`${DISCORD_API}/users/@me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get Discord user');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Prüft einen einzelnen Server
|
||||
async function fetchGuildMember(guildId, userId) {
|
||||
const response = await fetch(
|
||||
`${DISCORD_API}/guilds/${guildId}/members/${userId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Failed to get guild member from ${guildId}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Prüft alle konfigurierten Server und gibt Memberships zurück
|
||||
export async function getGuildMemberships(userId) {
|
||||
const configs = getGuildConfigs();
|
||||
const memberships = [];
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
const member = await fetchGuildMember(config.guildId, userId);
|
||||
if (member) {
|
||||
memberships.push({
|
||||
config,
|
||||
member,
|
||||
roles: member.roles || []
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Discord] Failed to check membership for guild ${config.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return memberships.length > 0 ? memberships : null;
|
||||
}
|
||||
|
||||
// Legacy-Funktion für Kompatibilität
|
||||
export async function getGuildMember(userId) {
|
||||
const memberships = await getGuildMemberships(userId);
|
||||
if (!memberships || memberships.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return memberships[0].member;
|
||||
}
|
||||
|
||||
// Rollen-Priorität: superadmin > moderator > user
|
||||
const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 };
|
||||
|
||||
// Bestimmt die höchste Rolle aus allen Server-Memberships
|
||||
export function getUserRoleFromMemberships(memberships) {
|
||||
if (!memberships || memberships.length === 0) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
let highestRole = 'user';
|
||||
|
||||
for (const { config, roles } of memberships) {
|
||||
let role = 'user';
|
||||
|
||||
if (roles.includes(config.adminRoleId)) {
|
||||
role = 'superadmin';
|
||||
} else if (roles.includes(config.modRoleId)) {
|
||||
role = 'moderator';
|
||||
}
|
||||
|
||||
if (ROLE_PRIORITY[role] > ROLE_PRIORITY[highestRole]) {
|
||||
highestRole = role;
|
||||
}
|
||||
}
|
||||
|
||||
return highestRole;
|
||||
}
|
||||
|
||||
// Legacy-Funktion für Kompatibilität
|
||||
export function getUserRole(memberRoles) {
|
||||
const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID || process.env.DISCORD_ADMIN_ROLE_ID_1;
|
||||
const modRoleId = process.env.DISCORD_MOD_ROLE_ID || process.env.DISCORD_MOD_ROLE_ID_1;
|
||||
|
||||
if (memberRoles.includes(adminRoleId)) {
|
||||
return 'superadmin';
|
||||
}
|
||||
if (memberRoles.includes(modRoleId)) {
|
||||
return 'moderator';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
534
gsm-backend/services/discordBot.js
Normal file
534
gsm-backend/services/discordBot.js
Normal file
@@ -0,0 +1,534 @@
|
||||
import { Client, GatewayIntentBits, EmbedBuilder, ChannelType, PermissionFlagsBits } from 'discord.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getServerStatus } from './ssh.js';
|
||||
import { getPlayers, getPlayerList } from './rcon.js';
|
||||
import { initGuildSettings, getGuildSettings, getAllGuildSettings, setGuildSettings, deleteGuildSettings } from '../db/init.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let client = null;
|
||||
|
||||
// State tracking for alerts (per guild)
|
||||
const previousServerState = new Map();
|
||||
const previousPlayerLists = new Map();
|
||||
|
||||
// Server display config
|
||||
const serverDisplay = {
|
||||
minecraft: { name: 'Minecraft ATM10', icon: '⛏️', color: 0x7B5E3C, address: 'minecraft.zeasy.dev' },
|
||||
factorio: { name: 'Factorio', icon: '⚙️', color: 0xF97316, address: 'factorio.zeasy.dev' },
|
||||
zomboid: { name: 'Project Zomboid', icon: '🧟', color: 0x4ADE80, address: 'pz.zeasy.dev:16261' },
|
||||
vrising: { name: 'V Rising', icon: '🧛', color: 0xDC2626, address: 'vrising.zeasy.dev' },
|
||||
palworld: { name: 'Palworld', icon: '🦎', color: 0x00D4AA, address: 'palworld.zeasy.dev:8211' },
|
||||
terraria: { name: 'Terraria', icon: '⚔️', color: 0x05C46B, address: 'terraria.zeasy.dev:7777' },
|
||||
openttd: { name: 'OpenTTD', icon: '🚂', color: 0x1E90FF, address: 'openttd.zeasy.dev:3979' }
|
||||
};
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Channel Setup Functions
|
||||
// ============================================
|
||||
|
||||
async function setupGuildChannels(guild) {
|
||||
console.log('[DiscordBot] Setting up channels for guild: ' + guild.name);
|
||||
|
||||
try {
|
||||
// Create category
|
||||
const category = await guild.channels.create({
|
||||
name: '🎮 Gameserver',
|
||||
type: ChannelType.GuildCategory,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id, // @everyone
|
||||
deny: [PermissionFlagsBits.SendMessages],
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
{
|
||||
id: client.user.id, // Bot
|
||||
allow: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageMessages, PermissionFlagsBits.EmbedLinks]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Create info channel
|
||||
const infoChannel = await guild.channels.create({
|
||||
name: 'ℹ️│info',
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: 'Informationen zum Gameserver Management System'
|
||||
});
|
||||
|
||||
// Create status channel
|
||||
const statusChannel = await guild.channels.create({
|
||||
name: '📊│status',
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: 'Live-Status aller Gameserver'
|
||||
});
|
||||
|
||||
// Create alerts channel
|
||||
const alertsChannel = await guild.channels.create({
|
||||
name: '📢│alerts',
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: 'Benachrichtigungen über Server-Events und Spieler'
|
||||
});
|
||||
|
||||
// Create updates channel
|
||||
const updatesChannel = await guild.channels.create({
|
||||
name: '📰│updates',
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: 'Ankündigungen zu neuen Gameservern'
|
||||
});
|
||||
|
||||
// Create discussion channel (users can write)
|
||||
const discussionChannel = await guild.channels.create({
|
||||
name: '💬│diskussion',
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: 'Diskutiert hier über die Gameserver',
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
allow: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Create requests forum channel
|
||||
const requestsChannel = await guild.channels.create({
|
||||
name: '💡│requests',
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
topic: 'Schlage neue Gameserver vor',
|
||||
defaultAutoArchiveDuration: 10080, // 7 days
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
allow: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.CreatePublicThreads]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Create info thread in requests forum
|
||||
const infoThread = await requestsChannel.threads.create({
|
||||
name: '📌 Wie funktioniert dieser Kanal?',
|
||||
message: {
|
||||
embeds: [new EmbedBuilder()
|
||||
.setTitle('💡 Gameserver Vorschläge')
|
||||
.setDescription(
|
||||
'In diesem Kanal kannst du neue Gameserver vorschlagen!\n\n' +
|
||||
'**So funktioniert\'s:**\n' +
|
||||
'1. Erstelle einen neuen Beitrag mit dem Namen des Spiels\n' +
|
||||
'2. Beschreibe warum dieser Server cool wäre\n' +
|
||||
'3. Andere können deinen Vorschlag diskutieren und liken\n\n' +
|
||||
'**Was wir berücksichtigen:**\n' +
|
||||
'• Interesse der Community (Likes & Diskussion)\n' +
|
||||
'• Technische Machbarkeit\n' +
|
||||
'• Serverkosten\n\n' +
|
||||
'Beliebte Vorschläge werden von uns geprüft!'
|
||||
)
|
||||
.setColor(0x5865F2)
|
||||
.setFooter({ text: 'Zeasy Gameserver' })
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Pin the info thread
|
||||
await infoThread.pin();
|
||||
|
||||
// Create info message
|
||||
const infoEmbed = new EmbedBuilder()
|
||||
.setTitle('🎮 Zeasy Gameserver Management')
|
||||
.setDescription(
|
||||
'Verwalte und überwache unsere Gameserver bequem über das Web-Interface.\n\n' +
|
||||
'**Features:**\n' +
|
||||
'• Server starten, stoppen & neustarten\n' +
|
||||
'• Live Server-Status & Spielerlisten\n' +
|
||||
'• Server-Konsole & RCON-Befehle\n' +
|
||||
'• CPU, RAM & Uptime Metriken\n' +
|
||||
'• Welten-Verwaltung (Factorio)\n' +
|
||||
'• Config-Editor (Palworld, Zomboid)\n\n' +
|
||||
'**Zugang:**\n' +
|
||||
'Melde dich mit deinem Discord-Account an.'
|
||||
)
|
||||
.setColor(0x5865F2)
|
||||
.addFields({
|
||||
name: '🔗 Web-Interface',
|
||||
value: '[server.zeasy.dev](https://server.zeasy.dev)',
|
||||
inline: false
|
||||
})
|
||||
.setFooter({ text: 'Zeasy Software' })
|
||||
.setTimestamp();
|
||||
|
||||
await infoChannel.send({ embeds: [infoEmbed] });
|
||||
|
||||
// Create initial status message
|
||||
const statusMsg = await statusChannel.send({ embeds: [createLoadingEmbed()] });
|
||||
|
||||
// Save settings to database
|
||||
const settings = {
|
||||
category_id: category.id,
|
||||
info_channel_id: infoChannel.id,
|
||||
status_channel_id: statusChannel.id,
|
||||
status_message_id: statusMsg.id,
|
||||
alerts_channel_id: alertsChannel.id,
|
||||
updates_channel_id: updatesChannel.id,
|
||||
discussion_channel_id: discussionChannel.id,
|
||||
requests_channel_id: requestsChannel.id,
|
||||
requests_info_thread_id: infoThread.id
|
||||
};
|
||||
|
||||
setGuildSettings(guild.id, settings);
|
||||
|
||||
console.log('[DiscordBot] Setup complete for guild: ' + guild.name);
|
||||
|
||||
// Send welcome message in alerts
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setTitle('🎉 Gameserver Bot eingerichtet!')
|
||||
.setDescription(
|
||||
'Die Gameserver-Kanäle wurden erfolgreich erstellt.\n\n' +
|
||||
'**Kanäle:**\n' +
|
||||
'• <#' + infoChannel.id + '> - Allgemeine Infos\n' +
|
||||
'• <#' + statusChannel.id + '> - Live Server-Status\n' +
|
||||
'• <#' + alertsChannel.id + '> - Benachrichtigungen\n' +
|
||||
'• <#' + updatesChannel.id + '> - Server-Ankündigungen\n' +
|
||||
'• <#' + discussionChannel.id + '> - Diskussion\n' +
|
||||
'• <#' + requestsChannel.id + '> - Server-Vorschläge'
|
||||
)
|
||||
.setColor(0x22C55E)
|
||||
.setTimestamp();
|
||||
|
||||
await alertsChannel.send({ embeds: [welcomeEmbed] });
|
||||
|
||||
return settings;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[DiscordBot] Error setting up guild channels:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Alert Functions
|
||||
// ============================================
|
||||
|
||||
async function sendAlertToAllGuilds(embed) {
|
||||
const allSettings = getAllGuildSettings();
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (!settings.alerts_channel_id) continue;
|
||||
|
||||
try {
|
||||
const channel = await client.channels.fetch(settings.alerts_channel_id);
|
||||
if (channel) {
|
||||
await channel.send({ embeds: [embed] });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[DiscordBot] Error sending alert to guild ' + settings.guild_id + ':', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndSendAlerts(serverStatuses) {
|
||||
for (const server of serverStatuses) {
|
||||
const display = serverDisplay[server.id] || { name: server.name, icon: '🖥️', color: 0x6B7280 };
|
||||
const prevState = previousServerState.get(server.id);
|
||||
const prevPlayers = previousPlayerLists.get(server.id) || [];
|
||||
|
||||
// Check server status changes
|
||||
if (prevState !== undefined && prevState !== server.status) {
|
||||
let embed;
|
||||
|
||||
if (server.status === 'online' && prevState !== 'online') {
|
||||
embed = new EmbedBuilder()
|
||||
.setTitle(display.icon + ' Server gestartet')
|
||||
.setDescription('**' + display.name + '** ist jetzt online')
|
||||
.setColor(0x22C55E)
|
||||
.setTimestamp();
|
||||
} else if (server.status === 'offline' && prevState === 'online') {
|
||||
embed = new EmbedBuilder()
|
||||
.setTitle(display.icon + ' Server gestoppt')
|
||||
.setDescription('**' + display.name + '** ist jetzt offline')
|
||||
.setColor(0xEF4444)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
if (embed) {
|
||||
await sendAlertToAllGuilds(embed);
|
||||
}
|
||||
}
|
||||
|
||||
// Check player changes (only if server is online)
|
||||
if (server.status === 'online' && server.playerList) {
|
||||
const currentPlayers = server.playerList;
|
||||
|
||||
for (const player of currentPlayers) {
|
||||
if (!prevPlayers.includes(player)) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('➡️ Spieler beigetreten')
|
||||
.setDescription('**' + player + '** hat **' + display.name + '** betreten')
|
||||
.setColor(0x22C55E)
|
||||
.setTimestamp();
|
||||
await sendAlertToAllGuilds(embed);
|
||||
}
|
||||
}
|
||||
|
||||
for (const player of prevPlayers) {
|
||||
if (!currentPlayers.includes(player)) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('⬅️ Spieler verlassen')
|
||||
.setDescription('**' + player + '** hat **' + display.name + '** verlassen')
|
||||
.setColor(0xF59E0B)
|
||||
.setTimestamp();
|
||||
await sendAlertToAllGuilds(embed);
|
||||
}
|
||||
}
|
||||
|
||||
previousPlayerLists.set(server.id, [...currentPlayers]);
|
||||
} else {
|
||||
previousPlayerLists.set(server.id, []);
|
||||
}
|
||||
|
||||
previousServerState.set(server.id, server.status);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Status Update Functions
|
||||
// ============================================
|
||||
|
||||
function createLoadingEmbed() {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('🎮 Gameserver Status')
|
||||
.setDescription('Lade Server-Status...')
|
||||
.setColor(0x6B7280)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
function createStatusEmbeds(serverStatuses) {
|
||||
const embeds = [];
|
||||
|
||||
const onlineCount = serverStatuses.filter(s => s.running).length;
|
||||
const totalPlayers = serverStatuses.reduce((sum, s) => sum + s.players, 0);
|
||||
|
||||
const headerEmbed = new EmbedBuilder()
|
||||
.setTitle('🎮 Gameserver Status')
|
||||
.setDescription('**' + onlineCount + '/' + serverStatuses.length + '** Server online • **' + totalPlayers + '** Spieler')
|
||||
.setColor(onlineCount > 0 ? 0x22C55E : 0xEF4444)
|
||||
.setTimestamp()
|
||||
.setFooter({ text: 'Aktualisiert alle 60 Sekunden' });
|
||||
|
||||
embeds.push(headerEmbed);
|
||||
|
||||
for (const server of serverStatuses) {
|
||||
const display = serverDisplay[server.id] || { name: server.name, icon: '🖥️', color: 0x6B7280, address: '' };
|
||||
|
||||
const serverEmbed = new EmbedBuilder()
|
||||
.setTitle(display.icon + ' ' + display.name)
|
||||
.setColor(server.running ? display.color : 0x4B5563);
|
||||
|
||||
if (server.running) {
|
||||
let description = '✅ **Online**\n';
|
||||
|
||||
if (display.address) {
|
||||
description += '```' + display.address + '```';
|
||||
}
|
||||
|
||||
description += '👥 **Spieler:** ' + server.players;
|
||||
if (server.maxPlayers) {
|
||||
description += '/' + server.maxPlayers;
|
||||
}
|
||||
|
||||
if (server.playerList && server.playerList.length > 0) {
|
||||
const names = server.playerList.slice(0, 15).join(', ');
|
||||
description += '\n' + names;
|
||||
if (server.playerList.length > 15) {
|
||||
description += ' *+' + (server.playerList.length - 15) + ' mehr*';
|
||||
}
|
||||
}
|
||||
|
||||
serverEmbed.setDescription(description);
|
||||
} else {
|
||||
serverEmbed.setDescription('❌ **Offline**');
|
||||
}
|
||||
|
||||
embeds.push(serverEmbed);
|
||||
}
|
||||
|
||||
return embeds;
|
||||
}
|
||||
|
||||
async function fetchServerStatuses() {
|
||||
const config = loadConfig();
|
||||
|
||||
return await Promise.all(config.servers.map(async (server) => {
|
||||
try {
|
||||
const status = await getServerStatus(server);
|
||||
const running = status === 'online';
|
||||
|
||||
let players = { online: 0, max: null };
|
||||
let playerList = { players: [] };
|
||||
|
||||
if (running && server.rconPassword) {
|
||||
try {
|
||||
players = await getPlayers(server);
|
||||
playerList = await getPlayerList(server);
|
||||
} catch (e) {
|
||||
// RCON might fail
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status: running ? 'online' : 'offline',
|
||||
running,
|
||||
players: players.online || 0,
|
||||
maxPlayers: players.max,
|
||||
playerList: playerList.players || []
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status: 'unreachable',
|
||||
running: false,
|
||||
players: 0,
|
||||
maxPlayers: null,
|
||||
playerList: []
|
||||
};
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function updateAllStatusMessages(skipAlerts = false) {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const serverStatuses = await fetchServerStatuses();
|
||||
const embeds = createStatusEmbeds(serverStatuses);
|
||||
|
||||
// Send alerts
|
||||
if (!skipAlerts) {
|
||||
await checkAndSendAlerts(serverStatuses);
|
||||
} else {
|
||||
// Just populate initial state
|
||||
for (const server of serverStatuses) {
|
||||
previousServerState.set(server.id, server.status);
|
||||
previousPlayerLists.set(server.id, server.playerList || []);
|
||||
}
|
||||
}
|
||||
|
||||
// Update status message in all guilds
|
||||
const allSettings = getAllGuildSettings();
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (!settings.status_channel_id || !settings.status_message_id) continue;
|
||||
|
||||
try {
|
||||
const channel = await client.channels.fetch(settings.status_channel_id);
|
||||
const message = await channel.messages.fetch(settings.status_message_id);
|
||||
await message.edit({ embeds });
|
||||
} catch (err) {
|
||||
console.error('[DiscordBot] Error updating status for guild ' + settings.guild_id + ':', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[DiscordBot] Error updating status messages:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public Functions
|
||||
// ============================================
|
||||
|
||||
export async function sendUpdateToAllGuilds(embed) {
|
||||
const allSettings = getAllGuildSettings();
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (!settings.updates_channel_id) continue;
|
||||
|
||||
try {
|
||||
const channel = await client.channels.fetch(settings.updates_channel_id);
|
||||
if (channel) {
|
||||
await channel.send({ embeds: [embed] });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[DiscordBot] Error sending update to guild ' + settings.guild_id + ':', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDiscordBot() {
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
|
||||
if (!token) {
|
||||
console.log('[DiscordBot] Missing DISCORD_BOT_TOKEN, bot disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize database table
|
||||
initGuildSettings();
|
||||
|
||||
client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages]
|
||||
});
|
||||
|
||||
// Bot joined a new server
|
||||
client.on('guildCreate', async (guild) => {
|
||||
console.log('[DiscordBot] Joined new guild: ' + guild.name + ' (' + guild.id + ')');
|
||||
|
||||
try {
|
||||
await setupGuildChannels(guild);
|
||||
} catch (err) {
|
||||
console.error('[DiscordBot] Failed to setup guild:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Bot was removed from a server
|
||||
client.on('guildDelete', (guild) => {
|
||||
console.log('[DiscordBot] Left guild: ' + guild.name + ' (' + guild.id + ')');
|
||||
deleteGuildSettings(guild.id);
|
||||
});
|
||||
client.once('ready', async () => {
|
||||
console.log('[DiscordBot] Logged in as ' + client.user.tag);
|
||||
|
||||
// Check for guilds without settings and set them up
|
||||
for (const [guildId, guild] of client.guilds.cache) {
|
||||
const settings = getGuildSettings(guildId);
|
||||
if (!settings) {
|
||||
console.log('[DiscordBot] Setting up missing guild: ' + guild.name);
|
||||
try {
|
||||
await setupGuildChannels(guild);
|
||||
} catch (err) {
|
||||
console.error('[DiscordBot] Failed to setup missing guild ' + guild.name + ':', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First run - populate state without alerts
|
||||
await updateAllStatusMessages(true);
|
||||
|
||||
// Regular updates every 60 seconds
|
||||
setInterval(() => updateAllStatusMessages(false), 60000);
|
||||
});
|
||||
client.login(token).catch(err => {
|
||||
console.error('[DiscordBot] Failed to login:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
export function getDiscordClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
// Export setup function for manual setup via API
|
||||
export { setupGuildChannels };
|
||||
99
gsm-backend/services/factorio.js
Normal file
99
gsm-backend/services/factorio.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
|
||||
}
|
||||
|
||||
export function getFactorioServer() {
|
||||
const config = loadConfig();
|
||||
return config.servers.find(s => s.type === "factorio");
|
||||
}
|
||||
|
||||
// Default map-gen-settings structure
|
||||
export function getDefaultMapGenSettings() {
|
||||
return {
|
||||
terrain_segmentation: 1,
|
||||
water: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
starting_area: 1,
|
||||
peaceful_mode: false,
|
||||
autoplace_controls: {
|
||||
coal: { frequency: 1, size: 1, richness: 1 },
|
||||
stone: { frequency: 1, size: 1, richness: 1 },
|
||||
"copper-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"iron-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"uranium-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"crude-oil": { frequency: 1, size: 1, richness: 1 },
|
||||
trees: { frequency: 1, size: 1, richness: 1 },
|
||||
"enemy-base": { frequency: 1, size: 1, richness: 1 }
|
||||
},
|
||||
cliff_settings: {
|
||||
name: "cliff",
|
||||
cliff_elevation_0: 10,
|
||||
cliff_elevation_interval: 40,
|
||||
richness: 1
|
||||
},
|
||||
seed: null
|
||||
};
|
||||
}
|
||||
|
||||
// Factorio presets
|
||||
export const FACTORIO_PRESETS = {
|
||||
default: getDefaultMapGenSettings(),
|
||||
"rich-resources": {
|
||||
...getDefaultMapGenSettings(),
|
||||
autoplace_controls: {
|
||||
coal: { frequency: 1, size: 1, richness: 2 },
|
||||
stone: { frequency: 1, size: 1, richness: 2 },
|
||||
"copper-ore": { frequency: 1, size: 1, richness: 2 },
|
||||
"iron-ore": { frequency: 1, size: 1, richness: 2 },
|
||||
"uranium-ore": { frequency: 1, size: 1, richness: 2 },
|
||||
"crude-oil": { frequency: 1, size: 1, richness: 2 },
|
||||
trees: { frequency: 1, size: 1, richness: 1 },
|
||||
"enemy-base": { frequency: 1, size: 1, richness: 1 }
|
||||
}
|
||||
},
|
||||
"rail-world": {
|
||||
...getDefaultMapGenSettings(),
|
||||
autoplace_controls: {
|
||||
coal: { frequency: 0.33, size: 3, richness: 1 },
|
||||
stone: { frequency: 0.33, size: 3, richness: 1 },
|
||||
"copper-ore": { frequency: 0.33, size: 3, richness: 1 },
|
||||
"iron-ore": { frequency: 0.33, size: 3, richness: 1 },
|
||||
"uranium-ore": { frequency: 0.33, size: 3, richness: 1 },
|
||||
"crude-oil": { frequency: 0.33, size: 3, richness: 1 },
|
||||
trees: { frequency: 1, size: 1, richness: 1 },
|
||||
"enemy-base": { frequency: 0.5, size: 1, richness: 1 }
|
||||
}
|
||||
},
|
||||
"death-world": {
|
||||
...getDefaultMapGenSettings(),
|
||||
autoplace_controls: {
|
||||
coal: { frequency: 1, size: 1, richness: 1 },
|
||||
stone: { frequency: 1, size: 1, richness: 1 },
|
||||
"copper-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"iron-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"uranium-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"crude-oil": { frequency: 1, size: 1, richness: 1 },
|
||||
trees: { frequency: 1, size: 1, richness: 1 },
|
||||
"enemy-base": { frequency: 2, size: 2, richness: 1 }
|
||||
}
|
||||
},
|
||||
peaceful: {
|
||||
...getDefaultMapGenSettings(),
|
||||
peaceful_mode: true
|
||||
}
|
||||
};
|
||||
|
||||
export function getPresetNames() {
|
||||
return Object.keys(FACTORIO_PRESETS);
|
||||
}
|
||||
|
||||
export function getPreset(name) {
|
||||
return FACTORIO_PRESETS[name] || FACTORIO_PRESETS.default;
|
||||
}
|
||||
112
gsm-backend/services/prometheus.js
Normal file
112
gsm-backend/services/prometheus.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import fetch from "node-fetch";
|
||||
|
||||
const PROMETHEUS_URL = "http://localhost:9090";
|
||||
|
||||
const SERVER_JOBS = {
|
||||
"vrising": "vrising",
|
||||
"factorio": "factorio",
|
||||
"minecraft": "minecraft",
|
||||
"zomboid": "zomboid",
|
||||
"palworld": "palworld",
|
||||
"terraria": "terraria",
|
||||
"openttd": "openttd"
|
||||
};
|
||||
|
||||
export async function queryPrometheus(query) {
|
||||
const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.status !== "success") {
|
||||
throw new Error(`Prometheus query failed: ${data.error}`);
|
||||
}
|
||||
return data.data.result;
|
||||
}
|
||||
|
||||
export async function queryPrometheusRange(query, start, end, step) {
|
||||
const url = `${PROMETHEUS_URL}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${start}&end=${end}&step=${step}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.status !== "success") {
|
||||
throw new Error(`Prometheus query failed: ${data.error}`);
|
||||
}
|
||||
return data.data.result;
|
||||
}
|
||||
|
||||
export async function getServerMetricsHistory(serverId, range = "1h") {
|
||||
const job = SERVER_JOBS[serverId];
|
||||
if (!job) {
|
||||
throw new Error(`Unknown server ID: ${serverId}`);
|
||||
}
|
||||
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
let duration, step;
|
||||
switch (range) {
|
||||
case "15m": duration = 15 * 60; step = 15; break;
|
||||
case "1h": duration = 60 * 60; step = 60; break;
|
||||
case "6h": duration = 6 * 60 * 60; step = 300; break;
|
||||
case "24h": duration = 24 * 60 * 60; step = 900; break;
|
||||
default: duration = 60 * 60; step = 60;
|
||||
}
|
||||
const start = end - duration;
|
||||
|
||||
const cpuQuery = `100 - (avg by(instance) (irate(node_cpu_seconds_total{job="${job}",mode="idle"}[5m])) * 100)`;
|
||||
const memQuery = `100 * (1 - ((node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"}) / node_memory_MemTotal_bytes{job="${job}"}))`;
|
||||
const netRxQuery = `sum(irate(node_network_receive_bytes_total{job="${job}",device!~"lo|veth.*|docker.*|br-.*"}[5m]))`;
|
||||
const netTxQuery = `sum(irate(node_network_transmit_bytes_total{job="${job}",device!~"lo|veth.*|docker.*|br-.*"}[5m]))`;
|
||||
|
||||
try {
|
||||
const [cpuResult, memResult, netRxResult, netTxResult] = await Promise.all([
|
||||
queryPrometheusRange(cpuQuery, start, end, step),
|
||||
queryPrometheusRange(memQuery, start, end, step),
|
||||
queryPrometheusRange(netRxQuery, start, end, step),
|
||||
queryPrometheusRange(netTxQuery, start, end, step)
|
||||
]);
|
||||
|
||||
const cpu = cpuResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
|
||||
const memory = memResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
|
||||
const networkRx = netRxResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
|
||||
const networkTx = netTxResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
|
||||
|
||||
return { cpu, memory, networkRx, networkTx };
|
||||
} catch (error) {
|
||||
console.error("Prometheus query error:", error);
|
||||
return { cpu: [], memory: [], networkRx: [], networkTx: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentMetrics(serverId) {
|
||||
const job = SERVER_JOBS[serverId];
|
||||
if (!job) {
|
||||
throw new Error(`Unknown server ID: ${serverId}`);
|
||||
}
|
||||
|
||||
const cpuQuery = `100 - (avg by(instance) (irate(node_cpu_seconds_total{job="${job}",mode="idle"}[5m])) * 100)`;
|
||||
const memPercentQuery = `100 * (1 - ((node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"}) / node_memory_MemTotal_bytes{job="${job}"}))`;
|
||||
const memUsedQuery = `node_memory_MemTotal_bytes{job="${job}"} - (node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"})`;
|
||||
const memTotalQuery = `node_memory_MemTotal_bytes{job="${job}"}`;
|
||||
const uptimeQuery = `node_time_seconds{job="${job}"} - node_boot_time_seconds{job="${job}"}`;
|
||||
const cpuCoresQuery = `count(node_cpu_seconds_total{job="${job}",mode="idle"})`;
|
||||
|
||||
try {
|
||||
const [cpuResult, memPercentResult, memUsedResult, memTotalResult, uptimeResult, cpuCoresResult] = await Promise.all([
|
||||
queryPrometheus(cpuQuery),
|
||||
queryPrometheus(memPercentQuery),
|
||||
queryPrometheus(memUsedQuery),
|
||||
queryPrometheus(memTotalQuery),
|
||||
queryPrometheus(uptimeQuery),
|
||||
queryPrometheus(cpuCoresQuery)
|
||||
]);
|
||||
|
||||
return {
|
||||
cpu: parseFloat(cpuResult[0]?.value?.[1]) || 0,
|
||||
memory: parseFloat(memPercentResult[0]?.value?.[1]) || 0,
|
||||
memoryUsed: parseFloat(memUsedResult[0]?.value?.[1]) || 0,
|
||||
memoryTotal: parseFloat(memTotalResult[0]?.value?.[1]) || 0,
|
||||
uptime: parseFloat(uptimeResult[0]?.value?.[1]) || 0,
|
||||
cpuCores: parseInt(cpuCoresResult[0]?.value?.[1]) || 1
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Prometheus current metrics error:", error);
|
||||
return { cpu: 0, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0, cpuCores: 1 };
|
||||
}
|
||||
}
|
||||
159
gsm-backend/services/rcon.js
Normal file
159
gsm-backend/services/rcon.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Rcon } from 'rcon-client';
|
||||
|
||||
const rconConnections = new Map();
|
||||
const playerCache = new Map();
|
||||
const CACHE_TTL = 30000; // 30 seconds
|
||||
|
||||
async function getConnection(server) {
|
||||
const key = `${server.host}:${server.rconPort}`;
|
||||
|
||||
if (rconConnections.has(key)) {
|
||||
const conn = rconConnections.get(key);
|
||||
if (conn.authenticated) {
|
||||
return conn;
|
||||
}
|
||||
rconConnections.delete(key);
|
||||
}
|
||||
|
||||
const rcon = await Rcon.connect({
|
||||
host: server.host,
|
||||
port: server.rconPort,
|
||||
password: server.rconPassword,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
rcon.on('error', (err) => {
|
||||
console.error(`RCON error for ${key}:`, err.message);
|
||||
rconConnections.delete(key);
|
||||
});
|
||||
|
||||
rcon.on('end', () => {
|
||||
rconConnections.delete(key);
|
||||
});
|
||||
|
||||
// Handle socket errors to prevent crash
|
||||
if (rcon.socket) {
|
||||
rcon.socket.on('error', (err) => {
|
||||
console.error(`RCON socket error for ${key}:`, err.message);
|
||||
rconConnections.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
rconConnections.set(key, rcon);
|
||||
return rcon;
|
||||
}
|
||||
|
||||
export async function sendRconCommand(server, command) {
|
||||
if (!server.rconPassword) {
|
||||
throw new Error('RCON password not configured');
|
||||
}
|
||||
|
||||
const rcon = await getConnection(server);
|
||||
return await rcon.send(command);
|
||||
}
|
||||
|
||||
export async function getPlayers(server) {
|
||||
const cacheKey = `${server.id}-count`;
|
||||
const cached = playerCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.time < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
let result = { online: 0, max: null };
|
||||
|
||||
if (server.type === 'minecraft') {
|
||||
const response = await sendRconCommand(server, 'list');
|
||||
const match = response.match(/There are (\d+) of a max of (\d+) players online/);
|
||||
if (match) {
|
||||
result = { online: parseInt(match[1]), max: parseInt(match[2]) };
|
||||
}
|
||||
} else if (server.type === 'factorio') {
|
||||
const response = await sendRconCommand(server, '/players online count');
|
||||
const match = response.match(/(\d+)/);
|
||||
if (match) {
|
||||
result = { online: parseInt(match[1]), max: null };
|
||||
}
|
||||
} else if (server.type === 'zomboid') {
|
||||
const response = await sendRconCommand(server, 'players');
|
||||
// Format: "Players connected (X):" or "Players connected (X): Player1, Player2"
|
||||
const match = response.match(/Players connected \((\d+)\)/);
|
||||
if (match) {
|
||||
result = { online: parseInt(match[1]), max: null };
|
||||
}
|
||||
} else if (server.type === 'vrising') {
|
||||
const response = await sendRconCommand(server, 'listusers');
|
||||
// Count lines that contain player info
|
||||
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
|
||||
result = { online: lines.length, max: null };
|
||||
}
|
||||
|
||||
playerCache.set(cacheKey, { data: result, time: Date.now() });
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get players for ${server.id}:`, err.message);
|
||||
// Clear cache on error - server might be offline
|
||||
playerCache.delete(cacheKey);
|
||||
return { online: 0, max: null };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlayerList(server) {
|
||||
const cacheKey = `${server.id}-list`;
|
||||
const cached = playerCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.time < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
let players = [];
|
||||
|
||||
if (server.type === 'minecraft') {
|
||||
const response = await sendRconCommand(server, 'list');
|
||||
// Format: "There are X of a max of Y players online: Player1, Player2, Player3"
|
||||
const colonIndex = response.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const playerPart = response.substring(colonIndex + 1).trim();
|
||||
if (playerPart) {
|
||||
players = playerPart.split(',').map(p => p.trim()).filter(p => p);
|
||||
}
|
||||
}
|
||||
} else if (server.type === 'factorio') {
|
||||
const response = await sendRconCommand(server, '/players online');
|
||||
// Format: "Online players (X):\n player1 (online)\n player2 (online)"
|
||||
const lines = response.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(\S+)\s*\(online\)/);
|
||||
if (match) {
|
||||
players.push(match[1]);
|
||||
}
|
||||
}
|
||||
} else if (server.type === 'zomboid') {
|
||||
const response = await sendRconCommand(server, 'players');
|
||||
// Format: "Players connected (X): \n-Player1\n-Player2\n-Player3"
|
||||
const lines = response.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('-')) {
|
||||
players.push(trimmed.substring(1));
|
||||
}
|
||||
}
|
||||
} else if (server.type === 'vrising') {
|
||||
const response = await sendRconCommand(server, 'listusers');
|
||||
// Parse player names from listusers output
|
||||
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
|
||||
players = lines.map(l => l.trim()).filter(p => p);
|
||||
}
|
||||
|
||||
const result = { players };
|
||||
playerCache.set(cacheKey, { data: result, time: Date.now() });
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get player list for ${server.id}:`, err.message);
|
||||
// Clear cache on error - server might be offline
|
||||
playerCache.delete(cacheKey);
|
||||
return { players: [] };
|
||||
}
|
||||
}
|
||||
590
gsm-backend/services/ssh.js
Normal file
590
gsm-backend/services/ssh.js
Normal file
@@ -0,0 +1,590 @@
|
||||
import { NodeSSH } from "node-ssh";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sshConnections = new Map();
|
||||
const failedHosts = new Map(); // Cache failed connections
|
||||
const FAILED_HOST_TTL = 60000; // 60 seconds before retry
|
||||
const SSH_TIMEOUT = 5000;
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
|
||||
}
|
||||
|
||||
// Check if host is marked as failed (non-blocking)
|
||||
export function isHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
const failedAt = failedHosts.get(key);
|
||||
return failedAt && Date.now() - failedAt < FAILED_HOST_TTL;
|
||||
}
|
||||
|
||||
// Mark host as failed
|
||||
export function markHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.set(key, Date.now());
|
||||
}
|
||||
|
||||
// Clear failed status
|
||||
export function clearHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.delete(key);
|
||||
}
|
||||
|
||||
async function getConnection(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
|
||||
// Check if host recently failed - throw immediately
|
||||
if (isHostFailed(host, username)) {
|
||||
throw new Error("Host recently unreachable");
|
||||
}
|
||||
|
||||
if (sshConnections.has(key)) {
|
||||
const conn = sshConnections.get(key);
|
||||
if (conn.isConnected()) return conn;
|
||||
sshConnections.delete(key);
|
||||
}
|
||||
|
||||
const ssh = new NodeSSH();
|
||||
try {
|
||||
await ssh.connect({
|
||||
host,
|
||||
username,
|
||||
privateKeyPath: "/root/.ssh/id_ed25519",
|
||||
readyTimeout: SSH_TIMEOUT
|
||||
});
|
||||
clearHostFailed(host, username);
|
||||
sshConnections.set(key, ssh);
|
||||
return ssh;
|
||||
} catch (err) {
|
||||
markHostFailed(host, username);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns: "online", "starting", "stopping", "offline"
|
||||
export async function getServerStatus(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'running') return 'online';
|
||||
if (status === 'restarting' || status === 'created') return 'starting';
|
||||
if (status === 'removing' || status === 'paused') return 'stopping';
|
||||
return 'offline';
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'active') return 'online';
|
||||
if (status === 'activating' || status === 'reloading') return 'starting';
|
||||
if (status === 'deactivating') return 'stopping';
|
||||
return 'offline';
|
||||
} else if (server.runtime === 'pm2') {
|
||||
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
|
||||
const result = await ssh.execCommand(nvmPrefix + "pm2 jlist");
|
||||
try {
|
||||
const processes = JSON.parse(result.stdout);
|
||||
const proc = processes.find(p => p.name === server.serviceName);
|
||||
if (!proc) return "offline";
|
||||
if (proc.pm2_env.status === "online") return "online";
|
||||
if (proc.pm2_env.status === "launching") return "starting";
|
||||
if (proc.pm2_env.status === "stopping") return "stopping";
|
||||
return "offline";
|
||||
} catch { return "offline"; }
|
||||
} else {
|
||||
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (result.code === 0) {
|
||||
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
|
||||
if (uptime < 60) return 'starting';
|
||||
return 'online';
|
||||
}
|
||||
return 'offline';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to get status for ${server.id}:`, err.message);
|
||||
return 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServer(server, options = {}) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
// Factorio with specific save
|
||||
if (server.type === 'factorio' && options.save) {
|
||||
const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save;
|
||||
// Stop and remove existing container
|
||||
await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`);
|
||||
await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`);
|
||||
// Start with specific save
|
||||
await ssh.execCommand(`
|
||||
docker run -d \
|
||||
--name ${server.containerName} \
|
||||
-p 34197:34197/udp \
|
||||
-p 27015:27015/tcp \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
-e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \
|
||||
-e TZ=Europe/Berlin \
|
||||
-e PUID=845 \
|
||||
-e PGID=845 \
|
||||
--restart=unless-stopped \
|
||||
factoriotools/factorio
|
||||
`);
|
||||
} else {
|
||||
await ssh.execCommand(`docker start ${server.containerName}`);
|
||||
}
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const sudoCmd = server.external ? "sudo " : ""; await ssh.execCommand(`${sudoCmd}systemctl start ${server.serviceName}`);
|
||||
} else if (server.runtime === 'pm2') {
|
||||
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
|
||||
await ssh.execCommand(nvmPrefix + "pm2 start " + server.serviceName);
|
||||
} else {
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopServer(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
await ssh.execCommand(`docker stop ${server.containerName}`);
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const sudoCmd2 = server.external ? "sudo " : ""; await ssh.execCommand(`${sudoCmd2}systemctl stop ${server.serviceName}`);
|
||||
} else if (server.runtime === 'pm2') {
|
||||
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
|
||||
await ssh.execCommand(nvmPrefix + "pm2 stop " + server.serviceName);
|
||||
} else {
|
||||
// Different stop commands per server type
|
||||
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (check.code !== 0) {
|
||||
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Force killing ${server.id} after timeout`);
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartServer(server) {
|
||||
await stopServer(server);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await startServer(server);
|
||||
}
|
||||
|
||||
export async function getConsoleLog(server, lines = 50) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.runtime === 'pm2') {
|
||||
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
|
||||
const result = await ssh.execCommand(nvmPrefix + "pm2 logs " + server.serviceName + " --lines " + lines + " --nostream");
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.logFile) {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
} else {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessUptime(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === "docker") {
|
||||
const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`);
|
||||
if (result.stdout.trim()) {
|
||||
const startTime = new Date(result.stdout.trim());
|
||||
if (!isNaN(startTime.getTime())) {
|
||||
return Math.floor((Date.now() - startTime.getTime()) / 1000);
|
||||
}
|
||||
}
|
||||
} else if (server.runtime === "systemd") {
|
||||
const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`);
|
||||
const startEpoch = parseInt(result.stdout.trim());
|
||||
if (!isNaN(startEpoch)) {
|
||||
return Math.floor(Date.now() / 1000) - startEpoch;
|
||||
}
|
||||
} else if (server.runtime === "pm2") {
|
||||
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
|
||||
const result = await ssh.execCommand(nvmPrefix + "pm2 jlist");
|
||||
try {
|
||||
const processes = JSON.parse(result.stdout);
|
||||
const proc = processes.find(p => p.name === server.serviceName);
|
||||
if (proc && proc.pm2_env.pm_uptime) {
|
||||
return Math.floor((Date.now() - proc.pm2_env.pm_uptime) / 1000);
|
||||
}
|
||||
} catch {}
|
||||
return 0;
|
||||
} else {
|
||||
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(result.stdout.trim());
|
||||
if (!isNaN(uptime)) return uptime;
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get uptime for ${server.id}:`, err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ FACTORIO-SPECIFIC FUNCTIONS ============
|
||||
|
||||
export async function listFactorioSaves(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null');
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const saves = [];
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const filename = match[7].split('/').pop();
|
||||
// Skip autosaves
|
||||
if (filename.startsWith('_autosave')) continue;
|
||||
|
||||
const size = match[5];
|
||||
const modified = match[6];
|
||||
|
||||
saves.push({
|
||||
name: filename.replace('.zip', ''),
|
||||
filename,
|
||||
size,
|
||||
modified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return saves;
|
||||
}
|
||||
|
||||
export async function deleteFactorioSave(server, saveName) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`;
|
||||
|
||||
// Security check - prevent path traversal
|
||||
if (filename.includes('/') || filename.includes('..')) {
|
||||
throw new Error('Invalid save name');
|
||||
}
|
||||
|
||||
const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to delete save');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFactorioWorld(server, saveName, settings) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Security check
|
||||
if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) {
|
||||
throw new Error("Invalid save name - no spaces or special characters allowed");
|
||||
}
|
||||
|
||||
// Write map-gen-settings.json
|
||||
const settingsJson = JSON.stringify(settings, null, 2);
|
||||
await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF'
|
||||
${settingsJson}
|
||||
SETTINGSEOF`);
|
||||
|
||||
// Create new world using --create flag (correct method)
|
||||
console.log(`Creating new Factorio world: ${saveName}`);
|
||||
const createResult = await ssh.execCommand(`
|
||||
docker run --rm \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
factoriotools/factorio \
|
||||
/opt/factorio/bin/x64/factorio \
|
||||
--create /factorio/saves/${saveName}.zip \
|
||||
--map-gen-settings /factorio/config/map-gen-settings.json
|
||||
`, { execOptions: { timeout: 120000 } });
|
||||
|
||||
console.log("World creation output:", createResult.stdout, createResult.stderr);
|
||||
|
||||
// Verify save was created
|
||||
const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`);
|
||||
if (checkResult.code !== 0) {
|
||||
throw new Error("World creation failed - save file not found. Output: " + createResult.stderr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFactorioCurrentSave(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Check if container has SAVE_NAME env set
|
||||
const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`);
|
||||
if (envResult.stdout.trim()) {
|
||||
const saveName = envResult.stdout.trim().replace("SAVE_NAME=", "");
|
||||
return { save: saveName, source: "configured" };
|
||||
}
|
||||
|
||||
// Otherwise find newest save file
|
||||
const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1");
|
||||
if (result.stdout.trim()) {
|
||||
const filename = result.stdout.trim().split("/").pop().replace(".zip", "");
|
||||
return { save: filename, source: "newest" };
|
||||
}
|
||||
|
||||
return { save: null, source: null };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============ ZOMBOID CONFIG FUNCTIONS ============
|
||||
|
||||
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
|
||||
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
|
||||
|
||||
export async function listZomboidConfigs(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readZomboidConfig(server, filename) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writeZomboidConfig(server, filename, content) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ PALWORLD CONFIG ============
|
||||
const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer";
|
||||
const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"];
|
||||
|
||||
export async function listPalworldConfigs(server) {
|
||||
const ssh = await getConnection(server.host);
|
||||
const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readPalworldConfig(server, filename) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writePalworldConfig(server, filename, content) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ TERRARIA CONFIG ============
|
||||
const TERRARIA_CONFIG_PATH = "/home/terraria/serverconfig.txt";
|
||||
|
||||
export async function readTerrariaConfig(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand(`cat ${TERRARIA_CONFIG_PATH}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writeTerrariaConfig(server, content) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Create backup
|
||||
const backupName = `serverconfig.txt.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${TERRARIA_CONFIG_PATH} /home/terraria/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(TERRARIA_CONFIG_PATH, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t /home/terraria/serverconfig.txt.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// OpenTTD Config
|
||||
const OPENTTD_CONFIG_PATH = "/opt/openttd/.openttd/openttd.cfg";
|
||||
|
||||
export async function readOpenTTDConfig(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand(`cat ${OPENTTD_CONFIG_PATH}`);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writeOpenTTDConfig(server, content) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const backupName = `openttd.cfg.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${OPENTTD_CONFIG_PATH} /opt/openttd/.openttd/${backupName} 2>/dev/null || true`);
|
||||
const sftp = await ssh.requestSFTP();
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(OPENTTD_CONFIG_PATH, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
await ssh.execCommand(`ls -t /opt/openttd/.openttd/openttd.cfg.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
return true;
|
||||
}
|
||||
493
gsm-backend/services/ssh.js.bak
Normal file
493
gsm-backend/services/ssh.js.bak
Normal file
@@ -0,0 +1,493 @@
|
||||
import { NodeSSH } from "node-ssh";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sshConnections = new Map();
|
||||
const failedHosts = new Map(); // Cache failed connections
|
||||
const FAILED_HOST_TTL = 60000; // 60 seconds before retry
|
||||
const SSH_TIMEOUT = 5000;
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
|
||||
}
|
||||
|
||||
// Check if host is marked as failed (non-blocking)
|
||||
export function isHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
const failedAt = failedHosts.get(key);
|
||||
return failedAt && Date.now() - failedAt < FAILED_HOST_TTL;
|
||||
}
|
||||
|
||||
// Mark host as failed
|
||||
export function markHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.set(key, Date.now());
|
||||
}
|
||||
|
||||
// Clear failed status
|
||||
export function clearHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.delete(key);
|
||||
}
|
||||
|
||||
async function getConnection(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
|
||||
// Check if host recently failed - throw immediately
|
||||
if (isHostFailed(host, username)) {
|
||||
throw new Error("Host recently unreachable");
|
||||
}
|
||||
|
||||
if (sshConnections.has(key)) {
|
||||
const conn = sshConnections.get(key);
|
||||
if (conn.isConnected()) return conn;
|
||||
sshConnections.delete(key);
|
||||
}
|
||||
|
||||
const ssh = new NodeSSH();
|
||||
try {
|
||||
await ssh.connect({
|
||||
host,
|
||||
username,
|
||||
privateKeyPath: "/root/.ssh/id_ed25519",
|
||||
readyTimeout: SSH_TIMEOUT
|
||||
});
|
||||
clearHostFailed(host, username);
|
||||
sshConnections.set(key, ssh);
|
||||
return ssh;
|
||||
} catch (err) {
|
||||
markHostFailed(host, username);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns: "online", "starting", "stopping", "offline"
|
||||
export async function getServerStatus(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'running') return 'online';
|
||||
if (status === 'restarting' || status === 'created') return 'starting';
|
||||
if (status === 'removing' || status === 'paused') return 'stopping';
|
||||
return 'offline';
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'active') return 'online';
|
||||
if (status === 'activating' || status === 'reloading') return 'starting';
|
||||
if (status === 'deactivating') return 'stopping';
|
||||
return 'offline';
|
||||
} else {
|
||||
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (result.code === 0) {
|
||||
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
|
||||
if (uptime < 60) return 'starting';
|
||||
return 'online';
|
||||
}
|
||||
return 'offline';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to get status for ${server.id}:`, err.message);
|
||||
return 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServer(server, options = {}) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
// Factorio with specific save
|
||||
if (server.type === 'factorio' && options.save) {
|
||||
const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save;
|
||||
// Stop and remove existing container
|
||||
await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`);
|
||||
await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`);
|
||||
// Start with specific save
|
||||
await ssh.execCommand(`
|
||||
docker run -d \
|
||||
--name ${server.containerName} \
|
||||
-p 34197:34197/udp \
|
||||
-p 27015:27015/tcp \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
-e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \
|
||||
-e TZ=Europe/Berlin \
|
||||
-e PUID=845 \
|
||||
-e PGID=845 \
|
||||
--restart=unless-stopped \
|
||||
factoriotools/factorio
|
||||
`);
|
||||
} else {
|
||||
await ssh.execCommand(`docker start ${server.containerName}`);
|
||||
}
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl start ${server.serviceName}`);
|
||||
} else {
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopServer(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
await ssh.execCommand(`docker stop ${server.containerName}`);
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl stop ${server.serviceName}`);
|
||||
} else {
|
||||
// Different stop commands per server type
|
||||
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (check.code !== 0) {
|
||||
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Force killing ${server.id} after timeout`);
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartServer(server) {
|
||||
await stopServer(server);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await startServer(server);
|
||||
}
|
||||
|
||||
export async function getConsoleLog(server, lines = 50) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.logFile) {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
} else {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessUptime(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === "docker") {
|
||||
const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`);
|
||||
if (result.stdout.trim()) {
|
||||
const startTime = new Date(result.stdout.trim());
|
||||
if (!isNaN(startTime.getTime())) {
|
||||
return Math.floor((Date.now() - startTime.getTime()) / 1000);
|
||||
}
|
||||
}
|
||||
} else if (server.runtime === "systemd") {
|
||||
const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`);
|
||||
const startEpoch = parseInt(result.stdout.trim());
|
||||
if (!isNaN(startEpoch)) {
|
||||
return Math.floor(Date.now() / 1000) - startEpoch;
|
||||
}
|
||||
} else {
|
||||
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(result.stdout.trim());
|
||||
if (!isNaN(uptime)) return uptime;
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get uptime for ${server.id}:`, err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ FACTORIO-SPECIFIC FUNCTIONS ============
|
||||
|
||||
export async function listFactorioSaves(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null');
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const saves = [];
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const filename = match[7].split('/').pop();
|
||||
// Skip autosaves
|
||||
if (filename.startsWith('_autosave')) continue;
|
||||
|
||||
const size = match[5];
|
||||
const modified = match[6];
|
||||
|
||||
saves.push({
|
||||
name: filename.replace('.zip', ''),
|
||||
filename,
|
||||
size,
|
||||
modified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return saves;
|
||||
}
|
||||
|
||||
export async function deleteFactorioSave(server, saveName) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`;
|
||||
|
||||
// Security check - prevent path traversal
|
||||
if (filename.includes('/') || filename.includes('..')) {
|
||||
throw new Error('Invalid save name');
|
||||
}
|
||||
|
||||
const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to delete save');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFactorioWorld(server, saveName, settings) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Security check
|
||||
if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) {
|
||||
throw new Error("Invalid save name - no spaces or special characters allowed");
|
||||
}
|
||||
|
||||
// Write map-gen-settings.json
|
||||
const settingsJson = JSON.stringify(settings, null, 2);
|
||||
await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF'
|
||||
${settingsJson}
|
||||
SETTINGSEOF`);
|
||||
|
||||
// Create new world using --create flag (correct method)
|
||||
console.log(`Creating new Factorio world: ${saveName}`);
|
||||
const createResult = await ssh.execCommand(`
|
||||
docker run --rm \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
factoriotools/factorio \
|
||||
/opt/factorio/bin/x64/factorio \
|
||||
--create /factorio/saves/${saveName}.zip \
|
||||
--map-gen-settings /factorio/config/map-gen-settings.json
|
||||
`, { execOptions: { timeout: 120000 } });
|
||||
|
||||
console.log("World creation output:", createResult.stdout, createResult.stderr);
|
||||
|
||||
// Verify save was created
|
||||
const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`);
|
||||
if (checkResult.code !== 0) {
|
||||
throw new Error("World creation failed - save file not found. Output: " + createResult.stderr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFactorioCurrentSave(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Check if container has SAVE_NAME env set
|
||||
const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`);
|
||||
if (envResult.stdout.trim()) {
|
||||
const saveName = envResult.stdout.trim().replace("SAVE_NAME=", "");
|
||||
return { save: saveName, source: "configured" };
|
||||
}
|
||||
|
||||
// Otherwise find newest save file
|
||||
const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1");
|
||||
if (result.stdout.trim()) {
|
||||
const filename = result.stdout.trim().split("/").pop().replace(".zip", "");
|
||||
return { save: filename, source: "newest" };
|
||||
}
|
||||
|
||||
return { save: null, source: null };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============ ZOMBOID CONFIG FUNCTIONS ============
|
||||
|
||||
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
|
||||
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
|
||||
|
||||
export async function listZomboidConfigs(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readZomboidConfig(server, filename) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writeZomboidConfig(server, filename, content) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ PALWORLD CONFIG ============
|
||||
const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer";
|
||||
const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"];
|
||||
|
||||
export async function listPalworldConfigs(server) {
|
||||
const ssh = await getConnection(server.host);
|
||||
const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readPalworldConfig(server, filename) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writePalworldConfig(server, filename, content) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
493
gsm-backend/services/ssh.js.bak2
Normal file
493
gsm-backend/services/ssh.js.bak2
Normal file
@@ -0,0 +1,493 @@
|
||||
import { NodeSSH } from "node-ssh";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sshConnections = new Map();
|
||||
const failedHosts = new Map(); // Cache failed connections
|
||||
const FAILED_HOST_TTL = 60000; // 60 seconds before retry
|
||||
const SSH_TIMEOUT = 5000;
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
|
||||
}
|
||||
|
||||
// Check if host is marked as failed (non-blocking)
|
||||
export function isHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
const failedAt = failedHosts.get(key);
|
||||
return failedAt && Date.now() - failedAt < FAILED_HOST_TTL;
|
||||
}
|
||||
|
||||
// Mark host as failed
|
||||
export function markHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.set(key, Date.now());
|
||||
}
|
||||
|
||||
// Clear failed status
|
||||
export function clearHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.delete(key);
|
||||
}
|
||||
|
||||
async function getConnection(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
|
||||
// Check if host recently failed - throw immediately
|
||||
if (isHostFailed(host, username)) {
|
||||
throw new Error("Host recently unreachable");
|
||||
}
|
||||
|
||||
if (sshConnections.has(key)) {
|
||||
const conn = sshConnections.get(key);
|
||||
if (conn.isConnected()) return conn;
|
||||
sshConnections.delete(key);
|
||||
}
|
||||
|
||||
const ssh = new NodeSSH();
|
||||
try {
|
||||
await ssh.connect({
|
||||
host,
|
||||
username,
|
||||
privateKeyPath: "/root/.ssh/id_ed25519",
|
||||
readyTimeout: SSH_TIMEOUT
|
||||
});
|
||||
clearHostFailed(host, username);
|
||||
sshConnections.set(key, ssh);
|
||||
return ssh;
|
||||
} catch (err) {
|
||||
markHostFailed(host, username);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns: "online", "starting", "stopping", "offline"
|
||||
export async function getServerStatus(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'running') return 'online';
|
||||
if (status === 'restarting' || status === 'created') return 'starting';
|
||||
if (status === 'removing' || status === 'paused') return 'stopping';
|
||||
return 'offline';
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'active') return 'online';
|
||||
if (status === 'activating' || status === 'reloading') return 'starting';
|
||||
if (status === 'deactivating') return 'stopping';
|
||||
return 'offline';
|
||||
} else {
|
||||
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (result.code === 0) {
|
||||
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
|
||||
if (uptime < 60) return 'starting';
|
||||
return 'online';
|
||||
}
|
||||
return 'offline';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to get status for ${server.id}:`, err.message);
|
||||
return 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServer(server, options = {}) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
// Factorio with specific save
|
||||
if (server.type === 'factorio' && options.save) {
|
||||
const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save;
|
||||
// Stop and remove existing container
|
||||
await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`);
|
||||
await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`);
|
||||
// Start with specific save
|
||||
await ssh.execCommand(`
|
||||
docker run -d \
|
||||
--name ${server.containerName} \
|
||||
-p 34197:34197/udp \
|
||||
-p 27015:27015/tcp \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
-e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \
|
||||
-e TZ=Europe/Berlin \
|
||||
-e PUID=845 \
|
||||
-e PGID=845 \
|
||||
--restart=unless-stopped \
|
||||
factoriotools/factorio
|
||||
`);
|
||||
} else {
|
||||
await ssh.execCommand(`docker start ${server.containerName}`);
|
||||
}
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl start ${server.serviceName}`);
|
||||
} else {
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopServer(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
await ssh.execCommand(`docker stop ${server.containerName}`);
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl stop ${server.serviceName}`);
|
||||
} else {
|
||||
// Different stop commands per server type
|
||||
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (check.code !== 0) {
|
||||
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Force killing ${server.id} after timeout`);
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartServer(server) {
|
||||
await stopServer(server);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await startServer(server);
|
||||
}
|
||||
|
||||
export async function getConsoleLog(server, lines = 50) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.logFile) {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
} else {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessUptime(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === "docker") {
|
||||
const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`);
|
||||
if (result.stdout.trim()) {
|
||||
const startTime = new Date(result.stdout.trim());
|
||||
if (!isNaN(startTime.getTime())) {
|
||||
return Math.floor((Date.now() - startTime.getTime()) / 1000);
|
||||
}
|
||||
}
|
||||
} else if (server.runtime === "systemd") {
|
||||
const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`);
|
||||
const startEpoch = parseInt(result.stdout.trim());
|
||||
if (!isNaN(startEpoch)) {
|
||||
return Math.floor(Date.now() / 1000) - startEpoch;
|
||||
}
|
||||
} else {
|
||||
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(result.stdout.trim());
|
||||
if (!isNaN(uptime)) return uptime;
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get uptime for ${server.id}:`, err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ FACTORIO-SPECIFIC FUNCTIONS ============
|
||||
|
||||
export async function listFactorioSaves(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null');
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const saves = [];
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const filename = match[7].split('/').pop();
|
||||
// Skip autosaves
|
||||
if (filename.startsWith('_autosave')) continue;
|
||||
|
||||
const size = match[5];
|
||||
const modified = match[6];
|
||||
|
||||
saves.push({
|
||||
name: filename.replace('.zip', ''),
|
||||
filename,
|
||||
size,
|
||||
modified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return saves;
|
||||
}
|
||||
|
||||
export async function deleteFactorioSave(server, saveName) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`;
|
||||
|
||||
// Security check - prevent path traversal
|
||||
if (filename.includes('/') || filename.includes('..')) {
|
||||
throw new Error('Invalid save name');
|
||||
}
|
||||
|
||||
const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to delete save');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFactorioWorld(server, saveName, settings) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Security check
|
||||
if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) {
|
||||
throw new Error("Invalid save name - no spaces or special characters allowed");
|
||||
}
|
||||
|
||||
// Write map-gen-settings.json
|
||||
const settingsJson = JSON.stringify(settings, null, 2);
|
||||
await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF'
|
||||
${settingsJson}
|
||||
SETTINGSEOF`);
|
||||
|
||||
// Create new world using --create flag (correct method)
|
||||
console.log(`Creating new Factorio world: ${saveName}`);
|
||||
const createResult = await ssh.execCommand(`
|
||||
docker run --rm \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
factoriotools/factorio \
|
||||
/opt/factorio/bin/x64/factorio \
|
||||
--create /factorio/saves/${saveName}.zip \
|
||||
--map-gen-settings /factorio/config/map-gen-settings.json
|
||||
`, { execOptions: { timeout: 120000 } });
|
||||
|
||||
console.log("World creation output:", createResult.stdout, createResult.stderr);
|
||||
|
||||
// Verify save was created
|
||||
const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`);
|
||||
if (checkResult.code !== 0) {
|
||||
throw new Error("World creation failed - save file not found. Output: " + createResult.stderr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFactorioCurrentSave(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Check if container has SAVE_NAME env set
|
||||
const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`);
|
||||
if (envResult.stdout.trim()) {
|
||||
const saveName = envResult.stdout.trim().replace("SAVE_NAME=", "");
|
||||
return { save: saveName, source: "configured" };
|
||||
}
|
||||
|
||||
// Otherwise find newest save file
|
||||
const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1");
|
||||
if (result.stdout.trim()) {
|
||||
const filename = result.stdout.trim().split("/").pop().replace(".zip", "");
|
||||
return { save: filename, source: "newest" };
|
||||
}
|
||||
|
||||
return { save: null, source: null };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============ ZOMBOID CONFIG FUNCTIONS ============
|
||||
|
||||
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
|
||||
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
|
||||
|
||||
export async function listZomboidConfigs(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readZomboidConfig(server, filename) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writeZomboidConfig(server, filename, content) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ PALWORLD CONFIG ============
|
||||
const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer";
|
||||
const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"];
|
||||
|
||||
export async function listPalworldConfigs(server) {
|
||||
const ssh = await getConnection(server.host);
|
||||
const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readPalworldConfig(server, filename) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writePalworldConfig(server, filename, content) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
18
gsm-frontend/package-lock.json
generated
18
gsm-frontend/package-lock.json
generated
@@ -78,6 +78,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1792,6 +1793,7 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1839,6 +1841,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1981,6 +1984,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2397,6 +2401,7 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3419,6 +3424,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3446,6 +3452,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3487,6 +3494,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3496,6 +3504,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3515,6 +3524,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -3615,7 +3625,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -3779,7 +3790,8 @@
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
@@ -3918,6 +3930,7 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4039,6 +4052,7 @@
|
||||
"integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
BIN
gsm-frontend/public/openttd.png
Normal file
BIN
gsm-frontend/public/openttd.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
gsm-frontend/public/palworld.png
Normal file
BIN
gsm-frontend/public/palworld.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
BIN
gsm-frontend/public/terraria.png
Normal file
BIN
gsm-frontend/public/terraria.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
gsm-frontend/public/zomboid.png
Normal file
BIN
gsm-frontend/public/zomboid.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -3,6 +3,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { UserProvider } from './context/UserContext'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import ServerDetail from './pages/ServerDetail'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import AuthCallback from './pages/AuthCallback'
|
||||
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('gsm_token'))
|
||||
@@ -18,11 +20,13 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider token={token}>
|
||||
<UserProvider token={token} onLogout={handleLogout}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard onLogin={handleLogin} onLogout={handleLogout} />} />
|
||||
<Route path="/server/:serverId" element={<ServerDetail onLogin={handleLogin} onLogout={handleLogout} />} />
|
||||
<Route path="/" element={token ? <Dashboard onLogout={handleLogout} /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/server/:serverId" element={token ? <ServerDetail onLogout={handleLogout} /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback onLogin={handleLogin} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'https://monitor.dimension47.de/api'
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
async function fetchAPI(endpoint, options = {}) {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
@@ -9,6 +9,13 @@ async function fetchAPI(endpoint, options = {}) {
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-logout on auth errors (invalid/expired token)
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
localStorage.removeItem('gsm_token')
|
||||
window.location.href = '/'
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }))
|
||||
throw new Error(error.message || `HTTP ${response.status}`)
|
||||
@@ -185,3 +192,121 @@ export async function getFactorioWorldSettings(token, saveName) {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-Shutdown Settings
|
||||
export async function getAutoShutdownSettings(token, serverId) {
|
||||
return fetchAPI(`/servers/${serverId}/autoshutdown`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function setAutoShutdownSettings(token, serverId, enabled, timeoutMinutes) {
|
||||
return fetchAPI(`/servers/${serverId}/autoshutdown`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ enabled, timeoutMinutes }),
|
||||
})
|
||||
}
|
||||
|
||||
// Zomboid Config Management
|
||||
export async function getZomboidConfigs(token) {
|
||||
return fetchAPI('/servers/zomboid/config', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getZomboidConfig(token, filename) {
|
||||
return fetchAPI(`/servers/zomboid/config/${encodeURIComponent(filename)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveZomboidConfig(token, filename, content) {
|
||||
return fetchAPI(`/servers/zomboid/config/${encodeURIComponent(filename)}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
}
|
||||
|
||||
// Palworld Config Management
|
||||
export async function getPalworldConfigs(token) {
|
||||
return fetchAPI('/servers/palworld/config', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPalworldConfig(token, filename) {
|
||||
return fetchAPI(`/servers/palworld/config/${encodeURIComponent(filename)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function savePalworldConfig(token, filename, content) {
|
||||
return fetchAPI(`/servers/palworld/config/${encodeURIComponent(filename)}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
}
|
||||
|
||||
// Terraria Config Management
|
||||
export async function getTerrariaConfig(token) {
|
||||
return fetchAPI('/servers/terraria/config', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveTerrariaConfig(token, content) {
|
||||
return fetchAPI('/servers/terraria/config', {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
}
|
||||
|
||||
// OpenTTD Config Management
|
||||
export async function getOpenTTDConfig(token) {
|
||||
return fetchAPI('/servers/openttd/config', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveOpenTTDConfig(token, content) {
|
||||
return fetchAPI('/servers/openttd/config', {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
}
|
||||
|
||||
// Activity Log
|
||||
export async function getActivityLog(token, limit = 100) {
|
||||
return fetchAPI(`/servers/activity-log?limit=${limit}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
// Display Settings
|
||||
export async function getAllDisplaySettings(token) {
|
||||
return fetchAPI('/servers/display-settings', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getDisplaySettings(token, serverId) {
|
||||
return fetchAPI(`/servers/${serverId}/display-settings`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveDisplaySettings(token, serverId, address, hint) {
|
||||
return fetchAPI(`/servers/${serverId}/display-settings`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ address, hint }),
|
||||
})
|
||||
}
|
||||
|
||||
// Alias for backwards compatibility
|
||||
export const setDisplaySettings = saveDisplaySettings
|
||||
|
||||
175
gsm-frontend/src/components/ActivityLog.jsx
Normal file
175
gsm-frontend/src/components/ActivityLog.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useUser } from '../context/UserContext'
|
||||
import { getActivityLog } from '../api'
|
||||
|
||||
const actionLabels = {
|
||||
server_start: 'Server gestartet',
|
||||
server_stop: 'Server gestoppt',
|
||||
server_restart: 'Server neugestartet',
|
||||
rcon_command: 'RCON Befehl',
|
||||
autoshutdown_config: 'Auto-Shutdown geändert',
|
||||
zomboid_config: 'Config geändert',
|
||||
palworld_config: 'Config geändert',
|
||||
factorio_world_create: 'Welt erstellt',
|
||||
factorio_world_delete: 'Welt gelöscht'
|
||||
}
|
||||
|
||||
const actionIcons = {
|
||||
server_start: '▶️',
|
||||
server_stop: '⏹️',
|
||||
server_restart: '🔄',
|
||||
rcon_command: '💻',
|
||||
autoshutdown_config: '⏱️',
|
||||
zomboid_config: '📝',
|
||||
palworld_config: '📝',
|
||||
factorio_world_create: '🌍',
|
||||
factorio_world_delete: '🗑️'
|
||||
}
|
||||
|
||||
const serverLabels = {
|
||||
minecraft: 'Minecraft',
|
||||
factorio: 'Factorio',
|
||||
zomboid: 'Project Zomboid',
|
||||
vrising: 'V Rising',
|
||||
palworld: 'Palworld'
|
||||
}
|
||||
|
||||
function getAvatarUrl(discordId, avatar) {
|
||||
if (!discordId || !avatar) return null
|
||||
return `https://cdn.discordapp.com/avatars/${discordId}/${avatar}.png?size=32`
|
||||
}
|
||||
|
||||
function getDiscordProfileUrl(discordId) {
|
||||
return `https://discord.com/users/${discordId}`
|
||||
}
|
||||
|
||||
export default function ActivityLog({ onClose }) {
|
||||
const { token } = useUser()
|
||||
const [logs, setLogs] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const data = await getActivityLog(token, 100)
|
||||
setLogs(data)
|
||||
setError('')
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden des Activity Logs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchLogs()
|
||||
}, [token])
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr + 'Z')
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return 'Gerade eben'
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + ' Min'
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + ' Std'
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop fade-in" onClick={onClose}>
|
||||
<div className="modal fade-in-scale" style={{ maxWidth: '42rem' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Activity Log</h2>
|
||||
<button onClick={onClose} className="btn btn-ghost">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{error && (
|
||||
<div className="alert alert-error mb-4">{error}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-4 text-neutral-400">Laden...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-4 text-neutral-500">Noch keine Aktivitäten</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => {
|
||||
const avatarUrl = getAvatarUrl(log.discord_id, log.avatar)
|
||||
const profileUrl = log.discord_id ? getDiscordProfileUrl(log.discord_id) : null
|
||||
|
||||
return (
|
||||
<div key={log.id} className="card p-3 flex items-start gap-3">
|
||||
{/* Avatar or Action Icon */}
|
||||
{avatarUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full hover:ring-2 hover:ring-blue-500 transition-all"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-neutral-700 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-neutral-400 text-xs">
|
||||
{log.username?.charAt(0)?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{profileUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white font-medium hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{log.username}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-white font-medium">{log.username}</span>
|
||||
)}
|
||||
<span className="text-neutral-500">
|
||||
{actionIcons[log.action] || '📋'} {actionLabels[log.action] || log.action}
|
||||
</span>
|
||||
{log.target && (
|
||||
<span className="text-blue-400">
|
||||
{serverLabels[log.target] || log.target}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{log.details && (
|
||||
<div className="text-sm text-neutral-500 mt-1 truncate font-mono">
|
||||
{log.details}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-neutral-500 whitespace-nowrap">
|
||||
{formatDate(log.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +1,36 @@
|
||||
import { useState } from 'react'
|
||||
import { login } from '../api'
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
export default function LoginModal({ onLogin, onClose }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const { token } = await login(username, password)
|
||||
onLogin(token)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
export default function LoginModal({ onClose }) {
|
||||
const handleDiscordLogin = () => {
|
||||
window.location.href = `${API_URL}/auth/discord`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Sign in</h2>
|
||||
<h2 className="modal-title">Anmelden</h2>
|
||||
<button onClick={onClose} className="btn btn-ghost">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-neutral-400 text-sm mb-4">
|
||||
Melde dich mit Discord an um erweiterte Funktionen zu nutzen.
|
||||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Enter username"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
onClick={handleDiscordLogin}
|
||||
className="w-full flex items-center justify-center gap-3 text-white font-medium py-3 px-6 rounded-lg transition-all duration-200 hover:scale-[1.02]"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f144e8 0%, #7128d7 100%)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
Mit Discord anmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
244
gsm-frontend/src/components/OpenTTDConfigEditor.jsx
Normal file
244
gsm-frontend/src/components/OpenTTDConfigEditor.jsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { FileText, Save, RefreshCw, AlertTriangle, Check, X } from 'lucide-react'
|
||||
import { getOpenTTDConfig, saveOpenTTDConfig } from '../api'
|
||||
|
||||
export default function OpenTTDConfigEditor({ token }) {
|
||||
const [content, setContent] = useState('')
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const textareaRef = useRef(null)
|
||||
const highlightRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== originalContent)
|
||||
}, [content, originalContent])
|
||||
|
||||
const handleScroll = () => {
|
||||
if (highlightRef.current && textareaRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getOpenTTDConfig(token)
|
||||
setContent(data.content)
|
||||
setOriginalContent(data.content)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Config: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasChanges) return
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
await saveOpenTTDConfig(token, content)
|
||||
setOriginalContent(content)
|
||||
setSuccess('Config gespeichert! Server-Neustart erforderlich.')
|
||||
setTimeout(() => setSuccess(null), 5000)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
setContent(originalContent)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
function highlightSyntax(text) {
|
||||
if (!text) return ''
|
||||
|
||||
const lines = text.split('\n')
|
||||
|
||||
return lines.map((line) => {
|
||||
let highlighted = line
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Section headers [section]
|
||||
if (line.trim().startsWith('[') && line.trim().endsWith(']')) {
|
||||
highlighted = `<span class="text-purple-400 font-bold">${highlighted}</span>`
|
||||
}
|
||||
// Comments (;)
|
||||
else if (line.trim().startsWith(';')) {
|
||||
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||
}
|
||||
// key = value
|
||||
else if (line.includes('=')) {
|
||||
const idx = line.indexOf('=')
|
||||
const key = highlighted.substring(0, idx)
|
||||
const value = highlighted.substring(idx + 1)
|
||||
|
||||
// Color numbers, true/false, and quoted strings
|
||||
let coloredValue = value
|
||||
.replace(/\b(true|false)\b/gi, '<span class="text-orange-400">$1</span>')
|
||||
.replace(/\b(\d+)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||
|
||||
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${coloredValue}</span>`
|
||||
}
|
||||
|
||||
return highlighted
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-400">Lade Config...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>openttd.cfg - Server-Einstellungen</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Neu laden"
|
||||
>
|
||||
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error/Success messages */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||
<Check className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||
<pre
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: highlightSyntax(content) + '\n' }}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
style={{ caretColor: 'white' }}
|
||||
/>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||
Ungespeichert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Verwerfen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||
(hasChanges && !saving
|
||||
? 'bg-green-600 hover:bg-green-500 text-white border-2 border-green-400'
|
||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed border-2 border-gray-600'
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||
<div className="flex flex-wrap gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-purple-400"></span>
|
||||
<span className="text-gray-400">Sektion</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-emerald-500"></span>
|
||||
<span className="text-gray-400">Kommentare</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||
<span className="text-gray-400">Einstellung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||
<span className="text-gray-400">Wert</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-orange-400"></span>
|
||||
<span className="text-gray-400">Boolean</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||
<span className="text-gray-400">Zahlen</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
336
gsm-frontend/src/components/PalworldConfigEditor.jsx
Normal file
336
gsm-frontend/src/components/PalworldConfigEditor.jsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react'
|
||||
import { getPalworldConfigs, getPalworldConfig, savePalworldConfig } from '../api'
|
||||
|
||||
export default function PalworldConfigEditor({ token }) {
|
||||
const [files, setFiles] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState(null)
|
||||
const [content, setContent] = useState('')
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const textareaRef = useRef(null)
|
||||
const highlightRef = useRef(null)
|
||||
|
||||
// Load file list
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
}, [token])
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== originalContent)
|
||||
}, [content, originalContent])
|
||||
|
||||
// Sync scroll between textarea and highlight div
|
||||
const handleScroll = () => {
|
||||
if (highlightRef.current && textareaRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getPalworldConfigs(token)
|
||||
setFiles(data.files || [])
|
||||
if (data.files?.length > 0 && !selectedFile) {
|
||||
loadFile(data.files[0].filename)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Config-Dateien: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFile(filename) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const data = await getPalworldConfig(token, filename)
|
||||
setSelectedFile(filename)
|
||||
// Format the config for better readability
|
||||
let formattedContent = data.content
|
||||
if (filename === 'PalWorldSettings.ini') {
|
||||
formattedContent = formatPalworldConfig(data.content)
|
||||
}
|
||||
setContent(formattedContent)
|
||||
setOriginalContent(formattedContent)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Format Palworld config for better readability
|
||||
function formatPalworldConfig(content) {
|
||||
// The config is one long line with OptionSettings=(...)
|
||||
// We'll split key=value pairs for better readability
|
||||
return content
|
||||
.replace(/,(?=[A-Z])/g, ',\n') // Add newline before each key
|
||||
.replace(/\((?=[A-Z])/g, '(\n') // Newline after opening paren
|
||||
.replace(/\)$/gm, '\n)') // Newline before closing paren
|
||||
}
|
||||
|
||||
// Compact config back to single line for saving
|
||||
function compactPalworldConfig(content) {
|
||||
if (!selectedFile?.includes('PalWorldSettings.ini')) return content
|
||||
return content
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.join('')
|
||||
.replace(/,\s+/g, ',')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedFile || !hasChanges) return
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const saveContent = selectedFile === 'PalWorldSettings.ini'
|
||||
? compactPalworldConfig(content)
|
||||
: content
|
||||
await savePalworldConfig(token, selectedFile, saveContent)
|
||||
setOriginalContent(content)
|
||||
setSuccess('Config gespeichert! Server-Neustart erforderlich.')
|
||||
setTimeout(() => setSuccess(null), 5000)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
setContent(originalContent)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
function getFileDescription(filename) {
|
||||
const descriptions = {
|
||||
'PalWorldSettings.ini': 'Server-Einstellungen (Spieler, Raten, RCON)',
|
||||
'Engine.ini': 'Engine-Konfiguration (Performance)',
|
||||
'GameUserSettings.ini': 'Grafik und Audio Einstellungen'
|
||||
}
|
||||
return descriptions[filename] || filename
|
||||
}
|
||||
|
||||
// Highlight syntax for INI files
|
||||
function highlightSyntax(text) {
|
||||
if (!text) return ''
|
||||
|
||||
const lines = text.split('\n')
|
||||
|
||||
return lines.map((line) => {
|
||||
let highlighted = line
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Section headers [Section]
|
||||
if (line.trim().startsWith('[') && line.trim().endsWith(']')) {
|
||||
highlighted = `<span class="text-purple-400">${highlighted}</span>`
|
||||
}
|
||||
// Comments (;)
|
||||
else if (line.trim().startsWith(';')) {
|
||||
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||
}
|
||||
// Highlight key=value
|
||||
else if (line.includes('=')) {
|
||||
const idx = line.indexOf('=')
|
||||
const key = highlighted.substring(0, idx)
|
||||
const value = highlighted.substring(idx + 1)
|
||||
|
||||
// Color boolean values
|
||||
let coloredValue = value
|
||||
.replace(/\b(True|False)\b/gi, '<span class="text-orange-400">$1</span>')
|
||||
.replace(/\b(\d+\.?\d*)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||
|
||||
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${coloredValue}</span>`
|
||||
}
|
||||
|
||||
return highlighted
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-400">Lade Config-Dateien...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* File selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<select
|
||||
value={selectedFile || ''}
|
||||
onChange={(e) => {
|
||||
if (hasChanges) {
|
||||
if (!confirm('Ungespeicherte Änderungen verwerfen?')) return
|
||||
}
|
||||
loadFile(e.target.value)
|
||||
}}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 pr-10 text-white appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{files.map(file => (
|
||||
<option key={file.filename} value={file.filename}>
|
||||
{file.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => loadFile(selectedFile)}
|
||||
disabled={loading}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Neu laden"
|
||||
>
|
||||
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File description */}
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{getFileDescription(selectedFile)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error/Success messages */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||
<Check className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor with syntax highlighting */}
|
||||
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||
{/* Highlighted background layer */}
|
||||
<pre
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: highlightSyntax(content) + '\n' }}
|
||||
/>
|
||||
|
||||
{/* Transparent textarea for editing */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
style={{ caretColor: 'white' }}
|
||||
/>
|
||||
|
||||
{/* Change indicator */}
|
||||
{hasChanges && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||
Ungespeichert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
{selectedFile && files.find(f => f.filename === selectedFile)?.modified && (
|
||||
<span>Zuletzt geändert: {files.find(f => f.filename === selectedFile).modified}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Verwerfen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||
(hasChanges && !saving
|
||||
? 'bg-green-600 hover:bg-green-500 text-white border-2 border-green-400'
|
||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed border-2 border-gray-600'
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||
<div className="flex flex-wrap gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-purple-400"></span>
|
||||
<span className="text-gray-400">Sektion</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||
<span className="text-gray-400">Einstellung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||
<span className="text-gray-400">Wert</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-orange-400"></span>
|
||||
<span className="text-gray-400">Boolean</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||
<span className="text-gray-400">Zahlen</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const serverInfo = {
|
||||
minecraft: {
|
||||
address: 'minecraft.dimension47.de',
|
||||
address: 'minecraft.zeasy.dev',
|
||||
logo: '/minecraft.png',
|
||||
links: [
|
||||
{ label: 'ATM10 Modpack', url: 'https://www.curseforge.com/minecraft/modpacks/all-the-mods-10' }
|
||||
@@ -8,7 +8,7 @@ const serverInfo = {
|
||||
},
|
||||
factorio: {
|
||||
hint: 'Serverpasswort: affe',
|
||||
address: 'factorio.dimension47.de',
|
||||
address: 'factorio.zeasy.dev',
|
||||
logo: '/factorio.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/427520/Factorio/' }
|
||||
@@ -20,6 +20,35 @@ const serverInfo = {
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/1604030/V_Rising/' }
|
||||
]
|
||||
},
|
||||
zomboid: {
|
||||
hint: 'Version 42.13.1',
|
||||
address: 'pz.zeasy.dev:16261',
|
||||
logo: '/zomboid.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/108600/Project_Zomboid/' }
|
||||
]
|
||||
},
|
||||
palworld: {
|
||||
address: 'palworld.zeasy.dev:8211',
|
||||
logo: '/palworld.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/1623730/Palworld/' }
|
||||
]
|
||||
},
|
||||
terraria: {
|
||||
address: 'terraria.zeasy.dev:7777',
|
||||
logo: '/terraria.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/105600/Terraria/' }
|
||||
]
|
||||
},
|
||||
openttd: {
|
||||
address: 'openttd.zeasy.dev:3979',
|
||||
logo: '/openttd.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/1536610/OpenTTD/' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +57,27 @@ const getServerInfo = (serverName) => {
|
||||
if (name.includes('minecraft') || name.includes('all the mods')) return serverInfo.minecraft
|
||||
if (name.includes('factorio')) return serverInfo.factorio
|
||||
if (name.includes('vrising') || name.includes('v rising')) return serverInfo.vrising
|
||||
if (name.includes('zomboid')) return serverInfo.zomboid
|
||||
if (name.includes('palworld')) return serverInfo.palworld
|
||||
if (name.includes('terraria')) return serverInfo.terraria
|
||||
if (name.includes('openttd')) return serverInfo.openttd
|
||||
return null
|
||||
}
|
||||
|
||||
export default function ServerCard({ server, onClick, isAuthenticated }) {
|
||||
const info = getServerInfo(server.name)
|
||||
export default function ServerCard({ server, onClick, isAuthenticated, displaySettings }) {
|
||||
const defaultInfo = getServerInfo(server.name)
|
||||
|
||||
// Merge default info with database display settings (database takes priority)
|
||||
const info = defaultInfo ? {
|
||||
...defaultInfo,
|
||||
address: displaySettings?.address || defaultInfo.address,
|
||||
hint: displaySettings?.hint || defaultInfo.hint
|
||||
} : (displaySettings ? {
|
||||
address: displaySettings.address,
|
||||
hint: displaySettings.hint,
|
||||
logo: null,
|
||||
links: []
|
||||
} : null)
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
@@ -106,27 +151,20 @@ export default function ServerCard({ server, onClick, isAuthenticated }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whitelist notice for Minecraft - only for authenticated users */}
|
||||
{isAuthenticated && server.type === 'minecraft' && (
|
||||
{/* Server hint - only for authenticated users */}
|
||||
{isAuthenticated && info?.hint && (
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
{info.hint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whitelist notice for Minecraft - only if no custom hint is set */}
|
||||
{isAuthenticated && server.type === 'minecraft' && !displaySettings?.hint && (
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
Whitelist erforderlich - im Whitelist-Tab freischalten
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Factorio notice - only for authenticated users */}
|
||||
{isAuthenticated && server.type === 'factorio' && (
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
Serverpasswort: affe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* V Rising notice - only for authenticated users */}
|
||||
{isAuthenticated && server.type === 'vrising' && (
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
In der Serverliste suchen - Passwort: affe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="space-y-3">
|
||||
{/* CPU */}
|
||||
@@ -162,13 +200,25 @@ export default function ServerCard({ server, onClick, isAuthenticated }) {
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-neutral-800 text-sm">
|
||||
<div className="text-neutral-400">
|
||||
<span className="text-white font-medium">{server.players.online}</span>
|
||||
{server.players.max ? ' / ' + server.players.max : ''} players
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-neutral-400">
|
||||
<span className="text-white font-medium">{server.players.online}</span>
|
||||
{server.players.max ? ' / ' + server.players.max : ''} Spieler
|
||||
</div>
|
||||
{server.running && server.autoShutdown?.enabled && server.autoShutdown?.emptySinceMinutes !== null && (
|
||||
<div className="flex items-center gap-1.5 text-yellow-500">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-xs">
|
||||
Shutdown in {server.autoShutdown.timeoutMinutes - server.autoShutdown.emptySinceMinutes}m
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{server.running && (
|
||||
<div className="text-neutral-400">
|
||||
Uptime: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
|
||||
Laufzeit: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
230
gsm-frontend/src/components/TerrariaConfigEditor.jsx
Normal file
230
gsm-frontend/src/components/TerrariaConfigEditor.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { FileText, Save, RefreshCw, AlertTriangle, Check, X } from 'lucide-react'
|
||||
import { getTerrariaConfig, saveTerrariaConfig } from '../api'
|
||||
|
||||
export default function TerrariaConfigEditor({ token }) {
|
||||
const [content, setContent] = useState('')
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const textareaRef = useRef(null)
|
||||
const highlightRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== originalContent)
|
||||
}, [content, originalContent])
|
||||
|
||||
const handleScroll = () => {
|
||||
if (highlightRef.current && textareaRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getTerrariaConfig(token)
|
||||
setContent(data.content)
|
||||
setOriginalContent(data.content)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Config: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasChanges) return
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
await saveTerrariaConfig(token, content)
|
||||
setOriginalContent(content)
|
||||
setSuccess('Config gespeichert! Server-Neustart erforderlich.')
|
||||
setTimeout(() => setSuccess(null), 5000)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
setContent(originalContent)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
function highlightSyntax(text) {
|
||||
if (!text) return ''
|
||||
|
||||
const lines = text.split('\n')
|
||||
|
||||
return lines.map((line) => {
|
||||
let highlighted = line
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Comments (#)
|
||||
if (line.trim().startsWith('#')) {
|
||||
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||
}
|
||||
// key=value
|
||||
else if (line.includes('=')) {
|
||||
const idx = line.indexOf('=')
|
||||
const key = highlighted.substring(0, idx)
|
||||
const value = highlighted.substring(idx + 1)
|
||||
|
||||
let coloredValue = value
|
||||
.replace(/\b(\d+)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||
|
||||
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${coloredValue}</span>`
|
||||
}
|
||||
|
||||
return highlighted
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-400">Lade Config...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>serverconfig.txt - Server-Einstellungen</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Neu laden"
|
||||
>
|
||||
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error/Success messages */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||
<Check className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||
<pre
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: highlightSyntax(content) + '\n' }}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
style={{ caretColor: 'white' }}
|
||||
/>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||
Ungespeichert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Verwerfen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||
(hasChanges && !saving
|
||||
? 'bg-green-600 hover:bg-green-500 text-white border-2 border-green-400'
|
||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed border-2 border-gray-600'
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||
<div className="flex flex-wrap gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-emerald-500"></span>
|
||||
<span className="text-gray-400">Kommentare</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||
<span className="text-gray-400">Einstellung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||
<span className="text-gray-400">Wert</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||
<span className="text-gray-400">Zahlen</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useUser } from '../context/UserContext'
|
||||
import { getUsers, createUser, updateUserRole, updateUserPassword, deleteUser } from '../api'
|
||||
import { getUsers } from '../api'
|
||||
|
||||
function getDiscordProfileUrl(discordId) {
|
||||
return `https://discord.com/users/${discordId}`
|
||||
}
|
||||
|
||||
export default function UserManagement({ onClose }) {
|
||||
const { token } = useUser()
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showAddUser, setShowAddUser] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
|
||||
// Form state
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [role, setRole] = useState('user')
|
||||
const [formLoading, setFormLoading] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
@@ -33,71 +28,14 @@ export default function UserManagement({ onClose }) {
|
||||
fetchUsers()
|
||||
}, [token])
|
||||
|
||||
const resetForm = () => {
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setRole('user')
|
||||
setFormError('')
|
||||
setShowAddUser(false)
|
||||
setEditingUser(null)
|
||||
}
|
||||
|
||||
const handleAddUser = async (e) => {
|
||||
e.preventDefault()
|
||||
setFormError('')
|
||||
setFormLoading(true)
|
||||
|
||||
try {
|
||||
await createUser(token, { username, password, role })
|
||||
await fetchUsers()
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
setFormError(err.message || 'Failed to create user')
|
||||
} finally {
|
||||
setFormLoading(false)
|
||||
const getAvatarUrl = (user) => {
|
||||
const discordId = user.discord_id || user.discordId
|
||||
if (user.avatar && discordId) {
|
||||
return `https://cdn.discordapp.com/avatars/${discordId}/${user.avatar}.png?size=64`
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateUser = async (e) => {
|
||||
e.preventDefault()
|
||||
setFormError('')
|
||||
setFormLoading(true)
|
||||
|
||||
try {
|
||||
// Update role if changed
|
||||
if (role !== editingUser.role) {
|
||||
await updateUserRole(token, editingUser.id, role)
|
||||
}
|
||||
// Update password if provided
|
||||
if (password) {
|
||||
await updateUserPassword(token, editingUser.id, password)
|
||||
}
|
||||
await fetchUsers()
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
setFormError(err.message || 'Failed to update user')
|
||||
} finally {
|
||||
setFormLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return
|
||||
|
||||
try {
|
||||
await deleteUser(token, userId)
|
||||
await fetchUsers()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (user) => {
|
||||
setEditingUser(user)
|
||||
setUsername(user.username)
|
||||
setRole(user.role)
|
||||
setPassword('')
|
||||
setShowAddUser(false)
|
||||
// Default Discord avatar
|
||||
const defaultIndex = discordId ? parseInt(discordId) % 5 : 0
|
||||
return `https://cdn.discordapp.com/embed/avatars/${defaultIndex}.png`
|
||||
}
|
||||
|
||||
const roleLabels = {
|
||||
@@ -106,6 +44,12 @@ export default function UserManagement({ onClose }) {
|
||||
superadmin: 'Admin'
|
||||
}
|
||||
|
||||
const roleColors = {
|
||||
user: 'text-gray-400',
|
||||
moderator: 'text-blue-400',
|
||||
superadmin: 'text-amber-400'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop fade-in" onClick={onClose}>
|
||||
<div className="modal fade-in-scale" style={{ maxWidth: '32rem' }} onClick={(e) => e.stopPropagation()}>
|
||||
@@ -125,109 +69,64 @@ export default function UserManagement({ onClose }) {
|
||||
{loading ? (
|
||||
<div className="text-center py-4 text-neutral-400">Loading users...</div>
|
||||
) : (
|
||||
<div className="space-y-2 mb-4">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="card p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-medium">{user.username}</div>
|
||||
<div className="text-xs text-neutral-500">{roleLabels[user.role]}</div>
|
||||
<div className="space-y-2">
|
||||
{users.map((user) => {
|
||||
const discordId = user.discord_id || user.discordId
|
||||
const profileUrl = discordId ? getDiscordProfileUrl(discordId) : null
|
||||
|
||||
return (
|
||||
<div key={user.id} className="card p-3 flex items-center gap-3">
|
||||
{profileUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(user)}
|
||||
alt={user.username}
|
||||
className="w-10 h-10 rounded-full hover:ring-2 hover:ring-blue-500 transition-all"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={getAvatarUrl(user)}
|
||||
alt={user.username}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{profileUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white font-medium truncate block hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{user.username}
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-white font-medium truncate">{user.username}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className={roleColors[user.role] || 'text-neutral-500'}>
|
||||
{roleLabels[user.role]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => startEdit(user)}
|
||||
className="btn btn-ghost text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
className="btn btn-ghost text-sm text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddUser || editingUser) ? (
|
||||
<form onSubmit={editingUser ? handleUpdateUser : handleAddUser} className="space-y-4">
|
||||
<div className="border-t border-neutral-800 pt-4">
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">
|
||||
{editingUser ? 'Edit User' : 'Add New User'}
|
||||
</h3>
|
||||
|
||||
{formError && (
|
||||
<div className="alert alert-error mb-4">{formError}</div>
|
||||
)}
|
||||
|
||||
{!editingUser && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{editingUser ? 'New Password (leave empty to keep current)' : 'Password'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
required={!editingUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="select w-full"
|
||||
>
|
||||
<option value="user">Viewer</option>
|
||||
<option value="moderator">Operator</option>
|
||||
<option value="superadmin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{formLoading ? 'Saving...' : (editingUser ? 'Update User' : 'Add User')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAddUser(true)}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
)}
|
||||
{/* Info about Discord management */}
|
||||
<div className="mt-4 p-3 bg-neutral-800/50 rounded-lg border border-neutral-700">
|
||||
<p className="text-xs text-neutral-400">
|
||||
Benutzer und Rollen werden über Discord verwaltet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
326
gsm-frontend/src/components/ZomboidConfigEditor.jsx
Normal file
326
gsm-frontend/src/components/ZomboidConfigEditor.jsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react'
|
||||
import { getZomboidConfigs, getZomboidConfig, saveZomboidConfig } from '../api'
|
||||
|
||||
export default function ZomboidConfigEditor({ token }) {
|
||||
const [files, setFiles] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState(null)
|
||||
const [content, setContent] = useState('')
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const textareaRef = useRef(null)
|
||||
const highlightRef = useRef(null)
|
||||
|
||||
// Load file list
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
}, [token])
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== originalContent)
|
||||
}, [content, originalContent])
|
||||
|
||||
// Sync scroll between textarea and highlight div
|
||||
const handleScroll = () => {
|
||||
if (highlightRef.current && textareaRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getZomboidConfigs(token)
|
||||
setFiles(data.files || [])
|
||||
if (data.files?.length > 0 && !selectedFile) {
|
||||
loadFile(data.files[0].filename)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Config-Dateien: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFile(filename) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const data = await getZomboidConfig(token, filename)
|
||||
setSelectedFile(filename)
|
||||
setContent(data.content)
|
||||
setOriginalContent(data.content)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedFile || !hasChanges) return
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
await saveZomboidConfig(token, selectedFile, content)
|
||||
setOriginalContent(content)
|
||||
setSuccess('Config gespeichert! Server-Neustart erforderlich für Änderungen.')
|
||||
setTimeout(() => setSuccess(null), 5000)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
setContent(originalContent)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
function getFileDescription(filename) {
|
||||
const descriptions = {
|
||||
'Project.ini': 'Server-Einstellungen (PVP, Spieler, Netzwerk)',
|
||||
'Project_SandboxVars.lua': 'Gameplay-Einstellungen (Zombies, Loot, Schwierigkeit)',
|
||||
'Project_spawnpoints.lua': 'Spawn-Punkte für neue Spieler',
|
||||
'Project_spawnregions.lua': 'Spawn-Regionen Konfiguration'
|
||||
}
|
||||
return descriptions[filename] || filename
|
||||
}
|
||||
|
||||
// Highlight syntax based on file type
|
||||
function highlightSyntax(text, filename) {
|
||||
if (!text) return ''
|
||||
|
||||
const isLua = filename?.endsWith('.lua')
|
||||
const lines = text.split('\n')
|
||||
|
||||
return lines.map((line, i) => {
|
||||
let highlighted = line
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
if (isLua) {
|
||||
// Lua: -- comments
|
||||
if (line.trim().startsWith('--')) {
|
||||
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||
} else if (line.includes('--')) {
|
||||
const idx = line.indexOf('--')
|
||||
const code = highlighted.substring(0, idx)
|
||||
const comment = highlighted.substring(idx)
|
||||
highlighted = `${code}<span class="text-emerald-500">${comment}</span>`
|
||||
}
|
||||
// Highlight true/false/nil
|
||||
highlighted = highlighted
|
||||
.replace(/\b(true|false|nil)\b/g, '<span class="text-orange-400">$1</span>')
|
||||
// Highlight numbers
|
||||
highlighted = highlighted
|
||||
.replace(/\b(\d+\.?\d*)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||
} else {
|
||||
// INI: # comments
|
||||
if (line.trim().startsWith('#')) {
|
||||
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||
}
|
||||
// Highlight key=value
|
||||
else if (line.includes('=')) {
|
||||
const idx = line.indexOf('=')
|
||||
const key = highlighted.substring(0, idx)
|
||||
const value = highlighted.substring(idx + 1)
|
||||
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${value}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
return highlighted
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-400">Lade Config-Dateien...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* File selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<select
|
||||
value={selectedFile || ''}
|
||||
onChange={(e) => {
|
||||
if (hasChanges) {
|
||||
if (!confirm('Ungespeicherte Änderungen verwerfen?')) return
|
||||
}
|
||||
loadFile(e.target.value)
|
||||
}}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 pr-10 text-white appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{files.map(file => (
|
||||
<option key={file.filename} value={file.filename}>
|
||||
{file.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => loadFile(selectedFile)}
|
||||
disabled={loading}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Neu laden"
|
||||
>
|
||||
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File description */}
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{getFileDescription(selectedFile)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error/Success messages */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||
<Check className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor with syntax highlighting */}
|
||||
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||
{/* Highlighted background layer */}
|
||||
<pre
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: highlightSyntax(content, selectedFile) + '\n' }}
|
||||
/>
|
||||
|
||||
{/* Transparent textarea for editing */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
style={{ caretColor: 'white' }}
|
||||
/>
|
||||
|
||||
{/* Change indicator */}
|
||||
{hasChanges && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||
Ungespeichert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
{selectedFile && files.find(f => f.filename === selectedFile)?.modified && (
|
||||
<span>Zuletzt geändert: {files.find(f => f.filename === selectedFile).modified}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Verwerfen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||
(hasChanges && !saving
|
||||
? 'bg-green-600 hover:bg-green-500 text-white border-2 border-green-400'
|
||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed border-2 border-gray-600'
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||
<div className="flex flex-wrap gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-emerald-500"></span>
|
||||
<span className="text-gray-400">Kommentare</span>
|
||||
</div>
|
||||
{selectedFile?.endsWith('.ini') && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||
<span className="text-gray-400">Einstellung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||
<span className="text-gray-400">Wert</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selectedFile?.endsWith('.lua') && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-orange-400"></span>
|
||||
<span className="text-gray-400">Boolean/Nil</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||
<span className="text-gray-400">Zahlen</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -366,6 +366,21 @@ button {
|
||||
animation: slideInLeft 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slideDown {
|
||||
animation: slideDown 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Page transitions */
|
||||
.page-enter {
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
|
||||
28
gsm-frontend/src/pages/AuthCallback.jsx
Normal file
28
gsm-frontend/src/pages/AuthCallback.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function AuthCallback({ onLogin }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token')
|
||||
|
||||
if (token) {
|
||||
onLogin(token)
|
||||
navigate('/', { replace: true })
|
||||
} else {
|
||||
// No token received, redirect to login with error
|
||||
navigate('/login?error=oauth_failed', { replace: true })
|
||||
}
|
||||
}, [searchParams, onLogin, navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-400">Anmeldung wird verarbeitet...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,36 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getServers } from '../api'
|
||||
import { getServers, getAllDisplaySettings } from '../api'
|
||||
import { useUser } from '../context/UserContext'
|
||||
import ServerCard from '../components/ServerCard'
|
||||
import SettingsModal from '../components/SettingsModal'
|
||||
import UserManagement from '../components/UserManagement'
|
||||
import ActivityLog from '../components/ActivityLog'
|
||||
import LoginModal from '../components/LoginModal'
|
||||
|
||||
export default function Dashboard({ onLogin, onLogout }) {
|
||||
export default function Dashboard({ onLogout }) {
|
||||
const navigate = useNavigate()
|
||||
const { user, token, loading: userLoading, isSuperadmin, role } = useUser()
|
||||
const [servers, setServers] = useState([])
|
||||
const [displaySettings, setDisplaySettings] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showUserMgmt, setShowUserMgmt] = useState(false)
|
||||
const [showActivityLog, setShowActivityLog] = useState(false)
|
||||
const [showLogin, setShowLogin] = useState(false)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
const isGuest = user?.isGuest || role === 'guest'
|
||||
|
||||
const isAuthenticated = !!token
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const data = await getServers(token)
|
||||
const [data, settings] = await Promise.all([
|
||||
getServers(token),
|
||||
getAllDisplaySettings(token)
|
||||
])
|
||||
setServers(data)
|
||||
setDisplaySettings(settings)
|
||||
setError('')
|
||||
} catch (err) {
|
||||
if (err.message.includes('401') || err.message.includes('403')) {
|
||||
@@ -30,7 +38,7 @@ export default function Dashboard({ onLogin, onLogout }) {
|
||||
onLogout()
|
||||
}
|
||||
} else {
|
||||
setError('Failed to connect to server')
|
||||
setError('Verbindung zum Server fehlgeschlagen')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -46,15 +54,15 @@ export default function Dashboard({ onLogin, onLogout }) {
|
||||
}, [token, userLoading])
|
||||
|
||||
const roleLabels = {
|
||||
user: 'Viewer',
|
||||
moderator: 'Operator',
|
||||
user: 'Zuschauer',
|
||||
moderator: 'Moderator',
|
||||
superadmin: 'Admin'
|
||||
}
|
||||
|
||||
if (userLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-neutral-400">Loading...</div>
|
||||
<div className="text-neutral-400">Laden...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -70,7 +78,7 @@ export default function Dashboard({ onLogin, onLogout }) {
|
||||
<div className="container-main py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="https://zeasy.software" target="_blank" rel="noopener noreferrer" className="relative block group"><img src="/navbarlogograuer.png" alt="Logo" className="h-8 transition-opacity duration-300 group-hover:opacity-0" /><img src="/navbarlogoweiß.png" alt="Logo" className="h-8 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" /></a>
|
||||
<a href="https://zeasy.software" target="_blank" rel="noopener noreferrer" className="relative block group"><img src="/navbarlogoweiß.png" alt="Logo" className="h-8 transition-opacity duration-300 group-hover:opacity-0" /><img src="/navbarlogograuer.png" alt="Logo" className="h-8 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" /></a>
|
||||
<span className="text-xl font-semibold text-white hidden sm:inline">Gameserver Management</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-4 text-sm text-neutral-400">
|
||||
@@ -79,47 +87,150 @@ export default function Dashboard({ onLogin, onLogout }) {
|
||||
</span>
|
||||
<span className="text-neutral-600">|</span>
|
||||
<span>
|
||||
<span className="text-white font-medium">{totalPlayers}</span> players
|
||||
<span className="text-white font-medium">{totalPlayers}</span> Spieler
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<div className="hidden sm:block text-right mr-2">
|
||||
<div className="text-sm text-white">{user?.username}</div>
|
||||
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{isGuest ? (
|
||||
<>
|
||||
<span className="text-sm text-neutral-400">Gast</span>
|
||||
<button
|
||||
onClick={() => setShowLogin(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{user?.avatar && user?.discordId && (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`}
|
||||
alt="Avatar"
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div className="text-right mr-2">
|
||||
<div className="text-sm text-white">{user?.username}</div>
|
||||
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
|
||||
</div>
|
||||
{isSuperadmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowUserMgmt(true)}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
Benutzer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowActivityLog(true)}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isSuperadmin && (
|
||||
|
||||
{/* Mobile Burger Button */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
onClick={() => setShowUserMgmt(true)}
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="btn btn-ghost p-2"
|
||||
aria-label="Menu"
|
||||
>
|
||||
Users
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{mobileMenuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLogin(true)}
|
||||
onClick={() => navigate('/login')}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Sign in
|
||||
Anmelden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Dropdown Menu */}
|
||||
{mobileMenuOpen && isAuthenticated && (
|
||||
<div className="md:hidden border-t border-neutral-800 py-4 mt-4 animate-slideDown">
|
||||
<div className="flex items-center justify-between mb-3 px-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{!isGuest && user?.avatar && user?.discordId && (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`}
|
||||
alt="Avatar"
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm text-white">{isGuest ? 'Gast' : user?.username}</div>
|
||||
{!isGuest && <div className="text-xs text-neutral-500">{roleLabels[role]}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-neutral-400 text-right">
|
||||
<div><span className="text-white font-medium">{onlineCount}</span>/{servers.length} online</div>
|
||||
<div><span className="text-white font-medium">{totalPlayers}</span> Spieler</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{isGuest ? (
|
||||
<button
|
||||
onClick={() => { setShowLogin(true); setMobileMenuOpen(false); }}
|
||||
className="btn btn-primary justify-start"
|
||||
>
|
||||
Mit Discord anmelden
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{isSuperadmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setShowUserMgmt(true); setMobileMenuOpen(false); }}
|
||||
className="btn btn-ghost justify-start"
|
||||
>
|
||||
Benutzer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowActivityLog(true); setMobileMenuOpen(false); }}
|
||||
className="btn btn-ghost justify-start"
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { onLogout(); setMobileMenuOpen(false); }}
|
||||
className="btn btn-outline justify-start"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -132,7 +243,7 @@ export default function Dashboard({ onLogin, onLogout }) {
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-neutral-400">Loading servers...</div>
|
||||
<div className="text-neutral-400">Lade Server...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -146,22 +257,51 @@ export default function Dashboard({ onLogin, onLogout }) {
|
||||
server={server}
|
||||
onClick={() => navigate('/server/' + server.id)}
|
||||
isAuthenticated={isAuthenticated}
|
||||
displaySettings={displaySettings[server.id]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discord Bot Invite Section */}
|
||||
<div className="mt-12 fade-in-up" style={{ animationDelay: '300ms' }}>
|
||||
<div className="card p-6 flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<svg className="w-10 h-10 text-[#5865F2]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">Discord Bot</h3>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Willst du Server-Status Updates auch auf deinem Discord?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://discord.com/oauth2/authorize?client_id=1458251194806833306&permissions=34359831568&integration_type=0&scope=bot+applications.commands"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
Bot einladen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
{showSettings && (
|
||||
<SettingsModal onClose={() => setShowSettings(false)} />
|
||||
)}
|
||||
{showUserMgmt && (
|
||||
<UserManagement onClose={() => setShowUserMgmt(false)} />
|
||||
)}
|
||||
{showActivityLog && (
|
||||
<ActivityLog onClose={() => setShowActivityLog(false)} />
|
||||
)}
|
||||
{showLogin && (
|
||||
<LoginModal onLogin={onLogin} onClose={() => setShowLogin(false)} />
|
||||
<LoginModal onClose={() => setShowLogin(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
140
gsm-frontend/src/pages/LoginPage.jsx
Normal file
140
gsm-frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { useUser } from '../context/UserContext'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
discord_denied: 'Discord-Anmeldung wurde abgebrochen.',
|
||||
no_code: 'Kein Autorisierungscode erhalten.',
|
||||
not_in_guild: 'Du bist nicht Mitglied eines berechtigten Discord-Servers.',
|
||||
oauth_failed: 'Discord-Anmeldung fehlgeschlagen. Bitte versuche es erneut.',
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { user, loading } = useUser()
|
||||
const [error, setError] = useState(null)
|
||||
const [guestLoading, setGuestLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [user, loading, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const errorCode = searchParams.get('error')
|
||||
if (errorCode) {
|
||||
setError(ERROR_MESSAGES[errorCode] || 'Ein unbekannter Fehler ist aufgetreten.')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const handleDiscordLogin = () => {
|
||||
window.location.href = `${API_URL}/auth/discord`
|
||||
}
|
||||
|
||||
const handleGuestLogin = async () => {
|
||||
setGuestLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/guest`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.token) {
|
||||
onLogin(data.token)
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Gast-Anmeldung fehlgeschlagen.')
|
||||
} finally {
|
||||
setGuestLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<div className="text-neutral-400">Laden...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Pulsing Spotlight */}
|
||||
<div
|
||||
className="absolute w-[800px] h-[800px] rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(241, 68, 232, 0.3) 0%, rgba(113, 40, 215, 0.2) 40%, transparent 70%)',
|
||||
filter: 'blur(60px)',
|
||||
animation: 'pulse 4s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="flex flex-col items-center relative z-10">
|
||||
{/* Logo */}
|
||||
<a
|
||||
href="https://zeasy.software"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative block group mb-4"
|
||||
>
|
||||
<img
|
||||
src="/navbarlogoweiß.png"
|
||||
alt="Zeasy"
|
||||
className="h-20 transition-opacity duration-300 group-hover:opacity-0"
|
||||
/>
|
||||
<img
|
||||
src="/navbarlogograuer.png"
|
||||
alt="Zeasy"
|
||||
className="h-20 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<p className="text-white text-xl font-semibold mb-8">Gameserver Management</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-lg mb-6 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<button
|
||||
onClick={handleDiscordLogin}
|
||||
className="flex items-center justify-center gap-3 text-white font-medium py-3 px-6 rounded-lg transition-all duration-200 hover:scale-[1.02] hover:shadow-lg"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f144e8 0%, #7128d7 100%)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
Mit Discord anmelden
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleGuestLogin}
|
||||
disabled={guestLoading}
|
||||
className="text-neutral-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
{guestLoading ? 'Laden...' : 'Als Gast fortfahren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave } from '../api'
|
||||
import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave, getAutoShutdownSettings, setAutoShutdownSettings, getDisplaySettings, setDisplaySettings } from '../api'
|
||||
import { useUser } from '../context/UserContext'
|
||||
import MetricsChart from '../components/MetricsChart'
|
||||
import FactorioWorldManager from '../components/FactorioWorldManager'
|
||||
import PalworldConfigEditor from '../components/PalworldConfigEditor'
|
||||
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
|
||||
import TerrariaConfigEditor from '../components/TerrariaConfigEditor'
|
||||
import OpenTTDConfigEditor from '../components/OpenTTDConfigEditor'
|
||||
|
||||
const getServerLogo = (serverName) => {
|
||||
const name = serverName.toLowerCase()
|
||||
if (name.includes("minecraft") || name.includes("all the mods")) return "/minecraft.png"
|
||||
if (name.includes("factorio")) return "/factorio.png"
|
||||
if (name.includes("vrising") || name.includes("v rising")) return "/vrising.png"
|
||||
if (name.includes("zomboid")) return "/zomboid.png"
|
||||
if (name.includes("palworld")) return "/palworld.png"
|
||||
if (name.includes("terraria")) return "/terraria.png"
|
||||
if (name.includes("openttd")) return "/openttd.png"
|
||||
return null
|
||||
}
|
||||
export default function ServerDetail() {
|
||||
const { serverId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { token, isModerator } = useUser()
|
||||
const { token, isModerator, isSuperadmin } = useUser()
|
||||
const [server, setServer] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -28,9 +36,19 @@ export default function ServerDetail() {
|
||||
const [whitelistInput, setWhitelistInput] = useState('')
|
||||
const [whitelistLoading, setWhitelistLoading] = useState(false)
|
||||
const [currentSave, setCurrentSave] = useState(null)
|
||||
const [logsUpdated, setLogsUpdated] = useState(null)
|
||||
const logsRef = useRef(null)
|
||||
const rconRef = useRef(null)
|
||||
|
||||
// Auto-shutdown state
|
||||
const [autoShutdown, setAutoShutdown] = useState({ enabled: false, timeoutMinutes: 15, emptySinceMinutes: null })
|
||||
const [autoShutdownLoading, setAutoShutdownLoading] = useState(false)
|
||||
|
||||
// Display settings state (superadmin only)
|
||||
const [displaySettings, setDisplaySettingsState] = useState({ address: '', hint: '' })
|
||||
const [displaySettingsLoading, setDisplaySettingsLoading] = useState(false)
|
||||
const [displaySettingsSaved, setDisplaySettingsSaved] = useState(false)
|
||||
|
||||
const fetchCurrentSave = async () => {
|
||||
if (token && serverId === 'factorio') {
|
||||
try {
|
||||
@@ -59,10 +77,49 @@ export default function ServerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAutoShutdownSettings = async () => {
|
||||
if (token && serverId) {
|
||||
try {
|
||||
const data = await getAutoShutdownSettings(token, serverId)
|
||||
setAutoShutdown(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load auto-shutdown settings:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoShutdownToggle = async () => {
|
||||
setAutoShutdownLoading(true)
|
||||
try {
|
||||
await setAutoShutdownSettings(token, serverId, !autoShutdown.enabled, autoShutdown.timeoutMinutes)
|
||||
setAutoShutdown(prev => ({ ...prev, enabled: !prev.enabled }))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
setAutoShutdownLoading(false)
|
||||
}
|
||||
|
||||
const handleAutoShutdownTimeoutChange = async (newTimeout) => {
|
||||
const timeout = Math.max(1, Math.min(1440, parseInt(newTimeout) || 15))
|
||||
setAutoShutdown(prev => ({ ...prev, timeoutMinutes: timeout }))
|
||||
|
||||
clearTimeout(window.autoShutdownSaveTimeout)
|
||||
window.autoShutdownSaveTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await setAutoShutdownSettings(token, serverId, autoShutdown.enabled, timeout)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchServer()
|
||||
fetchCurrentSave()
|
||||
const interval = setInterval(fetchServer, 10000)
|
||||
const interval = setInterval(() => {
|
||||
fetchServer()
|
||||
fetchCurrentSave()
|
||||
}, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [token, serverId])
|
||||
|
||||
@@ -97,8 +154,9 @@ export default function ServerDetail() {
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const data = await getServerLogs(token, server.id, 20)
|
||||
const data = await getServerLogs(token, server.id, 50)
|
||||
setLogs(data.logs || '')
|
||||
setLogsUpdated(new Date())
|
||||
if (logsRef.current) {
|
||||
logsRef.current.scrollTop = logsRef.current.scrollHeight
|
||||
}
|
||||
@@ -127,6 +185,42 @@ export default function ServerDetail() {
|
||||
}
|
||||
}, [rconHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'settings' && isModerator) {
|
||||
fetchAutoShutdownSettings()
|
||||
const interval = setInterval(fetchAutoShutdownSettings, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [activeTab, isModerator, serverId])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'display' && isSuperadmin) {
|
||||
fetchDisplaySettings()
|
||||
}
|
||||
}, [activeTab, isSuperadmin, serverId])
|
||||
|
||||
const fetchDisplaySettings = async () => {
|
||||
if (token && serverId) {
|
||||
try {
|
||||
const data = await getDisplaySettings(token, serverId)
|
||||
setDisplaySettingsState({ address: data.address || '', hint: data.hint || '' })
|
||||
} catch (err) {
|
||||
console.error('Failed to load display settings:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDisplaySettings = async () => {
|
||||
setDisplaySettingsLoading(true)
|
||||
try {
|
||||
await setDisplaySettings(token, serverId, displaySettings.address, displaySettings.hint)
|
||||
setDisplaySettingsSaved(true)
|
||||
setTimeout(() => setDisplaySettingsSaved(false), 3000)
|
||||
} catch (err) {
|
||||
console.error('Failed to save display settings:', err)
|
||||
}
|
||||
setDisplaySettingsLoading(false)
|
||||
}
|
||||
|
||||
const fetchWhitelist = async () => {
|
||||
if (!server?.hasRcon) return
|
||||
@@ -174,23 +268,41 @@ const formatUptime = (seconds) => {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'metrics', label: 'Metrics' },
|
||||
{ id: 'overview', label: 'Übersicht' },
|
||||
{ id: 'metrics', label: 'Metriken' },
|
||||
...(isModerator ? [
|
||||
{ id: 'console', label: 'Console' },
|
||||
{ id: 'console', label: 'Konsole' },
|
||||
] : []),
|
||||
...(isModerator && server?.type === 'minecraft' ? [
|
||||
{ id: 'whitelist', label: 'Whitelist' },
|
||||
] : []),
|
||||
...(isModerator && server?.type === 'factorio' ? [
|
||||
{ id: 'worlds', label: 'Worlds' },
|
||||
{ id: 'worlds', label: 'Welten' },
|
||||
] : []),
|
||||
...(isModerator && server?.type === 'palworld' ? [
|
||||
{ id: 'config', label: 'Config' },
|
||||
] : []),
|
||||
...(isModerator && server?.type === 'terraria' ? [
|
||||
{ id: 'config', label: 'Config' },
|
||||
] : []),
|
||||
...(isModerator && server?.type === 'zomboid' ? [
|
||||
{ id: 'zomboid-config', label: 'Config' },
|
||||
] : []),
|
||||
...(isModerator && server?.type === 'openttd' ? [
|
||||
{ id: 'openttd-config', label: 'Config' },
|
||||
] : []),
|
||||
...(isModerator ? [
|
||||
{ id: 'settings', label: 'Einstellungen' },
|
||||
] : []),
|
||||
...(isSuperadmin ? [
|
||||
{ id: 'display', label: 'Anzeige' },
|
||||
] : []),
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-neutral-400">Loading...</div>
|
||||
<div className="text-neutral-400">Laden...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -198,7 +310,7 @@ const formatUptime = (seconds) => {
|
||||
if (!server) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-neutral-400">Server not found</div>
|
||||
<div className="text-neutral-400">Server nicht gefunden</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -212,9 +324,9 @@ const formatUptime = (seconds) => {
|
||||
case 'online':
|
||||
return { class: 'badge badge-success', text: 'Online' }
|
||||
case 'starting':
|
||||
return { class: 'badge badge-warning', text: 'Starting...' }
|
||||
return { class: 'badge badge-warning', text: 'Startet...' }
|
||||
case 'stopping':
|
||||
return { class: 'badge badge-warning', text: 'Stopping...' }
|
||||
return { class: 'badge badge-warning', text: 'Stoppt...' }
|
||||
default:
|
||||
return { class: 'badge badge-destructive', text: 'Offline' }
|
||||
}
|
||||
@@ -232,7 +344,7 @@ const formatUptime = (seconds) => {
|
||||
onClick={() => navigate('/')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
Back
|
||||
Zurück
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -244,7 +356,7 @@ const formatUptime = (seconds) => {
|
||||
</div>
|
||||
{server.running && (
|
||||
<p className="text-sm text-neutral-400 mt-1">
|
||||
Uptime: {formatUptime(server.metrics.uptime)}
|
||||
Laufzeit: {formatUptime(server.metrics.uptime)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,38 +385,47 @@ const formatUptime = (seconds) => {
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-neutral-400">CPU Usage</div>
|
||||
<div className="text-sm text-neutral-400">CPU Auslastung</div>
|
||||
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpu.toFixed(1)}%</div>
|
||||
<div className="progress mt-2">
|
||||
<div className="progress-bar" style={{ width: cpuPercent + '%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-neutral-400">Memory</div>
|
||||
<div className="text-sm text-neutral-400">Arbeitsspeicher</div>
|
||||
<div className="text-2xl font-semibold text-white mt-1">
|
||||
{server.metrics.memoryUsed?.toFixed(1)} {server.metrics.memoryUnit}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 mt-1">
|
||||
of {server.metrics.memoryTotal?.toFixed(1)} {server.metrics.memoryUnit}
|
||||
von {server.metrics.memoryTotal?.toFixed(1)} {server.metrics.memoryUnit}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-neutral-400">Players</div>
|
||||
<div className="text-sm text-neutral-400">Spieler</div>
|
||||
<div className="text-2xl font-semibold text-white mt-1">{server.players.online}</div>
|
||||
<div className="text-xs text-neutral-500 mt-1">
|
||||
{server.players.max ? 'of ' + server.players.max + ' max' : 'No limit'}
|
||||
{server.players.max ? 'von ' + server.players.max + ' max' : 'Kein Limit'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-neutral-400">CPU Cores</div>
|
||||
<div className="text-sm text-neutral-400">CPU Kerne</div>
|
||||
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpuCores}</div>
|
||||
</div>
|
||||
{server.type === 'factorio' && currentSave?.save && (
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-neutral-400">{server.running ? 'Aktuelle Welt' : 'Nächste Welt'}</div>
|
||||
<div className="text-lg font-semibold text-white mt-1 truncate">{currentSave.save}</div>
|
||||
{!server.running && currentSave.source === 'newest' && (
|
||||
<div className="text-xs text-neutral-500 mt-1">neuester Spielstand</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Players List */}
|
||||
{server.players?.list?.length > 0 && (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Online Players</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Online Spieler</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{server.players.list.map((player, i) => (
|
||||
<span key={i} className="badge badge-secondary">{player}</span>
|
||||
@@ -316,15 +437,7 @@ const formatUptime = (seconds) => {
|
||||
{/* Power Controls */}
|
||||
{isModerator && (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Server Controls</h3>
|
||||
|
||||
{/* Factorio: Show which save will be loaded */}
|
||||
{server.type === 'factorio' && !server.running && currentSave?.save && (
|
||||
<div className="text-sm text-neutral-400 mb-3">
|
||||
Will load: <span className="text-white font-medium">{currentSave.save}</span>
|
||||
{currentSave.source === 'newest' && <span className="text-neutral-500 ml-1">(newest save)</span>}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Server Steuerung</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(server.status === 'online' || (server.running && server.status !== 'starting' && server.status !== 'stopping')) ? (
|
||||
@@ -334,14 +447,14 @@ const formatUptime = (seconds) => {
|
||||
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||
className="btn btn-destructive"
|
||||
>
|
||||
{server.status === 'stopping' ? 'Stopping...' : 'Stop Server'}
|
||||
{server.status === 'stopping' ? 'Stoppt...' : 'Server stoppen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('restart')}
|
||||
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{server.status === 'starting' ? 'Starting...' : 'Restart Server'}
|
||||
{server.status === 'starting' ? 'Startet...' : 'Server neustarten'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -350,7 +463,7 @@ const formatUptime = (seconds) => {
|
||||
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{server.status === 'starting' ? 'Starting...' : 'Start Server'}
|
||||
{server.status === 'starting' ? 'Startet...' : 'Server starten'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,22 +482,29 @@ const formatUptime = (seconds) => {
|
||||
<div className="space-y-4 tab-content">
|
||||
{/* Logs */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-neutral-400">Server Logs (last 20 lines)</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-neutral-400">Server Logs (letzte 50 Zeilen)</span>
|
||||
{logsUpdated && (
|
||||
<span className="text-xs text-neutral-600">
|
||||
Aktualisiert: {logsUpdated.toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={fetchLogs} className="btn btn-secondary">
|
||||
Refresh
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={logsRef}
|
||||
className="terminal p-4 logs-container text-xs text-neutral-300 whitespace-pre-wrap"
|
||||
>
|
||||
{logs || 'Loading...'}
|
||||
{logs || 'Laden...'}
|
||||
</div>
|
||||
|
||||
{/* RCON History */}
|
||||
{rconHistory.length > 0 && (
|
||||
<div ref={rconRef} className="terminal p-4 max-h-40 overflow-y-auto">
|
||||
<div className="text-neutral-500 text-xs mb-2">RCON History</div>
|
||||
<div className="text-neutral-500 text-xs mb-2">RCON Verlauf</div>
|
||||
{rconHistory.map((entry, i) => (
|
||||
<div key={i} className="mb-2 text-sm">
|
||||
<div className="text-neutral-400">
|
||||
@@ -405,11 +525,11 @@ const formatUptime = (seconds) => {
|
||||
type="text"
|
||||
value={rconCommand}
|
||||
onChange={(e) => setRconCommand(e.target.value)}
|
||||
placeholder="RCON command..."
|
||||
placeholder="RCON Befehl..."
|
||||
className="input flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Send
|
||||
Senden
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -420,27 +540,27 @@ const formatUptime = (seconds) => {
|
||||
{activeTab === 'whitelist' && isModerator && server.type === 'minecraft' && (
|
||||
<div className="space-y-4 tab-content">
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Add to Whitelist</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Zur Whitelist hinzufügen</h3>
|
||||
<form onSubmit={addToWhitelist} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={whitelistInput}
|
||||
onChange={(e) => setWhitelistInput(e.target.value)}
|
||||
placeholder="Minecraft username..."
|
||||
placeholder="Minecraft Benutzername..."
|
||||
className="input flex-1"
|
||||
disabled={whitelistLoading || !server.running}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={whitelistLoading || !server.running}>
|
||||
{whitelistLoading ? 'Adding...' : 'Add'}
|
||||
{whitelistLoading ? 'Hinzufügen...' : 'Hinzufügen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-medium text-neutral-300">Whitelisted Players ({whitelistPlayers.length})</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-300">Whitelist Spieler ({whitelistPlayers.length})</h3>
|
||||
<button onClick={fetchWhitelist} className="btn btn-ghost text-sm">
|
||||
Refresh
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{whitelistPlayers.length > 0 ? (
|
||||
@@ -459,7 +579,7 @@ const formatUptime = (seconds) => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 text-sm">No players whitelisted</div>
|
||||
<div className="text-neutral-500 text-sm">Keine Spieler auf der Whitelist</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,8 +590,8 @@ const formatUptime = (seconds) => {
|
||||
<div className="tab-content">
|
||||
{server.running || server.status === 'starting' || server.status === 'stopping' ? (
|
||||
<div className="card p-8 text-center">
|
||||
<div className="text-neutral-400 mb-2">World management is locked while the server is running</div>
|
||||
<div className="text-neutral-500 text-sm">Stop the server to manage saves</div>
|
||||
<div className="text-neutral-400 mb-2">Weltverwaltung ist gesperrt während der Server läuft</div>
|
||||
<div className="text-neutral-500 text-sm">Stoppe den Server um Spielstände zu verwalten</div>
|
||||
</div>
|
||||
) : (
|
||||
<FactorioWorldManager
|
||||
@@ -482,6 +602,179 @@ const formatUptime = (seconds) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Tab - Palworld only */}
|
||||
{activeTab === 'config' && isModerator && server.type === 'palworld' && (
|
||||
<div className="tab-content">
|
||||
<PalworldConfigEditor token={token} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Tab - Terraria only */}
|
||||
{activeTab === 'config' && isModerator && server.type === 'terraria' && (
|
||||
<div className="tab-content">
|
||||
<TerrariaConfigEditor token={token} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Tab - Zomboid only */}
|
||||
{activeTab === 'zomboid-config' && isModerator && server.type === 'zomboid' && (
|
||||
<div className="tab-content">
|
||||
<ZomboidConfigEditor token={token} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Tab - OpenTTD only */}
|
||||
{activeTab === 'openttd-config' && isModerator && server.type === 'openttd' && (
|
||||
<div className="tab-content">
|
||||
<OpenTTDConfigEditor token={token} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Tab */}
|
||||
{activeTab === 'settings' && isModerator && (
|
||||
<div className="space-y-4 tab-content">
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-4">Auto-Shutdown</h3>
|
||||
<p className="text-neutral-500 text-sm mb-4">
|
||||
Server automatisch stoppen wenn keine Spieler online sind
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Toggle Switch */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleAutoShutdownToggle}
|
||||
disabled={autoShutdownLoading}
|
||||
className={`
|
||||
relative w-14 h-8 rounded-full transition-all duration-200
|
||||
${autoShutdown.enabled
|
||||
? 'bg-green-600 border-green-500'
|
||||
: 'bg-neutral-700 border-neutral-600'
|
||||
}
|
||||
border-2 focus:outline-none focus:ring-2 focus:ring-neutral-500
|
||||
${autoShutdownLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
absolute top-1 w-5 h-5 rounded-full bg-white shadow-md
|
||||
transition-all duration-200 ease-in-out
|
||||
${autoShutdown.enabled ? 'left-7' : 'left-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
<span className={`text-sm font-medium ${autoShutdown.enabled ? 'text-green-500' : 'text-neutral-500'}`}>
|
||||
{autoShutdown.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeout mit +/- Buttons */}
|
||||
{autoShutdown.enabled && (
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<span className="text-neutral-400 text-sm">Timeout:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleAutoShutdownTimeoutChange(autoShutdown.timeoutMinutes - 5)}
|
||||
disabled={autoShutdown.timeoutMinutes <= 5}
|
||||
className="btn btn-secondary px-3"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="text-white text-lg font-medium w-16 text-center">
|
||||
{autoShutdown.timeoutMinutes}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleAutoShutdownTimeoutChange(autoShutdown.timeoutMinutes + 5)}
|
||||
disabled={autoShutdown.timeoutMinutes >= 1440}
|
||||
className="btn btn-secondary px-3"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-neutral-500 text-sm">Minuten</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{autoShutdown.enabled && server.running && (
|
||||
<div className="border-t border-neutral-800 pt-4 mt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-neutral-400 text-sm">Status:</span>
|
||||
{autoShutdown.emptySinceMinutes !== null ? (
|
||||
<span className="badge badge-warning">
|
||||
Leer seit {autoShutdown.emptySinceMinutes} Min. Shutdown in {autoShutdown.timeoutMinutes - autoShutdown.emptySinceMinutes} Min.
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-success">
|
||||
Spieler online
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Tab - Superadmin only */}
|
||||
{activeTab === 'display' && isSuperadmin && (
|
||||
<div className="space-y-4 tab-content">
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-4">Anzeigeeinstellungen</h3>
|
||||
<p className="text-neutral-500 text-sm mb-4">
|
||||
Diese Einstellungen werden in der Server-Übersicht angezeigt
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Address Input */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Verbindungsadresse</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displaySettings.address}
|
||||
onChange={(e) => setDisplaySettingsState(prev => ({ ...prev, address: e.target.value }))}
|
||||
placeholder="z.B. server.example.com:25565"
|
||||
className="input w-full max-w-md"
|
||||
/>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
Die Adresse die Spieler zum Verbinden nutzen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hint Input */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Hinweis / Kommentar</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displaySettings.hint}
|
||||
onChange={(e) => setDisplaySettingsState(prev => ({ ...prev, hint: e.target.value }))}
|
||||
placeholder="z.B. Passwort: geheim123"
|
||||
className="input w-full max-w-md"
|
||||
/>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
Wird unter der Server-Karte angezeigt (z.B. Passwort, Version)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSaveDisplaySettings}
|
||||
disabled={displaySettingsLoading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{displaySettingsLoading ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
{displaySettingsSaved && (
|
||||
<span className="text-green-500 text-sm">Gespeichert!</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user