Compare commits

..

11 Commits

Author SHA1 Message Date
66716279ad Fix syntax highlighting regex order in config editors
All checks were successful
Deploy GSM / deploy (push) Successful in 22s
The number regex was applied after the boolean regex, causing it to
match "400" in CSS class names like "text-orange-400" and corrupt
the HTML output. Now uses placeholder tokens to mark numbers before
adding any HTML tags.

Affected editors: Palworld, Zomboid, OpenTTD

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:31:20 +01:00
8447484270 Add Discord update API route
All checks were successful
Deploy GSM / deploy (push) Successful in 26s
Adds POST /api/servers/discord/send-update endpoint that allows
superadmins to send announcements to all Discord guild update channels.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:13:49 +01:00
51b95240f2 Logging für REST Abfragen aus dem Palworld Server etnfernt
All checks were successful
Deploy GSM / deploy (push) Successful in 24s
2026-01-12 03:07:23 +01:00
4bfb870fbb Add Palworld player count and list support via RCON
All checks were successful
Deploy GSM / deploy (push) Successful in 41s
- Add ShowPlayers RCON command handling for Palworld servers
- Update SSH access documentation with jump host instructions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:58:11 +01:00
b07ec607eb Remove SQLite databases from git tracking
All checks were successful
Deploy GSM / deploy (push) Successful in 24s
Database files should not be in version control as they contain
production data that gets overwritten on each deployment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:55:25 +01:00
a07e8df3e7 Add server control buttons to dashboard with confirmation dialogs
All checks were successful
Deploy GSM / deploy (push) Successful in 24s
- Add ConfirmModal component for stop/restart confirmations
- Add start/stop/restart buttons to ServerCard (moderator/admin only)
- Add confirmation dialogs to ServerDetail for stop/restart actions
- Add btn-sm CSS class for smaller buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:40:45 +01:00
3dc7e9e7e7 Restrict server detail access for guests
All checks were successful
Deploy GSM / deploy (push) Successful in 26s
- Add isGuest flag to UserContext
- Block guests from navigating to /server/:id route
- Make ServerCards non-clickable for guests
- Add rejectGuest middleware to backend
- Protect server detail endpoints (/:id, /metrics/history, /whitelist)

Guests can now only view the dashboard overview without accessing
individual server details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:57:34 +01:00
Alexander Zielonka
e88e246be6 Add unreachable status handling to ServerCard
All checks were successful
Deploy GSM / deploy (push) Successful in 24s
- Gray out unreachable servers with 50% opacity
- Disable click interaction for unreachable servers
- Show "Nicht erreichbar" badge

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:28:34 +01:00
Alexander Zielonka
f2b7b62d81 Bump frontend version to 1.0.0
All checks were successful
Deploy GSM / deploy (push) Successful in 23s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:36:19 +01:00
Alexander Zielonka
a043b6faf8 Trigger deployment 2026-01-09 12:35:11 +01:00
Alexander Zielonka
d5700fe84a Fix deployment workflow: correct target paths and PM2 cwd
- Deploy backend to /opt/gameserver-monitor/backend/ (not root)
- Deploy frontend to /opt/gameserver-monitor/frontend/dist/
- Run PM2 restart from backend directory with --update-env
- Add proper PM2 process check before restart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:27:09 +01:00
22 changed files with 305 additions and 36 deletions

View File

@@ -9,7 +9,10 @@
"Bash(cat:*)",
"Bash(git add:*)",
"Bash(dir:*)",
"Bash(ssh-keygen:*)"
"Bash(ssh-keygen:*)",
"WebSearch",
"WebFetch(domain:tech.palworldgame.com)",
"WebFetch(domain:docs.palworldgame.com)"
]
}
}

View File

