From bb48f75b5d2b52cd384289c448ecf90ffbd4575f Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Sun, 10 May 2026 20:02:40 +0200 Subject: [PATCH] Show Space Engineers build ID and last update time in detail view Reads the SteamCMD appmanifest_298740.acf via SSH and exposes buildid + LastUpdated through a new /api/servers/:id/version endpoint. The overview tab now renders a "Server Version" card and surfaces a pending update hint when TargetBuildID differs from the installed build. Co-Authored-By: Claude Opus 4.7 (1M context) --- gsm-backend/routes/servers.js | 27 +++++++++ gsm-backend/services/serverVersions.js | 75 +++++++++++++++++++++++++ gsm-frontend/src/api.js | 6 ++ gsm-frontend/src/pages/ServerDetail.jsx | 54 +++++++++++++++++- 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 gsm-backend/services/serverVersions.js diff --git a/gsm-backend/routes/servers.js b/gsm-backend/routes/servers.js index b9dcd2f..c7bec2d 100644 --- a/gsm-backend/routes/servers.js +++ b/gsm-backend/routes/servers.js @@ -6,6 +6,7 @@ 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, readHytaleConfig, writeHytaleConfig } from '../services/ssh.js'; import { supportsUpdate, checkForUpdate, performUpdate } from '../services/serverUpdates.js'; +import { supportsVersionInfo, getVersionInfo } from '../services/serverVersions.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'; @@ -622,6 +623,32 @@ router.get('/:id', authenticateToken, rejectGuest, async (req, res) => { } }); +// Get server version info (build id + last update timestamp) +router.get('/:id/version', 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' }); + + if (!supportsVersionInfo(server.type)) { + return res.json({ supported: false }); + } + + if (isHostFailed(server.host, server.sshUser)) { + return res.json({ supported: true, error: 'Host unreachable' }); + } + + try { + const info = await getVersionInfo(server); + if (!info) { + return res.json({ supported: true, error: 'Version konnte nicht ermittelt werden' }); + } + res.json({ supported: true, ...info }); + } catch (err) { + console.error(`[Version] Failed for ${server.id}:`, err.message); + res.status(500).json({ supported: true, error: err.message }); + } +}); + // Get metrics history from Prometheus (guests not allowed) router.get('/:id/metrics/history', authenticateToken, rejectGuest, async (req, res) => { const config = loadConfig(); diff --git a/gsm-backend/services/serverVersions.js b/gsm-backend/services/serverVersions.js new file mode 100644 index 0000000..205c76f --- /dev/null +++ b/gsm-backend/services/serverVersions.js @@ -0,0 +1,75 @@ +import { NodeSSH } from "node-ssh"; + +const SSH_TIMEOUT = 15000; +const sshConnections = new Map(); + +async function getConnection(host, username = "root") { + const key = username + "@" + host; + if (sshConnections.has(key)) { + const conn = sshConnections.get(key); + if (conn.isConnected()) return conn; + sshConnections.delete(key); + } + const ssh = new NodeSSH(); + await ssh.connect({ + host, + username, + privateKeyPath: "/root/.ssh/id_ed25519", + readyTimeout: SSH_TIMEOUT + }); + sshConnections.set(key, ssh); + return ssh; +} + +const SE_APPMANIFEST = "/opt/spaceengineers/appdata/space-engineers/bins/SpaceEngineersDedicated/steamapps/appmanifest_298740.acf"; + +async function getSpaceEngineersVersion(server) { + const ssh = await getConnection(server.host, server.sshUser); + const result = await ssh.execCommand(`cat ${SE_APPMANIFEST} 2>/dev/null`); + const text = result.stdout || ""; + if (!text) return null; + + const buildidMatch = text.match(/"buildid"\s*"([^"]+)"/); + const lastUpdatedMatch = text.match(/"LastUpdated"\s*"([^"]+)"/); + const targetBuildMatch = text.match(/"TargetBuildID"\s*"([^"]+)"/); + + const buildid = buildidMatch ? buildidMatch[1] : null; + const lastUpdatedUnix = lastUpdatedMatch ? parseInt(lastUpdatedMatch[1], 10) : null; + const targetBuild = targetBuildMatch ? targetBuildMatch[1] : null; + + if (!buildid) return null; + + return { + version: buildid, + lastUpdated: lastUpdatedUnix ? new Date(lastUpdatedUnix * 1000).toISOString() : null, + updatePending: targetBuild && targetBuild !== "0" && targetBuild !== buildid ? targetBuild : null, + source: "steam-appmanifest" + }; +} + +const versionHandlers = { + spaceengineers: getSpaceEngineersVersion +}; + +const versionCache = new Map(); +const CACHE_TTL_MS = 60_000; + +export function supportsVersionInfo(serverType) { + return !!versionHandlers[serverType]; +} + +export async function getVersionInfo(server) { + const handler = versionHandlers[server.type]; + if (!handler) return null; + + const cached = versionCache.get(server.id); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.data; + } + + const data = await handler(server); + if (data) { + versionCache.set(server.id, { data, fetchedAt: Date.now() }); + } + return data; +} diff --git a/gsm-frontend/src/api.js b/gsm-frontend/src/api.js index edff8f3..9b27b4e 100644 --- a/gsm-frontend/src/api.js +++ b/gsm-frontend/src/api.js @@ -165,6 +165,12 @@ export async function getMetricsHistory(token, serverId, range = '1h') { }) } +export async function getServerVersion(token, serverId) { + return fetchAPI(`/servers/${serverId}/version`, { + headers: { Authorization: `Bearer ${token}` }, + }) +} + // Users (admin only) export async function getUsers(token) { return fetchAPI('/auth/users', { diff --git a/gsm-frontend/src/pages/ServerDetail.jsx b/gsm-frontend/src/pages/ServerDetail.jsx index 7161609..bd73aa8 100644 --- a/gsm-frontend/src/pages/ServerDetail.jsx +++ b/gsm-frontend/src/pages/ServerDetail.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave, getAutoShutdownSettings, setAutoShutdownSettings, getDisplaySettings, setDisplaySettings } from '../api' +import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave, getAutoShutdownSettings, setAutoShutdownSettings, getDisplaySettings, setDisplaySettings, getServerVersion } from '../api' import { useUser } from '../context/UserContext' import MetricsChart from '../components/MetricsChart' import ConfirmModal from '../components/ConfirmModal' @@ -57,6 +57,9 @@ export default function ServerDetail() { const [displaySettingsLoading, setDisplaySettingsLoading] = useState(false) const [displaySettingsSaved, setDisplaySettingsSaved] = useState(false) + // Version info (per-server-type) + const [versionInfo, setVersionInfo] = useState(null) + const fetchCurrentSave = async () => { if (token && serverId === 'factorio') { try { @@ -131,6 +134,27 @@ export default function ServerDetail() { return () => clearInterval(interval) }, [token, serverId]) + useEffect(() => { + if (!token || !server) return + const supportedTypes = ['spaceengineers'] + if (!supportedTypes.includes(server.type)) { + setVersionInfo(null) + return + } + let cancelled = false + const load = async () => { + try { + const data = await getServerVersion(token, serverId) + if (!cancelled) setVersionInfo(data) + } catch (err) { + if (!cancelled) setVersionInfo({ supported: true, error: err.message }) + } + } + load() + const interval = setInterval(load, 60_000) + return () => { cancelled = true; clearInterval(interval) } + }, [token, serverId, server?.type]) + const handleAction = async (action) => { // Immediately set status locally const newStatus = action === 'start' ? 'starting' : (action === 'stop' ? 'stopping' : 'starting') @@ -448,6 +472,34 @@ const formatUptime = (seconds) => { )} + {/* Server Version */} + {versionInfo && versionInfo.supported && ( +
+

Server Version

+ {versionInfo.error ? ( +

{versionInfo.error}

+ ) : ( +
+
+
Build ID
+
{versionInfo.version}
+ {versionInfo.updatePending && ( +
Update verfügbar: Build {versionInfo.updatePending}
+ )} +
+
+
Letztes Update
+
+ {versionInfo.lastUpdated + ? new Date(versionInfo.lastUpdated).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) + : 'unbekannt'} +
+
+
+ )} +
+ )} + {/* Players List */} {server.players?.list?.length > 0 && (