diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ee37eb4..66a4c30 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,23 +3,13 @@ "allow": [ "Bash(ssh:*)", "Bash(scp:*)", - "Bash(veth.*)", - "Bash(docker.*)", + "Bash(docker:*)", "Bash(curl:*)", "Bash(findstr:*)", "Bash(cat:*)", - "Bash(powershell -Command @'\n$content = @\"\"\nimport { useState, useEffect } from 'react'\nimport { useNavigate } from 'react-router-dom'\nimport { getServers } from '../api'\nimport { useUser } from '../context/UserContext'\nimport ServerCard from '../components/ServerCard'\nimport SettingsModal from '../components/SettingsModal'\nimport UserManagement from '../components/UserManagement'\n\nexport default function Dashboard\\({ onLogout }\\) {\n const navigate = useNavigate\\(\\)\n const { user, token, loading: userLoading, isSuperadmin, role } = useUser\\(\\)\n const [servers, setServers] = useState\\([]\\)\n const [loading, setLoading] = useState\\(true\\)\n const [error, setError] = useState\\(''\\)\n const [showSettings, setShowSettings] = useState\\(false\\)\n const [showUserMgmt, setShowUserMgmt] = useState\\(false\\)\n\n const fetchServers = async \\(\\) => {\n try {\n const data = await getServers\\(token\\)\n setServers\\(data\\)\n setError\\(''\\)\n } catch \\(err\\) {\n setError\\('Failed to connect to server'\\)\n if \\(err.message.includes\\('401'\\) || err.message.includes\\('403'\\)\\) {\n onLogout\\(\\)\n }\n } finally {\n setLoading\\(false\\)\n }\n }\n\n useEffect\\(\\(\\) => {\n if \\(!userLoading\\) {\n fetchServers\\(\\)\n const interval = setInterval\\(fetchServers, 10000\\)\n return \\(\\) => clearInterval\\(interval\\)\n }\n }, [token, userLoading]\\)\n\n const roleLabels = {\n user: 'Viewer',\n moderator: 'Operator',\n superadmin: 'Admin'\n }\n\n if \\(userLoading\\) {\n return \\(\n
\n
Loading...
\n
\n \\)\n }\n\n const onlineCount = servers.filter\\(s => s.running\\).length\n const totalPlayers = servers.reduce\\(\\(sum, s\\) => sum + \\(s.players?.online || 0\\), 0\\)\n\n return \\(\n
\n {/* Header */}\n
\n
\n
\n
\n

\n Gameserver Monitor\n

\n
\n \n {onlineCount}/{servers.length} online\n \n |\n \n {totalPlayers} players\n \n
\n
\n\n
\n
\n
{user?.username}
\n
{roleLabels[role]}
\n
\n\n {isSuperadmin && \\(\n setShowUserMgmt\\(true\\)}\n className=\"\"btn btn-ghost\"\"\n >\n Users\n \n \\)}\n\n setShowSettings\\(true\\)}\n className=\"\"btn btn-ghost\"\"\n >\n Settings\n \n\n \n Sign out\n \n
\n
\n
\n
\n\n {/* Main Content */}\n
\n {error && \\(\n
\n {error}\n
\n \\)}\n\n {loading ? \\(\n
\n
Loading servers...
\n
\n \\) : \\(\n
\n {servers.map\\(\\(server, index\\) => \\(\n \n navigate\\('/server/' + server.id\\)}\n />\n
\n \\)\\)}\n
\n \\)}\n \n\n {/* Modals */}\n {showSettings && \\(\n setShowSettings\\(false\\)} />\n \\)}\n\n {showUserMgmt && \\(\n setShowUserMgmt\\(false\\)} />\n \\)}\n \n \\)\n}\n\"\"@\n$content | Out-File -FilePath \"\"Dashboard.jsx\"\" -Encoding UTF8\n'@)", "Bash(git add:*)", "Bash(dir:*)", - "Bash(ssh-keygen:*)", - "Bash(ssh alex@192.168.2.10 \"ssh root@192.168.2.30 ''cat /etc/nginx/nginx.conf''\")", - "Bash(ssh alex@192.168.2.10 \"ssh root@192.168.2.30 ''curl -s --max-time 5 http://localhost:3000/api/health''\")", - "Bash(ssh alex@192.168.2.10 \"ssh root@192.168.2.30 ''sleep 3 && curl -s --max-time 10 http://localhost:3000/api/servers 2>&1''\")", - "Bash(ssh alex@192.168.2.10 \"ssh root@192.168.2.30 ''cat > /opt/gameserver-monitor/backend/config.json << \"\"CONFIGEOF\"\"\n{\n \"\"servers\"\": [\n {\n \"\"id\"\": \"\"minecraft\"\",\n \"\"name\"\": \"\"All the Mods 10 | Minecraft\"\",\n \"\"host\"\": \"\"192.168.2.51\"\",\n \"\"type\"\": \"\"minecraft\"\",\n \"\"runtime\"\": \"\"screen\"\",\n \"\"rconPort\"\": 25575,\n \"\"rconPassword\"\": \"\"gsm-mc-2026\"\",\n \"\"screenName\"\": \"\"minecraft\"\",\n \"\"workDir\"\": \"\"/opt/minecraft\"\",\n \"\"startCmd\"\": \"\"./run.sh\"\"\n },\n {\n \"\"id\"\": \"\"factorio\"\",\n \"\"name\"\": \"\"Factorio\"\",\n \"\"host\"\": \"\"192.168.2.50\"\",\n \"\"type\"\": \"\"factorio\"\",\n \"\"runtime\"\": \"\"docker\"\",\n \"\"containerName\"\": \"\"factorio\"\",\n \"\"rconPort\"\": 27015,\n \"\"rconPassword\"\": \"\"jieTig6IkixaKuu\"\"\n },\n {\n \"\"id\"\": \"\"vrising\"\",\n \"\"name\"\": \"\"V Rising\"\",\n \"\"host\"\": \"\"192.168.2.52\"\",\n \"\"type\"\": \"\"vrising\"\",\n \"\"runtime\"\": \"\"systemd\"\",\n \"\"serviceName\"\": \"\"vrising\"\",\n \"\"rconPort\"\": 25575,\n \"\"rconPassword\"\": \"\"changeme\"\",\n \"\"workDir\"\": \"\"/home/steam/vrising\"\"\n },\n {\n \"\"id\"\": \"\"zomboid\"\",\n \"\"name\"\": \"\"Project Zomboid\"\",\n \"\"host\"\": \"\"10.0.30.66\"\",\n \"\"type\"\": \"\"zomboid\"\",\n \"\"runtime\"\": \"\"screen\"\",\n \"\"rconPort\"\": 27015,\n \"\"rconPassword\"\": \"\"ShkeloAufNettoParkplatzSchlagen47139\"\",\n \"\"screenName\"\": \"\"zomboid\"\",\n \"\"workDir\"\": \"\"/opt/pzserver\"\",\n \"\"startCmd\"\": \"\"./start-server.sh -servername Project\"\",\n \"\"sshUser\"\": \"\"pzuser\"\",\n \"\"logFile\"\": \"\"/home/pzuser/Zomboid/server-console.txt\"\"\n }\n ]\n}\nCONFIGEOF\npkill -f \"\"node server.js\"\" 2>/dev/null; cd /opt/gameserver-monitor/backend && node server.js >> /var/log/gsm-backend.log 2>&1 &''\")", - "Bash(ssh alex@192.168.2.10 \"ssh root@192.168.2.30 ''cat /opt/gameserver-monitor/backend/config.json | head -5''\")", - "Bash(ssh alex@192.168.2.10 \"ssh root@192.168.2.30 ''cat > /tmp/routefix.js << \"\"FIXEOF\"\"\nconst fs = require\\(\"\"fs\"\"\\);\nconst file = \"\"/opt/gameserver-monitor/backend/routes/servers.js\"\";\nlet content = fs.readFileSync\\(file, \"\"utf8\"\"\\);\n\n// Add import for isHostFailed\ncontent = content.replace\\(\n \"\"import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave } from \\\\\"\"../services/ssh.js\\\\\"\";\"\",\n \"\"import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed } from \\\\\"\"../services/ssh.js\\\\\"\";\"\"\n\\);\n\n// Find and update the server list endpoint\nconst oldMapCode = \"\"const servers = await Promise.all\\(config.servers.map\\(async \\(server\\) => {\"\";\nconst newMapCode = \\\\`const servers = await Promise.all\\(config.servers.map\\(async \\(server\\) => {\n // Quick check if host is unreachable - skip expensive operations\n const hostUnreachable = isHostFailed\\(server.host, server.sshUser\\);\\\\`;\n\ncontent = content.replace\\(oldMapCode, newMapCode\\);\n\n// Update the status call to use the quick check\nconst oldStatusCall = \"\"const [status, metrics, players, playerList, processUptime] = await Promise.all\\([\"\";\nconst newStatusCall = \\\\`// If host is unreachable, return immediately with minimal data\n if \\(hostUnreachable\\) {\n const metrics = await getCurrentMetrics\\(server.id\\).catch\\(\\(\\) => \\({\n cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0\n }\\)\\);\n const memTotal = formatBytes\\(metrics.memoryTotal\\);\n const memUsed = formatBytes\\(metrics.memoryUsed, memTotal.unit\\);\n return {\n id: server.id,\n name: server.name,\n type: server.type,\n status: \"\"unreachable\"\",\n running: false,\n metrics: {\n cpu: metrics.cpu,\n cpuCores: metrics.cpuCores,\n memory: metrics.memory,\n memoryUsed: memUsed.value,\n memoryTotal: memTotal.value,\n memoryUnit: memTotal.unit,\n uptime: 0\n },\n players: { online: 0, max: null, list: [] },\n hasRcon: !!server.rconPassword\n };\n }\n\n const [status, metrics, players, playerList, processUptime] = await Promise.all\\([\\\\`;\n\ncontent = content.replace\\(oldStatusCall, newStatusCall\\);\n\nfs.writeFileSync\\(file, content\\);\nconsole.log\\(\"\"Done\"\"\\);\nFIXEOF\nnode /tmp/routefix.js''\")", - "Bash(ssh alex@192.168.2.10 \"ssh root@192.168.2.30 ''cat > /opt/gameserver-monitor/backend/config.json << \"\"CONFIGEOF\"\"\n{\n \"\"servers\"\": [\n {\n \"\"id\"\": \"\"minecraft\"\",\n \"\"name\"\": \"\"All the Mods 10 | Minecraft\"\",\n \"\"host\"\": \"\"192.168.2.51\"\",\n \"\"type\"\": \"\"minecraft\"\",\n \"\"runtime\"\": \"\"screen\"\",\n \"\"rconPort\"\": 25575,\n \"\"rconPassword\"\": \"\"gsm-mc-2026\"\",\n \"\"screenName\"\": \"\"minecraft\"\",\n \"\"workDir\"\": \"\"/opt/minecraft\"\",\n \"\"startCmd\"\": \"\"./run.sh\"\"\n },\n {\n \"\"id\"\": \"\"factorio\"\",\n \"\"name\"\": \"\"Factorio\"\",\n \"\"host\"\": \"\"192.168.2.50\"\",\n \"\"type\"\": \"\"factorio\"\",\n \"\"runtime\"\": \"\"docker\"\",\n \"\"containerName\"\": \"\"factorio\"\",\n \"\"rconPort\"\": 27015,\n \"\"rconPassword\"\": \"\"jieTig6IkixaKuu\"\"\n },\n {\n \"\"id\"\": \"\"vrising\"\",\n \"\"name\"\": \"\"V Rising\"\",\n \"\"host\"\": \"\"192.168.2.52\"\",\n \"\"type\"\": \"\"vrising\"\",\n \"\"runtime\"\": \"\"systemd\"\",\n \"\"serviceName\"\": \"\"vrising\"\",\n \"\"rconPort\"\": 25575,\n \"\"rconPassword\"\": \"\"changeme\"\",\n \"\"workDir\"\": \"\"/home/steam/vrising\"\"\n },\n {\n \"\"id\"\": \"\"zomboid\"\",\n \"\"name\"\": \"\"Project Zomboid\"\",\n \"\"host\"\": \"\"10.0.30.66\"\",\n \"\"type\"\": \"\"zomboid\"\",\n \"\"runtime\"\": \"\"screen\"\",\n \"\"rconPort\"\": 27015,\n \"\"rconPassword\"\": \"\"ShkeloAufNettoParkplatzSchlagen47139\"\",\n \"\"screenName\"\": \"\"zomboid\"\",\n \"\"workDir\"\": \"\"/opt/pzserver\"\",\n \"\"startCmd\"\": \"\"./start-server.sh -servername Project\"\",\n \"\"sshUser\"\": \"\"pzuser\"\",\n \"\"logFile\"\": \"\"/home/pzuser/Zomboid/server-console.txt\"\"\n }\n ]\n}\nCONFIGEOF''\")", - "Bash(ssh alex@192.168.2.10 \"ssh root@192.168.2.30 ''wg show 2>&1 || echo \"\"WireGuard nicht installiert/aktiv\"\"''\")" + "Bash(ssh-keygen:*)" ] } } diff --git a/ActivityLog.jsx b/ActivityLog.jsx new file mode 100644 index 0000000..8aaddd2 --- /dev/null +++ b/ActivityLog.jsx @@ -0,0 +1,172 @@ +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', + 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: '📝', + factorio_world_create: '🌍', + factorio_world_delete: '🗑️' +} + +const serverLabels = { + minecraft: 'Minecraft', + factorio: 'Factorio', + zomboid: 'Project Zomboid', + vrising: 'V Rising' +} + +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 ( +
+
e.stopPropagation()}> +
+