@@ -38,8 +38,9 @@ jobs:
username: root
key: ${{ secrets.SSH_DEPLOY_KEY }}
source: "gsm-backend/"
target: "/opt/gameserver-monitor/"
target: "/opt/gameserver-monitor/backend/"
strip_components: 1
overwrite: true
- name: Deploy Frontend
uses: appleboy/scp-action@v0.1.7
@@ -48,8 +49,9 @@ jobs:
username: root
key: ${{ secrets.SSH_DEPLOY_KEY }}
source: "gsm-frontend/dist/"
target: "/opt/gameserver-monitor/frontend/"
target: "/opt/gameserver-monitor/frontend/dist/"
strip_components: 2
overwrite: true
- name: Restart Services
uses: appleboy/ssh-action@v1.0.3
@@ -58,6 +60,14 @@ jobs:
username: root
key: ${{ secrets.SSH_DEPLOY_KEY }}
script: |
cd /opt/gameserver-monitor
pm2 restart gsm-backend || pm2 start backend/server.js --name gsm-backend
cd /opt/gameserver-monitor/backend
# Restart or start PM2 process with correct cwd
if pm2 list | grep -q gsm-backend; then
pm2 restart gsm-backend --update-env
else
pm2 start server.js --name gsm-backend
pm2 save
fi
echo "Deploy complete!"

4
.gitignore vendored
View File

@@ -4,3 +4,7 @@ dist/
.env
.env.*
!.env.example
# SQLite databases (production data should not be in git)
*.sqlite
*.sqlite3

View File

@@ -40,7 +40,12 @@ The homelab consists of:
**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.
**SSH Access**: Zugriff auf Server erfolgt über den Pi als Jump-Host:
```bash
ssh alex@192.168.2.10 # Erst auf den Pi
ssh root@192.168.2.XX # Dann zum Zielserver (z.B. .53 für Palworld)
```
Oder in einem Befehl: `ssh alex@192.168.2.10 "ssh root@192.168.2.53 'befehl'"`
## Language Note

View File

