palworld added

This commit is contained in:
Alexander Zielonka
2026-01-07 12:35:23 +01:00
30 changed files with 5846 additions and 123 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -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}`, {
@@ -185,3 +185,67 @@ 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 }),
})
}
// Activity Log
export async function getActivityLog(token, limit = 100) {
return fetchAPI(`/servers/activity-log?limit=${limit}`, {
headers: { Authorization: `Bearer ${token}` },
})
}

View 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>
)
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 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'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
)}
>
{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>
)
}

View File

@@ -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,21 @@ 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/' }
]
}
}
@@ -28,11 +43,25 @@ 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
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 +135,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 +184,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>

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
)}
>
{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>
)
}

View File

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

View File

@@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom'
import { getServers } from '../api'
import { useUser } from '../context/UserContext'
import ServerCard from '../components/ServerCard'
import SettingsModal from '../components/SettingsModal'
import UserManagement from '../components/UserManagement'
import LoginModal from '../components/LoginModal'
import ActivityLog from '../components/ActivityLog'
export default function Dashboard({ onLogin, onLogout }) {
const navigate = useNavigate()
@@ -13,9 +13,10 @@ export default function Dashboard({ onLogin, onLogout }) {
const [servers, setServers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showSettings, setShowSettings] = useState(false)
const [showUserMgmt, setShowUserMgmt] = useState(false)
const [showLogin, setShowLogin] = useState(false)
const [showActivityLog, setShowActivityLog] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const isAuthenticated = !!token
@@ -30,7 +31,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 +47,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 +71,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 +80,125 @@ 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>
</div>
{isSuperadmin && (
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-6">
{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={() => setShowUserMgmt(true)}
className="btn btn-ghost"
onClick={onLogout}
className="btn btn-outline"
>
Users
Abmelden
</button>
)}
<button
onClick={() => setShowSettings(true)}
className="btn btn-ghost"
>
Settings
</button>
<button
onClick={onLogout}
className="btn btn-outline"
>
Sign out
</button>
</div>
{/* Mobile Burger Button */}
<div className="md:hidden">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="btn btn-ghost p-2"
aria-label="Menu"
>
<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>
</div>
</>
) : (
<button
onClick={() => setShowLogin(true)}
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">
{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">{user?.username}</div>
<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">
{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 +211,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">
@@ -154,12 +233,12 @@ export default function Dashboard({ onLogin, onLogout }) {
</main>
{/* Modals */}
{showSettings && (
<SettingsModal onClose={() => setShowSettings(false)} />
)}
{showUserMgmt && (
<UserManagement onClose={() => setShowUserMgmt(false)} />
)}
{showActivityLog && (
<ActivityLog onClose={() => setShowActivityLog(false)} />
)}
{showLogin && (
<LoginModal onLogin={onLogin} onClose={() => setShowLogin(false)} />
)}

View File

@@ -1,21 +1,25 @@
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'
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"
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 +32,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 +73,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 +150,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,7 +181,43 @@ 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
try {
@@ -174,23 +264,35 @@ 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 === 'zomboid' ? [
{ id: 'zomboid-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 +300,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 +314,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 +334,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 +346,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 +375,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 +427,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 +437,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 +453,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 +472,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 +515,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 +530,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 +569,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 +580,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 +592,165 @@ const formatUptime = (seconds) => {
)}
</div>
)}
{/* Config Tab - Palworld only */}
{activeTab === 'config' && isModerator && server.type === 'palworld' && (
<div className="tab-content">
<PalworldConfigEditor token={token} />
</div>
)}
{/* Config Tab - Zomboid only */}
{activeTab === 'zomboid-config' && isModerator && server.type === 'zomboid' && (
<div className="tab-content">
<ZomboidConfigEditor 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>
)