Activity Log

+ +
+ +
+ {error && ( +
{error}
+ )} + + {loading ? ( +
Laden...
+ ) : logs.length === 0 ? ( +
Noch keine Aktivitäten
+ ) : ( +
+ {logs.map((log) => { + const avatarUrl = getAvatarUrl(log.discord_id, log.avatar) + const profileUrl = log.discord_id ? getDiscordProfileUrl(log.discord_id) : null + + return ( +
+ {/* Avatar or Action Icon */} + {avatarUrl ? ( + + + + ) : ( +
+ + {log.username?.charAt(0)?.toUpperCase()} + +
+ )} + +
+
+ {profileUrl ? ( + + {log.username} + + ) : ( + {log.username} + )} + + {actionIcons[log.action] || '📋'} {actionLabels[log.action] || log.action} + + {log.target && ( + + {serverLabels[log.target] || log.target} + + )} +
+ {log.details && ( +
+ {log.details} +
+ )} +
+ +
+ {formatDate(log.created_at)} +
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/App.jsx b/App.jsx new file mode 100644 index 0000000..6991efa --- /dev/null +++ b/App.jsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react' +import { BrowserRouter, Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom' +import { UserProvider } from './context/UserContext' +import Dashboard from './pages/Dashboard' +import ServerDetail from './pages/ServerDetail' + +// OAuth Callback Handler +function AuthCallback({ onLogin }) { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const [error, setError] = useState(null) + + useEffect(() => { + const token = searchParams.get('token') + const errorParam = searchParams.get('error') + + if (errorParam) { + const errorMessages = { + 'discord_denied': 'Discord-Anmeldung abgebrochen', + 'no_code': 'Kein Autorisierungscode erhalten', + 'not_in_guild': 'Du bist nicht Mitglied des Discord-Servers', + 'oauth_failed': 'Anmeldung fehlgeschlagen' + } + setError(errorMessages[errorParam] || 'Unbekannter Fehler') + setTimeout(() => navigate('/'), 3000) + return + } + + if (token) { + onLogin(token) + navigate('/') + } else { + navigate('/') + } + }, [searchParams, onLogin, navigate]) + + if (error) { + return ( +
+
+
{error}
+
Weiterleitung...
+
+
+ ) + } + + return ( +
+
Anmeldung wird verarbeitet...
+
+ ) +} + +// Login page for unauthenticated users +function LoginPage() { + const [searchParams] = useSearchParams() + const error = searchParams.get('error') + + const errorMessages = { + 'discord_denied': 'Discord-Anmeldung abgebrochen', + 'no_code': 'Kein Autorisierungscode erhalten', + 'not_in_guild': 'Du bist nicht Mitglied des Discord-Servers', + 'oauth_failed': 'Anmeldung fehlgeschlagen' + } + + const handleDiscordLogin = () => { + window.location.href = '/api/auth/discord' + } + + return ( +
+
+ Logo +

Gameserver Management

+

Melde dich mit Discord an, um fortzufahren

+ + {error && ( +
+ {errorMessages[error] || 'Anmeldung fehlgeschlagen'} +
+ )} + + + +

+ Nur für Mitglieder des Discord-Servers +

+
+
+ ) +} + +export default function App() { + const [token, setToken] = useState(localStorage.getItem('gsm_token')) + + const handleLogin = (newToken) => { + localStorage.setItem('gsm_token', newToken) + setToken(newToken) + } + + const handleLogout = () => { + localStorage.removeItem('gsm_token') + setToken(null) + } + + return ( + + + + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + } + /> + } + /> + } + /> + } /> + + + + ) +} diff --git a/CLAUDE.md b/CLAUDE.md index 0b24dd5..b37470d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,9 @@ The homelab consists of: - **Gameserver Monitor (192.168.2.30)**: React/Node.js webapp for monitoring game servers (LXC) - **Factorio Server (192.168.2.50)**: Docker-based game server (LXC) - **Minecraft Server (192.168.2.51)**: ATM10 modded server running via screen (VM) +- **V Rising Server (192.168.2.52)**: Dedicated server (LXC) +- **Palworld Server (192.168.2.53)**: Dedicated server with systemd (LXC) +- **Project Zomboid Server (10.0.30.66)**: Dedicated server (external VM) ## Key Technical Details @@ -21,12 +24,22 @@ The homelab consists of: **Gameserver Monitor Rollensystem**: - `user`: Kann nur Server-Metriken sehen (CPU, RAM, Players, Uptime) -- `moderator`: Zusätzlich Konsole, RCON, Server Start/Stop/Restart -- `superadmin`: Zusätzlich Nutzerverwaltung (User anlegen/löschen, Rollen ändern) +- `moderator`: Zusätzlich Konsole, RCON, Server Start/Stop/Restart, Auto-Shutdown, Config-Editoren +- `superadmin`: Zusätzlich Nutzerverwaltung, Activity Log, Anzeigeeinstellungen (Verbindungsadresse/Hinweis pro Server) -**Domain**: dimension47.de with subdomains managed via Cloudflare DDNS +**GSM Features**: +- Dashboard mit Server-Karten (Status, Metriken, Spieleranzahl) +- Server-Detailansicht mit Tabs (Übersicht, Metriken, Konsole, Einstellungen, etc.) +- Auto-Shutdown: Server stoppt automatisch wenn keine Spieler online sind +- Config-Editoren: Palworld (INI), Project Zomboid (INI/Lua) +- Factorio Weltverwaltung: Spielstände erstellen/löschen/laden +- Minecraft Whitelist-Verwaltung via RCON +- Anzeigeeinstellungen: Superadmins können Verbindungsadresse und Hinweis pro Server anpassen +- Activity Log: Protokolliert alle Aktionen mit Discord-Avatar -**SSH Access**: The monitor server (.30) has SSH key access to Proxmox and both game servers for remote management. +**Domain**: zeasy.dev with subdomains managed via Cloudflare DDNS (gsm.zeasy.dev, factorio.zeasy.dev, palworld.zeasy.dev, pz.zeasy.dev) + +**SSH Access**: The monitor server (.30) has SSH key access to Proxmox and all game servers for remote management. ## Language Note diff --git a/Dashboard.jsx b/Dashboard.jsx new file mode 100644 index 0000000..ff2ff6c --- /dev/null +++ b/Dashboard.jsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { getServers } from '../api' +import { useUser } from '../context/UserContext' +import ServerCard from '../components/ServerCard' +import UserManagement from '../components/UserManagement' +import ActivityLog from '../components/ActivityLog' + +export default function Dashboard({ onLogout }) { + const navigate = useNavigate() + const { user, token, loading: userLoading, isSuperadmin, role, avatarUrl } = useUser() + const [servers, setServers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showUserMgmt, setShowUserMgmt] = useState(false) + const [showActivityLog, setShowActivityLog] = useState(false) + + const fetchServers = async () => { + try { + const data = await getServers(token) + setServers(data) + setError('') + } catch (err) { + if (err.message.includes('401') || err.message.includes('403')) { + onLogout() + } else { + setError('Verbindung zum Server fehlgeschlagen') + } + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (!userLoading && token) { + fetchServers() + const interval = setInterval(fetchServers, 10000) + return () => clearInterval(interval) + } + }, [token, userLoading]) + + const roleLabels = { + user: 'Viewer', + moderator: 'Operator', + superadmin: 'Admin' + } + + if (userLoading) { + return ( +
+
Laden...
+
+ ) + } + + const onlineCount = servers.filter(s => s.running).length + document.title = 'Dashboard | Zeasy GSM' + const totalPlayers = servers.reduce((sum, s) => sum + (s.players?.online || 0), 0) + + return ( +
+ {/* Header */} +
+
+
+
+ + Logo + Logo + + Gameserver Management +
+
+ + {onlineCount}/{servers.length} online + + | + + {totalPlayers} Spieler + +
+
+ {/* User info with Discord avatar */} +
+ {avatarUrl && ( + Avatar + )} +
+
{user?.username}
+
{roleLabels[role]}
+
+
+ + {isSuperadmin && ( + <> + + + + )} + +
+
+
+
+ + {/* Main Content */} +
+ {error && ( +
+ {error} +
+ )} + {loading ? ( +
+
Server werden geladen...
+
+ ) : ( +
+ {servers.map((server, index) => ( +
+ navigate('/server/' + server.id)} + isAuthenticated={true} + /> +
+ ))} +
+ )} +
+ + {/* Modals */} + {showUserMgmt && ( + setShowUserMgmt(false)} /> + )} + {showActivityLog && ( + setShowActivityLog(false)} /> + )} +
+ ) +} diff --git a/UserContext.jsx b/UserContext.jsx new file mode 100644 index 0000000..86287cd --- /dev/null +++ b/UserContext.jsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState, useEffect } from 'react' +import { getMe } from '../api' + +const UserContext = createContext(null) + +export function UserProvider({ children, token }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!token) { + setUser(null) + setLoading(false) + return + } + + getMe(token) + .then(data => { + setUser(data) + setLoading(false) + }) + .catch(() => { + // Token invalid, clear it + localStorage.removeItem('gsm_token') + setUser(null) + setLoading(false) + }) + }, [token]) + + // Get Discord avatar URL + const getAvatarUrl = () => { + if (!user?.discordId || !user?.avatar) { + return null + } + return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64` + } + + const value = { + user, + token, + loading, + role: user?.role || 'user', + isModerator: ['moderator', 'superadmin'].includes(user?.role), + isSuperadmin: user?.role === 'superadmin', + avatarUrl: getAvatarUrl() + } + + return ( + + {children} + + ) +} + +export function useUser() { + const context = useContext(UserContext) + if (!context) { + throw new Error('useUser must be used within UserProvider') + } + return context +} diff --git a/UserManagement.jsx b/UserManagement.jsx new file mode 100644 index 0000000..cf2f285 --- /dev/null +++ b/UserManagement.jsx @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react' +import { useUser } from '../context/UserContext' +import { getUsers } from '../api' + +export default function UserManagement({ onClose }) { + const { token } = useUser() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + const fetchUsers = async () => { + try { + const data = await getUsers(token) + setUsers(data) + setError('') + } catch (err) { + setError('Fehler beim Laden der Benutzer') + } finally { + setLoading(false) + } + } + fetchUsers() + }, [token]) + + const roleLabels = { + user: 'Viewer', + moderator: 'Operator', + superadmin: 'Admin' + } + + const roleColors = { + user: 'text-neutral-400', + moderator: 'text-blue-400', + superadmin: 'text-yellow-400' + } + + const getAvatarUrl = (user) => { + if (!user.discord_id || !user.avatar) return null + return `https://cdn.discordapp.com/avatars/${user.discord_id}/${user.avatar}.png?size=64` + } + + return ( +
+
e.stopPropagation()}> +
+

Benutzerliste

+ +
+ +
+ {error && ( +
{error}
+ )} + +

+ Benutzer, die sich über Discord angemeldet haben. Rollen werden durch Discord-Rollen bestimmt. +

+ + {loading ? ( +
Laden...
+ ) : users.length === 0 ? ( +
Noch keine Benutzer angemeldet
+ ) : ( +
+ {users.map((user) => ( +
+ {getAvatarUrl(user) ? ( + + ) : ( +
+ + {user.username?.charAt(0)?.toUpperCase()} + +
+ )} + +
+
{user.username}
+
+ {user.discord_id} +
+
+ + + {roleLabels[user.role]} + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/ZeasyWG-Alex.conf b/ZeasyWG-Alex.conf new file mode 100644 index 0000000..028fdaa --- /dev/null +++ b/ZeasyWG-Alex.conf @@ -0,0 +1,10 @@ +[Interface] +PrivateKey = WC2HEXQ10sm5sE9Apj5wBBv2/CSRmpQNUovT1xSO8kM= +Address = 10.0.200.201/32 +DNS = 10.0.0.1 + +[Peer] +PublicKey = wgP8/WMTzhr45bH2GFabcYUhycCLF/pPczghWgLJN0Q= +Endpoint = beeck.zeasy.dev:47199 +AllowedIPs = 10.0.0.0/16 +PersistentKeepalive = 60 \ No newline at end of file diff --git a/ZomboidConfigEditor.jsx b/ZomboidConfigEditor.jsx new file mode 100644 index 0000000..493bac0 --- /dev/null +++ b/ZomboidConfigEditor.jsx @@ -0,0 +1,326 @@ +import { useState, useEffect, useRef } from 'react' +import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react' +import { getZomboidConfigs, getZomboidConfig, saveZomboidConfig } from '../api' + +export default function ZomboidConfigEditor({ token }) { + const [files, setFiles] = useState([]) + const [selectedFile, setSelectedFile] = useState(null) + const [content, setContent] = useState('') + const [originalContent, setOriginalContent] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [hasChanges, setHasChanges] = useState(false) + const textareaRef = useRef(null) + const highlightRef = useRef(null) + + // Load file list + useEffect(() => { + loadFiles() + }, [token]) + + // Track changes + useEffect(() => { + setHasChanges(content !== originalContent) + }, [content, originalContent]) + + // Sync scroll between textarea and highlight div + const handleScroll = () => { + if (highlightRef.current && textareaRef.current) { + highlightRef.current.scrollTop = textareaRef.current.scrollTop + highlightRef.current.scrollLeft = textareaRef.current.scrollLeft + } + } + + async function loadFiles() { + setLoading(true) + setError(null) + try { + const data = await getZomboidConfigs(token) + setFiles(data.files || []) + if (data.files?.length > 0 && !selectedFile) { + loadFile(data.files[0].filename) + } + } catch (err) { + setError('Fehler beim Laden der Config-Dateien: ' + err.message) + } finally { + setLoading(false) + } + } + + async function loadFile(filename) { + setLoading(true) + setError(null) + setSuccess(null) + try { + const data = await getZomboidConfig(token, filename) + setSelectedFile(filename) + setContent(data.content) + setOriginalContent(data.content) + } catch (err) { + setError('Fehler beim Laden: ' + err.message) + } finally { + setLoading(false) + } + } + + async function handleSave() { + if (!selectedFile || !hasChanges) return + + setSaving(true) + setError(null) + setSuccess(null) + try { + await saveZomboidConfig(token, selectedFile, content) + setOriginalContent(content) + setSuccess('Config gespeichert! Server-Neustart erforderlich für Änderungen.') + setTimeout(() => setSuccess(null), 5000) + } catch (err) { + setError('Fehler beim Speichern: ' + err.message) + } finally { + setSaving(false) + } + } + + function handleDiscard() { + setContent(originalContent) + setError(null) + setSuccess(null) + } + + function getFileDescription(filename) { + const descriptions = { + 'Project.ini': 'Server-Einstellungen (PVP, Spieler, Netzwerk)', + 'Project_SandboxVars.lua': 'Gameplay-Einstellungen (Zombies, Loot, Schwierigkeit)', + 'Project_spawnpoints.lua': 'Spawn-Punkte für neue Spieler', + 'Project_spawnregions.lua': 'Spawn-Regionen Konfiguration' + } + return descriptions[filename] || filename + } + + // Highlight syntax based on file type + function highlightSyntax(text, filename) { + if (!text) return '' + + const isLua = filename?.endsWith('.lua') + const lines = text.split('\n') + + return lines.map((line, i) => { + let highlighted = line + .replace(/&/g, '&') + .replace(//g, '>') + + if (isLua) { + // Lua: -- comments + if (line.trim().startsWith('--')) { + highlighted = `${highlighted}` + } else if (line.includes('--')) { + const idx = line.indexOf('--') + const code = highlighted.substring(0, idx) + const comment = highlighted.substring(idx) + highlighted = `${code}${comment}` + } + // Highlight true/false/nil + highlighted = highlighted + .replace(/\b(true|false|nil)\b/g, '$1') + // Highlight numbers + highlighted = highlighted + .replace(/\b(\d+\.?\d*)\b/g, '$1') + } else { + // INI: # comments + if (line.trim().startsWith('#')) { + highlighted = `${highlighted}` + } + // Highlight key=value + else if (line.includes('=')) { + const idx = line.indexOf('=') + const key = highlighted.substring(0, idx) + const value = highlighted.substring(idx + 1) + highlighted = `${key}=${value}` + } + } + + return highlighted + }).join('\n') + } + + if (loading && files.length === 0) { + return ( +
+ + Lade Config-Dateien... +
+ ) + } + + return ( +
+ {/* File selector */} +
+
+ + +
+ + +
+ + {/* File description */} + {selectedFile && ( +
+ + {getFileDescription(selectedFile)} +
+ )} + + {/* Error/Success messages */} + {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + + {/* Editor with syntax highlighting */} +
+ {/* Highlighted background layer */} +