@@ -0,0 +1,5 @@
; This configuration file is a sample of the default server settings.
; Changes to this file will NOT be reflected on the server.
; To change the server settings, modify Pal/Saved/Config/LinuxServer/PalWorldSettings.ini.
[/Script/Pal.PalGameWorldSettings]
OptionSettings=(Difficulty=None,RandomizerType=None,RandomizerSeed="",bIsRandomizerPalLevelRandom=False,DayTimeSpeedRate=1.000000,NightTimeSpeedRate=1.000000,ExpRate=1.000000,PalCaptureRate=1.000000,PalSpawnNumRate=1.000000,PalDamageRateAttack=1.000000,PalDamageRateDefense=1.000000,PlayerDamageRateAttack=1.000000,PlayerDamageRateDefense=1.000000,PlayerStomachDecreaceRate=1.000000,PlayerStaminaDecreaceRate=1.000000,PlayerAutoHPRegeneRate=1.000000,PlayerAutoHpRegeneRateInSleep=1.000000,PalStomachDecreaceRate=1.000000,PalStaminaDecreaceRate=1.000000,PalAutoHPRegeneRate=1.000000,PalAutoHpRegeneRateInSleep=1.000000,BuildObjectHpRate=1.000000,BuildObjectDamageRate=1.000000,BuildObjectDeteriorationDamageRate=1.000000,CollectionDropRate=1.000000,CollectionObjectHpRate=1.000000,CollectionObjectRespawnSpeedRate=1.000000,EnemyDropItemRate=1.000000,DeathPenalty=All,bEnablePlayerToPlayerDamage=False,bEnableFriendlyFire=False,bEnableInvaderEnemy=True,bActiveUNKO=False,bEnableAimAssistPad=True,bEnableAimAssistKeyboard=False,DropItemMaxNum=3000,DropItemMaxNum_UNKO=100,BaseCampMaxNum=128,BaseCampWorkerMaxNum=15,DropItemAliveMaxHours=1.000000,bAutoResetGuildNoOnlinePlayers=False,AutoResetGuildTimeNoOnlinePlayers=72.000000,GuildPlayerMaxNum=20,BaseCampMaxNumInGuild=4,PalEggDefaultHatchingTime=72.000000,WorkSpeedRate=1.000000,AutoSaveSpan=30.000000,bIsMultiplay=False,bIsPvP=False,bHardcore=False,bPalLost=False,bCharacterRecreateInHardcore=False,bCanPickupOtherGuildDeathPenaltyDrop=False,bEnableNonLoginPenalty=True,bEnableFastTravel=True,bEnableFastTravelOnlyBaseCamp=False,bIsStartLocationSelectByMap=True,bExistPlayerAfterLogout=False,bEnableDefenseOtherGuildPlayer=False,bInvisibleOtherGuildBaseCampAreaFX=False,bBuildAreaLimit=False,ItemWeightRate=1.000000,CoopPlayerMaxNum=4,ServerPlayerMaxNum=32,ServerName="Default Palworld Server",ServerDescription="",AdminPassword="gsm-pal-admin-2026",ServerPassword="",bAllowClientMod=True,PublicPort=8211,PublicIP="",RCONEnabled=True,RCONPort=25575,Region="",bUseAuth=False,BanListURL="https://api.palworldgame.com/api/banlist.txt",RESTAPIEnabled=True,RESTAPIPort=8212,bShowPlayerList=False,ChatPostLimitPerMinute=30,CrossplayPlatforms=(Steam,Xbox,PS5,Mac),bIsUseBackupSaveData=True,LogFormatType=Text,bIsShowJoinLeftMessage=True,SupplyDropSpan=180,EnablePredatorBossPal=True,MaxBuildingLimitNum=0,ServerReplicatePawnCullDistance=15000.000000,bAllowGlobalPalboxExport=True,bAllowGlobalPalboxImport=False,EquipmentDurabilityDamageRate=1.000000,ItemContainerForceMarkDirtyInterval=1.000000,ItemCorruptionMultiplier=1.000000,DenyTechnologyList=,GuildRejoinCooldownMinutes=0,BlockRespawnTime=5.000000,RespawnPenaltyDurationThreshold=0.000000,RespawnPenaltyTimeScale=2.000000,bDisplayPvPItemNumOnWorldMap_BaseCamp=False,bDisplayPvPItemNumOnWorldMap_Player=False,AdditionalDropItemWhenPlayerKillingInPvPMode="PlayerDropItem",AdditionalDropItemNumWhenPlayerKillingInPvPMode=1,bAdditionalDropItemWhenPlayerKillingInPvPMode=False,bAllowEnhanceStat_Health=True,bAllowEnhanceStat_Attack=True,bAllowEnhanceStat_Stamina=True,bAllowEnhanceStat_Weight=True,bAllowEnhanceStat_WorkSpeed=True)

Binary file not shown.

View File

@@ -55,3 +55,13 @@ export function requireRole(minRole) {
next();
};
}
export function rejectGuest(req, res, next) {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (req.user.isGuest || req.user.role === 'guest') {
return res.status(403).json({ error: 'Guests cannot access server details' });
}
next();
}

View File

