From 8a3690d61f7faaf0d9fd3a069bc5c159ad87678a Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Thu, 15 Jan 2026 13:50:45 +0100 Subject: [PATCH] Add Hytale config editor and Prometheus integration - Add Node Exporter target for Hytale server (10.0.30.204:9100) - Add Hytale config read/write functions to ssh.js - Add GET/PUT /hytale/config API routes - Create HytaleConfigEditor.jsx with JSON syntax highlighting - Add Hytale config tab to ServerDetail.jsx - Add stopCmd and port to Hytale server config Co-Authored-By: Claude Opus 4.5 --- gsm-backend/config.json | 4 +- gsm-backend/routes/servers.js | 43 +++- gsm-backend/services/ssh.js | 27 ++ gsm-frontend/src/api.js | 15 ++ .../src/components/HytaleConfigEditor.jsx | 243 ++++++++++++++++++ gsm-frontend/src/pages/ServerDetail.jsx | 11 + 6 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 gsm-frontend/src/components/HytaleConfigEditor.jsx diff --git a/gsm-backend/config.json b/gsm-backend/config.json index 56f6e6b..404bded 100644 --- a/gsm-backend/config.json +++ b/gsm-backend/config.json @@ -102,7 +102,9 @@ "sshUser": "beeck", "workDir": "/opt/hytale/Server", "startCmd": "./start-server.sh", - "logCmd": "ls -t /opt/hytale/Server/logs/*.log | head -1 | xargs tail -n" + "stopCmd": "/stop", + "logCmd": "ls -t /opt/hytale/Server/logs/*.log | head -1 | xargs tail -n", + "port": 5520 } ] } diff --git a/gsm-backend/routes/servers.js b/gsm-backend/routes/servers.js index 14db41f..7476638 100644 --- a/gsm-backend/routes/servers.js +++ b/gsm-backend/routes/servers.js @@ -4,7 +4,7 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { EmbedBuilder } from 'discord.js'; import { authenticateToken, optionalAuth, requireRole, rejectGuest } 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 { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig, readHytaleConfig, writeHytaleConfig } 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'; @@ -502,6 +502,47 @@ router.put("/openttd/config", authenticateToken, requireRole("moderator"), async } }); +// ============ HYTALE ROUTES ============ + +// Get Hytale config +router.get("/hytale/config", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.id === "hytale"); + 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 readHytaleConfig(server); + res.json({ content }); + } catch (error) { + console.error("Error reading Hytale 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 Hytale config +router.put("/hytale/config", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const config = loadConfig(); + const server = config.servers.find(s => s.id === "hytale"); + 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 writeHytaleConfig(server, content); + logActivity(req.user.id, req.user.username, "hytale_config", "hytale", "config.json", req.user.discordId, req.user.avatar); + res.json({ success: true }); + } catch (error) { + console.error("Error writing Hytale config:", error); + res.status(500).json({ error: error.message }); + } +}); + // Get single server (guests not allowed) router.get('/:id', authenticateToken, rejectGuest, async (req, res) => { diff --git a/gsm-backend/services/ssh.js b/gsm-backend/services/ssh.js index 329895e..2d42582 100644 --- a/gsm-backend/services/ssh.js +++ b/gsm-backend/services/ssh.js @@ -642,3 +642,30 @@ export async function writeOpenTTDConfig(server, content) { 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; } + +// Hytale Config Management +const HYTALE_CONFIG_PATH = "/opt/hytale/Server/config.json"; + +export async function readHytaleConfig(server) { + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand(`cat ${HYTALE_CONFIG_PATH}`); + if (result.code !== 0) { + throw new Error(result.stderr || "Failed to read config file"); + } + return result.stdout; +} + +export async function writeHytaleConfig(server, content) { + const ssh = await getConnection(server.host, server.sshUser); + const backupName = `config.json.backup.${Date.now()}`; + await ssh.execCommand(`cp ${HYTALE_CONFIG_PATH} /opt/hytale/Server/${backupName} 2>/dev/null || true`); + const sftp = await ssh.requestSFTP(); + await new Promise((resolve, reject) => { + sftp.writeFile(HYTALE_CONFIG_PATH, content, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + await ssh.execCommand(`ls -t /opt/hytale/Server/config.json.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); + return true; +} diff --git a/gsm-frontend/src/api.js b/gsm-frontend/src/api.js index b89092b..7bceeb1 100644 --- a/gsm-frontend/src/api.js +++ b/gsm-frontend/src/api.js @@ -280,6 +280,21 @@ export async function saveOpenTTDConfig(token, content) { }) } +// Hytale Config Management +export async function getHytaleConfig(token) { + return fetchAPI('/servers/hytale/config', { + headers: { Authorization: `Bearer ${token}` }, + }) +} + +export async function saveHytaleConfig(token, content) { + return fetchAPI('/servers/hytale/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}`, { diff --git a/gsm-frontend/src/components/HytaleConfigEditor.jsx b/gsm-frontend/src/components/HytaleConfigEditor.jsx new file mode 100644 index 0000000..d0449fa --- /dev/null +++ b/gsm-frontend/src/components/HytaleConfigEditor.jsx @@ -0,0 +1,243 @@ +import { useState, useEffect, useRef } from 'react' +import { FileText, Save, RefreshCw, AlertTriangle, Check, X } from 'lucide-react' +import { getHytaleConfig, saveHytaleConfig } from '../api' + +export default function HytaleConfigEditor({ 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 [jsonError, setJsonError] = useState(null) + const textareaRef = useRef(null) + const highlightRef = useRef(null) + + useEffect(() => { + loadConfig() + }, [token]) + + useEffect(() => { + setHasChanges(content !== originalContent) + // Validate JSON + if (content) { + try { + JSON.parse(content) + setJsonError(null) + } catch (e) { + setJsonError(e.message) + } + } + }, [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 getHytaleConfig(token) + // Pretty-print JSON + const formatted = JSON.stringify(JSON.parse(data.content), null, 2) + setContent(formatted) + setOriginalContent(formatted) + } catch (err) { + setError('Fehler beim Laden der Config: ' + err.message) + } finally { + setLoading(false) + } + } + + async function handleSave() { + if (!hasChanges || jsonError) return + + setSaving(true) + setError(null) + setSuccess(null) + try { + // Validate and minify before saving + const parsed = JSON.parse(content) + await saveHytaleConfig(token, JSON.stringify(parsed, null, 2)) + 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 '' + + return text + .replace(/&/g, '&') + .replace(//g, '>') + // Strings + .replace(/"([^"\\]|\\.)*"/g, (match) => { + // Check if it's a key (followed by :) + if (match.endsWith('"') && text.indexOf(match + ':') !== -1) { + return `${match}` + } + return `${match}` + }) + // Numbers + .replace(/\b(-?\d+\.?\d*)\b/g, '$1') + // Booleans and null + .replace(/\b(true|false|null)\b/g, '$1') + // Brackets + .replace(/([{}\[\]])/g, '$1') + } + + if (loading) { + return ( +
+ + Lade Config... +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + config.json - Server-Einstellungen +
+ +
+ + {/* Error/Success messages */} + {error && ( +
+ + {error} +
+ )} + + {jsonError && ( +
+ + JSON-Fehler: {jsonError} +
+ )} + + {success && ( +
+ + {success} +
+ )} + + {/* Editor */} +
+