Show Space Engineers build ID and last update time in detail view
All checks were successful
Deploy GSM / deploy (push) Successful in 23s
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:
@@ -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();
|
||||
|
||||
75
gsm-backend/services/serverVersions.js
Normal file
75
gsm-backend/services/serverVersions.js
Normal 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;
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user