@@ -2,13 +2,15 @@ import { Router } from 'express';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js';
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 { 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';
import { getEmptySince } from '../services/autoshutdown.js';
import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js';
import { sendUpdateToAllGuilds } from '../services/discordBot.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -501,8 +503,8 @@ router.put("/openttd/config", authenticateToken, requireRole("moderator"), async
});
// Get single server
router.get('/:id', optionalAuth, async (req, res) => {
// Get single server (guests not allowed)
router.get('/:id', authenticateToken, rejectGuest, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) {
@@ -578,8 +580,8 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
});
// Get metrics history from Prometheus
router.get('/:id/metrics/history', optionalAuth, async (req, res) => {
// Get metrics history from Prometheus (guests not allowed)
router.get('/:id/metrics/history', authenticateToken, rejectGuest, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
@@ -706,8 +708,8 @@ router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (
}
});
// Get whitelist (with server-side caching)
router.get('/:id/whitelist', optionalAuth, async (req, res) => {
// Get whitelist (with server-side caching, guests not allowed)
router.get('/:id/whitelist', authenticateToken, rejectGuest, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
@@ -825,4 +827,36 @@ router.put("/:id/display-settings", authenticateToken, requireRole("superadmin")
res.status(500).json({ error: err.message });
}
});
// ============ DISCORD UPDATE ROUTES ============
// Send update to all Discord guilds (superadmin only)
router.post("/discord/send-update", authenticateToken, requireRole("superadmin"), async (req, res) => {
const { title, description, color, serverType } = req.body;
if (!title || !description) {
return res.status(400).json({ error: "Title and description required" });
}
try {
const serverIcons = {
minecraft: '⛏️', factorio: '⚙️', zomboid: '🧟', vrising: '🧛',
palworld: '🦎', terraria: '⚔️', openttd: '🚂'
};
const embed = new EmbedBuilder()
.setTitle((serverIcons[serverType] || '📢') + ' ' + title)
.setDescription(description)
.setColor(color || 0x5865F2)
.setTimestamp();
await sendUpdateToAllGuilds(embed);
logActivity(req.user.id, req.user.username, 'discord_update', serverType || 'general', title, req.user.discordId, req.user.avatar);
res.json({ message: "Update sent to all Discord guilds" });
} catch (err) {
console.error('[Discord] Error sending update:', err);
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@@ -4,6 +4,24 @@ const rconConnections = new Map();
const playerCache = new Map();
const CACHE_TTL = 30000; // 30 seconds
// Palworld REST API helper
async function getPalworldPlayers(server) {
const port = server.restApiPort || 8212;
const url = `http://${server.host}:${port}/v1/api/players`;
const auth = Buffer.from(`admin:${server.rconPassword}`).toString('base64');
const response = await fetch(url, {
headers: { 'Authorization': `Basic ${auth}` },
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
throw new Error(`REST API error: ${response.status}`);
}
return await response.json();
}
async function getConnection(server) {
const key = `${server.host}:${server.rconPort}`;
@@ -87,6 +105,10 @@ export async function getPlayers(server) {
// Count lines that contain player info
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
result = { online: lines.length, max: null };
} else if (server.type === 'palworld') {
// Use REST API instead of RCON for Palworld
const data = await getPalworldPlayers(server);
result = { online: data.players?.length || 0, max: null };
}
playerCache.set(cacheKey, { data: result, time: Date.now() });
@@ -145,6 +167,10 @@ export async function getPlayerList(server) {
// Parse player names from listusers output
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
players = lines.map(l => l.trim()).filter(p => p);
} else if (server.type === 'palworld') {
// Use REST API instead of RCON for Palworld
const data = await getPalworldPlayers(server);
players = (data.players || []).map(p => p.name);
}
const result = { players };

View File

@@ -192,7 +192,12 @@ export async function getConsoleLog(server, lines = 50) {
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
return result.stdout || result.stderr;
} else if (server.runtime === 'systemd') {
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
let cmd = `tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`;
// Filter out REST API spam for Palworld
if (server.type === 'palworld') {
cmd = `journalctl -u ${server.serviceName} -n ${lines * 2} --no-pager | grep -v "REST accessed endpoint" | tail -n ${lines}`;
}
const result = await ssh.execCommand(cmd);
return result.stdout || result.stderr;
} else if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,11 +1,20 @@
import { useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { UserProvider } from './context/UserContext'
import { UserProvider, useUser } from './context/UserContext'
import Dashboard from './pages/Dashboard'
import ServerDetail from './pages/ServerDetail'
import LoginPage from './pages/LoginPage'
import AuthCallback from './pages/AuthCallback'
function ProtectedServerDetail({ onLogout }) {
const { isGuest, loading } = useUser()
if (loading) return null
if (isGuest) return <Navigate to="/" replace />
return <ServerDetail onLogout={onLogout} />
}
export default function App() {
const [token, setToken] = useState(localStorage.getItem('gsm_token'))
@@ -24,7 +33,7 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={token ? <Dashboard onLogout={handleLogout} /> : <Navigate to="/login" replace />} />
<Route path="/server/:serverId" element={token ? <ServerDetail onLogout={handleLogout} /> : <Navigate to="/login" replace />} />
<Route path="/server/:serverId" element={token ? <ProtectedServerDetail onLogout={handleLogout} /> : <Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
<Route path="/auth/callback" element={<AuthCallback onLogin={handleLogin} />} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -0,0 +1,23 @@
export default function ConfirmModal({ title, message, confirmText, cancelText, onConfirm, onCancel, variant = 'danger' }) {
const confirmBtnClass = variant === 'danger' ? 'btn btn-destructive' : 'btn btn-primary'
return (
<div className="modal-backdrop fade-in" onClick={onCancel}>
<div className="modal fade-in-scale max-w-md" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<h2 className="text-lg font-semibold text-white mb-2">{title}</h2>
<p className="text-neutral-400 mb-6">{message}</p>
<div className="flex gap-3 justify-end">
<button onClick={onCancel} className="btn btn-ghost">
{cancelText || 'Abbrechen'}
</button>
<button onClick={onConfirm} className={confirmBtnClass}>
{confirmText || 'Bestätigen'}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -91,10 +91,13 @@ export default function OpenTTDConfigEditor({ token }) {
const key = highlighted.substring(0, idx)
const value = highlighted.substring(idx + 1)
// Color numbers, true/false, and quoted strings
// Color numbers first (with placeholders), then booleans
// This prevents the regex from matching numbers in CSS class names like "text-orange-400"
let coloredValue = value
.replace(/\b(\d+)\b/g, '%%%NUM_START%%%$1%%%NUM_END%%%')
.replace(/\b(true|false)\b/gi, '<span class="text-orange-400">$1</span>')
.replace(/\b(\d+)\b/g, '<span class="text-cyan-400">$1</span>')
.replace(/%%%NUM_START%%%/g, '<span class="text-cyan-400">')
.replace(/%%%NUM_END%%%/g, '</span>')
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${coloredValue}</span>`
}

View File

@@ -152,10 +152,13 @@ export default function PalworldConfigEditor({ token }) {
const key = highlighted.substring(0, idx)
const value = highlighted.substring(idx + 1)
// Color boolean values
// Color numbers first (before adding any HTML tags), then booleans
// This prevents the regex from matching numbers in CSS class names like "text-orange-400"
let coloredValue = value
.replace(/\b(\d+\.?\d*)\b/g, '%%%NUM_START%%%$1%%%NUM_END%%%')
.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>')
.replace(/%%%NUM_START%%%/g, '<span class="text-cyan-400">')
.replace(/%%%NUM_END%%%/g, '</span>')
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${coloredValue}</span>`
}

View File

@@ -1,3 +1,7 @@
import { useState } from 'react'
import { serverAction } from '../api'
import ConfirmModal from './ConfirmModal'
const serverInfo = {
minecraft: {
address: 'minecraft.zeasy.dev',
@@ -64,8 +68,10 @@ const getServerInfo = (serverName) => {
return null
}
export default function ServerCard({ server, onClick, isAuthenticated, displaySettings }) {
export default function ServerCard({ server, onClick, isAuthenticated, isGuest, displaySettings, isModerator, token, onServerAction }) {
const defaultInfo = getServerInfo(server.name)
const [confirmAction, setConfirmAction] = useState(null)
const [actionLoading, setActionLoading] = useState(false)
// Merge default info with database display settings (database takes priority)
const info = defaultInfo ? {
@@ -107,17 +113,46 @@ export default function ServerCard({ server, onClick, isAuthenticated, displaySe
return { class: 'badge badge-warning', text: 'Starting...' }
case 'stopping':
return { class: 'badge badge-warning', text: 'Stopping...' }
case 'unreachable':
return { class: 'badge bg-neutral-600 text-neutral-400', text: 'Nicht erreichbar' }
default:
return { class: 'badge badge-destructive', text: 'Offline' }
}
}
const statusBadge = getStatusBadge()
const isUnreachable = server.status === 'unreachable'
const isClickable = !isUnreachable && !isGuest && onClick
const handleAction = async (action) => {
setActionLoading(true)
try {
await serverAction(token, server.id, action)
if (onServerAction) onServerAction()
} catch (err) {
console.error(err)
} finally {
setActionLoading(false)
setConfirmAction(null)
}
}
const handleActionClick = (action, e) => {
e.stopPropagation()
if (action === 'start') {
handleAction(action)
} else {
setConfirmAction(action)
}
}
const isActionDisabled = actionLoading || server.status === 'starting' || server.status === 'stopping'
return (
<div
className="card card-clickable p-5"
onClick={onClick}
className={isUnreachable ? "card p-5 opacity-50 cursor-not-allowed" : (isClickable ? "card card-clickable p-5" : "card p-5")}
onClick={isClickable ? onClick : undefined}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
@@ -235,6 +270,55 @@ export default function ServerCard({ server, onClick, isAuthenticated, displaySe
</div>
</div>
)}
{/* Server Controls - only for moderators */}
{isModerator && !isUnreachable && (
<div className="mt-3 pt-3 border-t border-neutral-800">
<div className="flex flex-wrap gap-2">
{(server.status === 'online' || (server.running && server.status !== 'starting' && server.status !== 'stopping')) ? (
<>
<button
onClick={(e) => handleActionClick('stop', e)}
disabled={isActionDisabled}
className="btn btn-destructive btn-sm"
>
{server.status === 'stopping' ? 'Stoppt...' : 'Stoppen'}
</button>
<button
onClick={(e) => handleActionClick('restart', e)}
disabled={isActionDisabled}
className="btn btn-secondary btn-sm"
>
{server.status === 'starting' ? 'Startet...' : 'Neustarten'}
</button>
</>
) : (
<button
onClick={(e) => handleActionClick('start', e)}
disabled={isActionDisabled}
className="btn btn-primary btn-sm"
>
{server.status === 'starting' ? 'Startet...' : 'Starten'}
</button>
)}
</div>
</div>
)}
{/* Confirmation Modal */}
{confirmAction && (
<ConfirmModal
title={confirmAction === 'stop' ? 'Server stoppen?' : 'Server neustarten?'}
message={confirmAction === 'stop'
? `Bist du sicher, dass du ${server.name} stoppen möchtest?`
: `Bist du sicher, dass du ${server.name} neustarten möchtest?`
}
confirmText={confirmAction === 'stop' ? 'Stoppen' : 'Neustarten'}
variant={confirmAction === 'stop' ? 'danger' : 'primary'}
onConfirm={() => handleAction(confirmAction)}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
)
}

View File

@@ -122,12 +122,13 @@ export default function ZomboidConfigEditor({ token }) {
const comment = highlighted.substring(idx)
highlighted = `${code}<span class="text-emerald-500">${comment}</span>`
}
// Highlight true/false/nil
// Highlight numbers first (with placeholders), then booleans
// This prevents the regex from matching numbers in CSS class names like "text-orange-400"
highlighted = highlighted
.replace(/\b(\d+\.?\d*)\b/g, '%%%NUM_START%%%$1%%%NUM_END%%%')
.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>')
.replace(/%%%NUM_START%%%/g, '<span class="text-cyan-400">')
.replace(/%%%NUM_END%%%/g, '</span>')
} else {
// INI: # comments
if (line.trim().startsWith('#')) {

View File

@@ -30,6 +30,7 @@ export function UserProvider({ children, token, onLogout }) {
token,
loading,
role: user?.role || 'user',
isGuest: user?.isGuest || user?.role === 'guest',
isModerator: ['moderator', 'superadmin'].includes(user?.role),
isSuperadmin: user?.role === 'superadmin'
}

View File

@@ -52,6 +52,11 @@ button {
cursor: not-allowed;
}
.btn-sm {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
}
.btn-primary {
background-color: #fafafa;
color: #0a0a0a;

View File

@@ -9,7 +9,7 @@ import LoginModal from '../components/LoginModal'
export default function Dashboard({ onLogout }) {
const navigate = useNavigate()
const { user, token, loading: userLoading, isSuperadmin, role } = useUser()
const { user, token, loading: userLoading, isSuperadmin, isModerator, role } = useUser()
const [servers, setServers] = useState([])
const [displaySettings, setDisplaySettings] = useState({})
const [loading, setLoading] = useState(true)
@@ -255,9 +255,13 @@ export default function Dashboard({ onLogout }) {
>
<ServerCard
server={server}
onClick={() => navigate('/server/' + server.id)}
onClick={isGuest ? undefined : () => navigate('/server/' + server.id)}
isAuthenticated={isAuthenticated}
isGuest={isGuest}
displaySettings={displaySettings[server.id]}
isModerator={isModerator}
token={token}
onServerAction={fetchServers}
/>
</div>
))}

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave, getAutoShutdownSettings, setAutoShutdownSettings, getDisplaySettings, setDisplaySettings } from '../api'
import { useUser } from '../context/UserContext'
import MetricsChart from '../components/MetricsChart'
import ConfirmModal from '../components/ConfirmModal'
import FactorioWorldManager from '../components/FactorioWorldManager'
import PalworldConfigEditor from '../components/PalworldConfigEditor'
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
@@ -42,6 +43,9 @@ export default function ServerDetail() {
// Auto-shutdown state
const [autoShutdown, setAutoShutdown] = useState({ enabled: false, timeoutMinutes: 15, emptySinceMinutes: null })
// Confirmation modal state
const [confirmAction, setConfirmAction] = useState(null)
const [autoShutdownLoading, setAutoShutdownLoading] = useState(false)
// Display settings state (superadmin only)
@@ -139,6 +143,21 @@ export default function ServerDetail() {
}
}
const handleActionWithConfirm = (action) => {
if (action === 'start') {
handleAction(action)
} else {
setConfirmAction(action)
}
}
const handleConfirmAction = () => {
if (confirmAction) {
handleAction(confirmAction)
setConfirmAction(null)
}
}
const handleRcon = async (e) => {
e.preventDefault()
if (!rconCommand.trim()) return
@@ -443,14 +462,14 @@ const formatUptime = (seconds) => {
{(server.status === 'online' || (server.running && server.status !== 'starting' && server.status !== 'stopping')) ? (
<>
<button
onClick={() => handleAction('stop')}
onClick={() => handleActionWithConfirm('stop')}
disabled={server.status === 'stopping' || server.status === 'starting'}
className="btn btn-destructive"
>
{server.status === 'stopping' ? 'Stoppt...' : 'Server stoppen'}
</button>
<button
onClick={() => handleAction('restart')}
onClick={() => handleActionWithConfirm('restart')}
disabled={server.status === 'stopping' || server.status === 'starting'}
className="btn btn-secondary"
>
@@ -459,7 +478,7 @@ const formatUptime = (seconds) => {
</>
) : (
<button
onClick={() => handleAction('start')}
onClick={() => handleActionWithConfirm('start')}
disabled={server.status === 'stopping' || server.status === 'starting'}
className="btn btn-primary"
>
@@ -776,6 +795,21 @@ const formatUptime = (seconds) => {
</div>
)}
</main>
{/* Confirmation Modal */}
{confirmAction && (
<ConfirmModal
title={confirmAction === 'stop' ? 'Server stoppen?' : 'Server neustarten?'}
message={confirmAction === 'stop'
? `Bist du sicher, dass du ${server.name} stoppen möchtest?`
: `Bist du sicher, dass du ${server.name} neustarten möchtest?`
}
confirmText={confirmAction === 'stop' ? 'Stoppen' : 'Neustarten'}
variant={confirmAction === 'stop' ? 'danger' : 'primary'}
onConfirm={handleConfirmAction}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
)
}