Add Hytale config editor and Prometheus integration
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
243
gsm-frontend/src/components/HytaleConfigEditor.jsx
Normal file
243
gsm-frontend/src/components/HytaleConfigEditor.jsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user