Files
GSM/gsm-frontend/src/components/ServerCard.jsx
Alexander Zielonka dfbcc2da82
All checks were successful
Deploy GSM / deploy (push) Successful in 30s
Dashboard: hide unreachable servers by default + keep card height consistent
- Unreachable cards now show a disabled 'Starten' button so moderator cards
  stay the same height as reachable ones
- Dashboard hides unreachable servers by default; a toggle button (bottom
  of header area, only visible when unreachable count > 0) flips the view
  and persists the preference in localStorage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:34:55 +02:00

349 lines
12 KiB
JavaScript

import { useState } from 'react'
import { serverAction } from '../api'
import ConfirmModal from './ConfirmModal'
const serverInfo = {
minecraft: {
address: 'minecraft.zeasy.dev',
logo: '/minecraft.png',
links: [
{ label: 'Biohazard Modpack', url: 'https://www.curseforge.com/minecraft/modpacks/biohazard-project-genesis' }
]
},
factorio: {
hint: 'Serverpasswort: affe',
address: 'factorio.zeasy.dev',
logo: '/factorio.png',
links: [
{ label: 'Steam', url: 'https://store.steampowered.com/app/427520/Factorio/' }
]
},
vrising: {
address: 'Zeasy Software Vampire',
logo: '/vrising.png',
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/' }
]
},
terraria: {
address: 'terraria.zeasy.dev:7777',
logo: '/terraria.png',
links: [
{ label: 'Steam', url: 'https://store.steampowered.com/app/105600/Terraria/' }
]
},
openttd: {
address: 'openttd.zeasy.dev:3979',
logo: '/openttd.png',
links: [
{ label: 'Steam', url: 'https://store.steampowered.com/app/1536610/OpenTTD/' }
]
},
hytale: {
address: 'hytale.zeasy.dev',
logo: '/hytale.png',
links: [
{ label: 'Website', url: 'https://hytale.com/' }
]
},
spaceengineers: {
address: 'space.zeasy.dev:27020',
logo: '/spaceengineers.png',
links: [
{ label: 'Steam', url: 'https://store.steampowered.com/app/244850/Space_Engineers/' }
]
}
}
const getServerInfo = (serverName) => {
const name = serverName.toLowerCase()
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
if (name.includes('terraria')) return serverInfo.terraria
if (name.includes('openttd')) return serverInfo.openttd
if (name.includes('hytale')) return serverInfo.hytale
if (name.includes('space engineers') || name.includes('spaceengineers')) return serverInfo.spaceengineers
return null
}
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 ? {
...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)
if (hours > 24) {
const days = Math.floor(hours / 24)
return days + 'd ' + (hours % 24) + 'h'
}
const minutes = Math.floor((seconds % 3600) / 60)
return hours + 'h ' + minutes + 'm'
}
const cpuPercent = Math.min(server.metrics.cpu, 100)
const memPercent = Math.min(server.metrics.memory, 100)
const getProgressColor = (percent) => {
if (percent > 80) return 'progress-bar-danger'
if (percent > 60) return 'progress-bar-warning'
return 'progress-bar-success'
}
const getStatusBadge = () => {
const status = server.status || (server.running ? 'online' : 'offline')
switch (status) {
case 'online':
return { class: 'badge badge-success', text: 'Online' }
case 'starting':
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={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">
<div className="flex items-center gap-3">
{info && info.logo && <img src={info.logo} alt="" className="h-8 w-8 object-contain" />}
<h3 className="text-lg font-semibold text-white">{server.name}</h3>
</div>
<span className={statusBadge.class}>
{statusBadge.text}
</span>
</div>
{/* Server Address & Links */}
{info && (
<div className="mb-4 flex items-center gap-3 text-sm">
<code className="text-neutral-400 bg-neutral-800 px-2 py-0.5 rounded">
{info.address}
</code>
{info.links.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-blue-400 hover:text-blue-300 hover:underline"
>
{link.label}
</a>
))}
</div>
)}
{/* 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>
)}
{/* Metrics */}
<div className="space-y-3">
{/* CPU */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-400">CPU</span>
<span className="text-white">{server.metrics.cpu.toFixed(1)}%</span>
</div>
<div className="progress">
<div
className={'progress-bar ' + getProgressColor(cpuPercent)}
style={{ width: cpuPercent + '%' }}
/>
</div>
</div>
{/* RAM */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-400">Memory</span>
<span className="text-white">
{server.metrics.memoryUsed?.toFixed(1) || 0} / {server.metrics.memoryTotal?.toFixed(1) || 0} {server.metrics.memoryUnit}
</span>
</div>
<div className="progress">
<div
className={'progress-bar ' + getProgressColor(memPercent)}
style={{ width: memPercent + '%' }}
/>
</div>
</div>
</div>
{/* Footer Stats */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-neutral-800 text-sm">
<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">
Laufzeit: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
</div>
)}
</div>
{/* Players List */}
{server.players?.list?.length > 0 && (
<div className="mt-3 pt-3 border-t border-neutral-800">
<div className="flex flex-wrap gap-1.5">
{server.players.list.map((player, i) => (
<span key={i} className="badge badge-secondary">
{player}
</span>
))}
</div>
</div>
)}
{/* Server Controls - only for moderators */}
{isModerator && (
<div className="mt-3 pt-3 border-t border-neutral-800">
<div className="flex flex-wrap gap-2">
{isUnreachable ? (
<button
disabled
title="Host nicht erreichbar"
className="btn btn-primary btn-sm"
>
Starten
</button>
) : (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>
)
}