diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2c41e8e..b140acf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,12 +3,8 @@ "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:*)" ] } 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/gsm-backend/services/rcon.js b/gsm-backend/services/rcon.js new file mode 100644 index 0000000..c86b886 --- /dev/null +++ b/gsm-backend/services/rcon.js @@ -0,0 +1,159 @@ +import { Rcon } from 'rcon-client'; + +const rconConnections = new Map(); +const playerCache = new Map(); +const CACHE_TTL = 30000; // 30 seconds + +async function getConnection(server) { + const key = `${server.host}:${server.rconPort}`; + + if (rconConnections.has(key)) { + const conn = rconConnections.get(key); + if (conn.authenticated) { + return conn; + } + rconConnections.delete(key); + } + + const rcon = await Rcon.connect({ + host: server.host, + port: server.rconPort, + password: server.rconPassword, + timeout: 5000 + }); + + rcon.on('error', (err) => { + console.error(`RCON error for ${key}:`, err.message); + rconConnections.delete(key); + }); + + rcon.on('end', () => { + rconConnections.delete(key); + }); + + // Handle socket errors to prevent crash + if (rcon.socket) { + rcon.socket.on('error', (err) => { + console.error(`RCON socket error for ${key}:`, err.message); + rconConnections.delete(key); + }); + } + + rconConnections.set(key, rcon); + return rcon; +} + +export async function sendRconCommand(server, command) { + if (!server.rconPassword) { + throw new Error('RCON password not configured'); + } + + const rcon = await getConnection(server); + return await rcon.send(command); +} + +export async function getPlayers(server) { + const cacheKey = `${server.id}-count`; + const cached = playerCache.get(cacheKey); + + if (cached && Date.now() - cached.time < CACHE_TTL) { + return cached.data; + } + + try { + let result = { online: 0, max: null }; + + if (server.type === 'minecraft') { + const response = await sendRconCommand(server, 'list'); + const match = response.match(/There are (\d+) of a max of (\d+) players online/); + if (match) { + result = { online: parseInt(match[1]), max: parseInt(match[2]) }; + } + } else if (server.type === 'factorio') { + const response = await sendRconCommand(server, '/players online count'); + const match = response.match(/(\d+)/); + if (match) { + result = { online: parseInt(match[1]), max: null }; + } + } else if (server.type === 'zomboid') { + const response = await sendRconCommand(server, 'players'); + // Format: "Players connected (X):" or "Players connected (X): Player1, Player2" + const match = response.match(/Players connected \((\d+)\)/); + if (match) { + result = { online: parseInt(match[1]), max: null }; + } + } else if (server.type === 'vrising') { + const response = await sendRconCommand(server, 'listusers'); + // Count lines that contain player info + const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers')); + result = { online: lines.length, max: null }; + } + + playerCache.set(cacheKey, { data: result, time: Date.now() }); + return result; + } catch (err) { + console.error(`Failed to get players for ${server.id}:`, err.message); + // Clear cache on error - server might be offline + playerCache.delete(cacheKey); + return { online: 0, max: null }; + } +} + +export async function getPlayerList(server) { + const cacheKey = `${server.id}-list`; + const cached = playerCache.get(cacheKey); + + if (cached && Date.now() - cached.time < CACHE_TTL) { + return cached.data; + } + + try { + let players = []; + + if (server.type === 'minecraft') { + const response = await sendRconCommand(server, 'list'); + // Format: "There are X of a max of Y players online: Player1, Player2, Player3" + const colonIndex = response.indexOf(':'); + if (colonIndex !== -1) { + const playerPart = response.substring(colonIndex + 1).trim(); + if (playerPart) { + players = playerPart.split(',').map(p => p.trim()).filter(p => p); + } + } + } else if (server.type === 'factorio') { + const response = await sendRconCommand(server, '/players online'); + // Format: "Online players (X):\n player1 (online)\n player2 (online)" + const lines = response.split('\n'); + for (const line of lines) { + const match = line.match(/^\s*(\S+)\s*\(online\)/); + if (match) { + players.push(match[1]); + } + } + } else if (server.type === 'zomboid') { + const response = await sendRconCommand(server, 'players'); + // Format: "Players connected (X):" or "Players connected (X): Player1, Player2" + const colonIndex = response.indexOf(':'); + if (colonIndex !== -1) { + const playerPart = response.substring(colonIndex + 1).trim(); + if (playerPart) { + players = playerPart.split(',').map(p => p.trim()).filter(p => p); + } + } + } else if (server.type === 'vrising') { + const response = await sendRconCommand(server, 'listusers'); + // 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); + } + + const result = { players }; + playerCache.set(cacheKey, { data: result, time: Date.now() }); + return result; + } catch (err) { + console.error(`Failed to get player list for ${server.id}:`, err.message); + // Clear cache on error - server might be offline + playerCache.delete(cacheKey); + return { players: [] }; + } +} diff --git a/gsm-backend/services/ssh.js b/gsm-backend/services/ssh.js new file mode 100644 index 0000000..8edbe96 --- /dev/null +++ b/gsm-backend/services/ssh.js @@ -0,0 +1,286 @@ +import { NodeSSH } from 'node-ssh'; +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const sshConnections = new Map(); + +function loadConfig() { + return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8')); +} + +async function getConnection(host, username = "root") { + if (sshConnections.has(username + "@" + host)) { + const conn = sshConnections.get(username + "@" + host); + if (conn.isConnected()) return conn; + } + + const ssh = new NodeSSH(); + await ssh.connect({ + host, + username: username, + privateKeyPath: '/root/.ssh/id_ed25519' + }); + + sshConnections.set(username + "@" + host, ssh); + return ssh; +} + +// Returns: "online", "starting", "stopping", "offline" +export async function getServerStatus(server) { + try { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`); + const status = result.stdout.trim(); + if (status === 'running') return 'online'; + if (status === 'restarting' || status === 'created') return 'starting'; + if (status === 'removing' || status === 'paused') return 'stopping'; + return 'offline'; + } else if (server.runtime === 'systemd') { + const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`); + const status = result.stdout.trim(); + if (status === 'active') return 'online'; + if (status === 'activating' || status === 'reloading') return 'starting'; + if (status === 'deactivating') return 'stopping'; + return 'offline'; + } else { + const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); + if (result.code === 0) { + const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`); + const uptime = parseInt(uptimeResult.stdout.trim()) || 999; + if (uptime < 60) return 'starting'; + return 'online'; + } + return 'offline'; + } + } catch (err) { + console.error(`Failed to get status for ${server.id}:`, err.message); + return 'offline'; + } +} + +export async function startServer(server, options = {}) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + // Factorio with specific save + if (server.type === 'factorio' && options.save) { + const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save; + // Stop and remove existing container + await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`); + await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`); + // Start with specific save + await ssh.execCommand(` + docker run -d \ + --name ${server.containerName} \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v /srv/docker/factorio/data:/factorio \ + -e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \ + -e TZ=Europe/Berlin \ + -e PUID=845 \ + -e PGID=845 \ + --restart=unless-stopped \ + factoriotools/factorio + `); + } else { + await ssh.execCommand(`docker start ${server.containerName}`); + } + } else if (server.runtime === 'systemd') { + await ssh.execCommand(`systemctl start ${server.serviceName}`); + } else { + await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`); + await new Promise(resolve => setTimeout(resolve, 1000)); + await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`); + } +} + +export async function stopServer(server) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + await ssh.execCommand(`docker stop ${server.containerName}`); + } else if (server.runtime === 'systemd') { + await ssh.execCommand(`systemctl stop ${server.serviceName}`); + } else { + // Different stop commands per server type + const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop'; + await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`); + + for (let i = 0; i < 30; i++) { + await new Promise(resolve => setTimeout(resolve, 2000)); + const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); + if (check.code !== 0) { + console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`); + return; + } + } + + console.log(`Force killing ${server.id} after timeout`); + await ssh.execCommand(`screen -S ${server.screenName} -X quit`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } +} + +export async function restartServer(server) { + await stopServer(server); + await new Promise(resolve => setTimeout(resolve, 2000)); + await startServer(server); +} + +export async function getConsoleLog(server, lines = 50) { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === 'docker') { + 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`); + return result.stdout || result.stderr; + } else if (server.logFile) { + const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`); + return result.stdout; + } else { + const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`); + return result.stdout; + } +} + +export async function getProcessUptime(server) { + try { + const ssh = await getConnection(server.host, server.sshUser); + + if (server.runtime === "docker") { + const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`); + if (result.stdout.trim()) { + const startTime = new Date(result.stdout.trim()); + if (!isNaN(startTime.getTime())) { + return Math.floor((Date.now() - startTime.getTime()) / 1000); + } + } + } else if (server.runtime === "systemd") { + const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`); + const startEpoch = parseInt(result.stdout.trim()); + if (!isNaN(startEpoch)) { + return Math.floor(Date.now() / 1000) - startEpoch; + } + } else { + const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`); + const uptime = parseInt(result.stdout.trim()); + if (!isNaN(uptime)) return uptime; + } + return 0; + } catch (err) { + console.error(`Failed to get uptime for ${server.id}:`, err.message); + return 0; + } +} + +// ============ FACTORIO-SPECIFIC FUNCTIONS ============ + +export async function listFactorioSaves(server) { + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null'); + + if (result.code !== 0 || !result.stdout.trim()) { + return []; + } + + const saves = []; + const lines = result.stdout.trim().split('\n'); + + for (const line of lines) { + const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); + if (match) { + const filename = match[7].split('/').pop(); + // Skip autosaves + if (filename.startsWith('_autosave')) continue; + + const size = match[5]; + const modified = match[6]; + + saves.push({ + name: filename.replace('.zip', ''), + filename, + size, + modified + }); + } + } + + return saves; +} + +export async function deleteFactorioSave(server, saveName) { + const ssh = await getConnection(server.host, server.sshUser); + const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`; + + // Security check - prevent path traversal + if (filename.includes('/') || filename.includes('..')) { + throw new Error('Invalid save name'); + } + + const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`); + if (result.code !== 0) { + throw new Error(result.stderr || 'Failed to delete save'); + } +} + +export async function createFactorioWorld(server, saveName, settings) { + const ssh = await getConnection(server.host, server.sshUser); + + // Security check + if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) { + throw new Error("Invalid save name - no spaces or special characters allowed"); + } + + // Write map-gen-settings.json + const settingsJson = JSON.stringify(settings, null, 2); + await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF' +${settingsJson} +SETTINGSEOF`); + + // Create new world using --create flag (correct method) + console.log(`Creating new Factorio world: ${saveName}`); + const createResult = await ssh.execCommand(` + docker run --rm \ + -v /srv/docker/factorio/data:/factorio \ + factoriotools/factorio \ + /opt/factorio/bin/x64/factorio \ + --create /factorio/saves/${saveName}.zip \ + --map-gen-settings /factorio/config/map-gen-settings.json + `, { execOptions: { timeout: 120000 } }); + + console.log("World creation output:", createResult.stdout, createResult.stderr); + + // Verify save was created + const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`); + if (checkResult.code !== 0) { + throw new Error("World creation failed - save file not found. Output: " + createResult.stderr); + } + + return true; +} + +export async function getFactorioCurrentSave(server) { + const ssh = await getConnection(server.host, server.sshUser); + + // Check if container has SAVE_NAME env set + const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`); + if (envResult.stdout.trim()) { + const saveName = envResult.stdout.trim().replace("SAVE_NAME=", ""); + return { save: saveName, source: "configured" }; + } + + // Otherwise find newest save file + const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1"); + if (result.stdout.trim()) { + const filename = result.stdout.trim().split("/").pop().replace(".zip", ""); + return { save: filename, source: "newest" }; + } + + return { save: null, source: null }; +} diff --git a/gsm-frontend/public/zomboid.png b/gsm-frontend/public/zomboid.png new file mode 100644 index 0000000..67d2e8a Binary files /dev/null and b/gsm-frontend/public/zomboid.png differ diff --git a/gsm-frontend/src/components/ServerCard.jsx b/gsm-frontend/src/components/ServerCard.jsx index db8d6ef..dd8e665 100644 --- a/gsm-frontend/src/components/ServerCard.jsx +++ b/gsm-frontend/src/components/ServerCard.jsx @@ -20,6 +20,14 @@ const serverInfo = { links: [ { label: 'Steam', url: 'https://store.steampowered.com/app/1604030/V_Rising/' } ] + }, + zomboid: { + hint: 'Version 42.13', + address: 'pz.zeasy.dev:16261', + logo: '/zomboid.png', + links: [ + { label: 'Steam', url: 'https://store.steampowered.com/app/108600/Project_Zomboid/' } + ] } } @@ -28,6 +36,7 @@ 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 return null } @@ -127,6 +136,13 @@ export default function ServerCard({ server, onClick, isAuthenticated }) { )} + {/* Project Zomboid notice - only for authenticated users */} + {isAuthenticated && server.type === 'zomboid' && ( +
+ Version 42.13 +
+ )} + {/* Metrics */}
{/* CPU */} diff --git a/gsm-frontend/src/pages/ServerDetail.jsx b/gsm-frontend/src/pages/ServerDetail.jsx index 785644c..ce5709f 100644 --- a/gsm-frontend/src/pages/ServerDetail.jsx +++ b/gsm-frontend/src/pages/ServerDetail.jsx @@ -10,6 +10,7 @@ const getServerLogo = (serverName) => { 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" return null } export default function ServerDetail() { @@ -28,6 +29,7 @@ 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) @@ -62,7 +64,10 @@ export default function ServerDetail() { useEffect(() => { fetchServer() fetchCurrentSave() - const interval = setInterval(fetchServer, 10000) + const interval = setInterval(() => { + fetchServer() + fetchCurrentSave() + }, 10000) return () => clearInterval(interval) }, [token, serverId]) @@ -97,8 +102,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 } @@ -299,6 +305,15 @@ const formatUptime = (seconds) => {
CPU Cores
{server.metrics.cpuCores}
+ {server.type === 'factorio' && currentSave?.save && ( +
+
{server.running ? 'Current World' : 'Next World'}
+
{currentSave.save}
+ {!server.running && currentSave.source === 'newest' && ( +
newest save
+ )} +
+ )} {/* Players List */} @@ -318,14 +333,6 @@ const formatUptime = (seconds) => {

Server Controls

- {/* Factorio: Show which save will be loaded */} - {server.type === 'factorio' && !server.running && currentSave?.save && ( -
- Will load: {currentSave.save} - {currentSave.source === 'newest' && (newest save)} -
- )} -
{(server.status === 'online' || (server.running && server.status !== 'starting' && server.status !== 'stopping')) ? ( <> @@ -369,7 +376,14 @@ const formatUptime = (seconds) => {
{/* Logs */}
- Server Logs (last 20 lines) +
+ Server Logs (last 50 lines) + {logsUpdated && ( + + Aktualisiert: {logsUpdated.toLocaleTimeString('de-DE')} + + )} +