Add Hytale config editor and Prometheus integration
All checks were successful
Deploy GSM / deploy (push) Successful in 25s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 13:50:45 +01:00
parent 1f98747d59
commit 8a3690d61f
6 changed files with 341 additions and 2 deletions

View File

@@ -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
}
]
}

View File

@@ -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) => {

View File

@@ -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;
}

View File

@@ -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}`, {

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Strings
.replace(/"([^"\\]|\\.)*"/g, (match) => {
// Check if it's a key (followed by :)
if (match.endsWith('"') && text.indexOf(match + ':') !== -1) {
return `<span class="text-blue-400">${match}</span>`
}
return `<span class="text-amber-300">${match}</span>`
})
// Numbers
.replace(/\b(-?\d+\.?\d*)\b/g, '<span class="text-cyan-400">$1</span>')
// Booleans and null
.replace(/\b(true|false|null)\b/g, '<span class="text-purple-400">$1</span>')
// Brackets
.replace(/([{}\[\]])/g, '<span class="text-gray-500">$1</span>')
}
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>config.json - 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>
)}
{jsonError && (
<div className="flex items-center gap-2 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg text-orange-400">
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
<span>JSON-Fehler: {jsonError}</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 || jsonError}
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
(hasChanges && !saving && !jsonError
? '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-blue-400"></span>
<span className="text-gray-400">Schlüssel</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">Strings</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 className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-purple-400"></span>
<span className="text-gray-400">Boolean/Null</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>
)
}

View File

@@ -9,6 +9,7 @@ import PalworldConfigEditor from '../components/PalworldConfigEditor'
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
import TerrariaConfigEditor from '../components/TerrariaConfigEditor'
import OpenTTDConfigEditor from '../components/OpenTTDConfigEditor'
import HytaleConfigEditor from '../components/HytaleConfigEditor'
const getServerLogo = (serverName) => {
const name = serverName.toLowerCase()
@@ -311,6 +312,9 @@ const formatUptime = (seconds) => {
...(isModerator && server?.type === 'openttd' ? [
{ id: 'openttd-config', label: 'Config' },
] : []),
...(isModerator && server?.type === 'hytale' ? [
{ id: 'hytale-config', label: 'Config' },
] : []),
...(isModerator ? [
{ id: 'settings', label: 'Einstellungen' },
] : []),
@@ -651,6 +655,13 @@ const formatUptime = (seconds) => {
</div>
)}
{/* Config Tab - Hytale only */}
{activeTab === 'hytale-config' && isModerator && server.type === 'hytale' && (
<div className="tab-content">
<HytaleConfigEditor token={token} />
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && isModerator && (
<div className="space-y-4 tab-content">