Show Space Engineers build ID and last update time in detail view
All checks were successful
Deploy GSM / deploy (push) Successful in 23s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 20:02:40 +02:00
parent 7bc93f3b70
commit bb48f75b5d
4 changed files with 161 additions and 1 deletions

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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', {

View File

@@ -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) => {
)}
</div>
{/* Server Version */}
{versionInfo && versionInfo.supported && (
<div className="card p-4">
<h3 className="text-sm font-medium text-neutral-300 mb-3">Server Version</h3>
{versionInfo.error ? (
<p className="text-sm text-neutral-500">{versionInfo.error}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-xs text-neutral-400">Build ID</div>
<div className="text-lg font-semibold text-white mt-1 font-mono">{versionInfo.version}</div>
{versionInfo.updatePending && (
<div className="text-xs text-amber-400 mt-1">Update verfügbar: Build {versionInfo.updatePending}</div>
)}
</div>
<div>
<div className="text-xs text-neutral-400">Letztes Update</div>
<div className="text-lg font-semibold text-white mt-1">
{versionInfo.lastUpdated
? new Date(versionInfo.lastUpdated).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
: 'unbekannt'}
</div>
</div>
</div>
)}
</div>
)}
{/* Players List */}
{server.players?.list?.length > 0 && (
<div className="card p-4">