Add world management for Space Engineers (list/switch/create/clone/rename/delete)
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
Mirrors Factorio's world feature for the SE server. The SE docker image always loads the flat config/World dir and its entrypoint rewrites <LoadWorld> on every start, so repointing the cfg is useless. Instead inactive worlds live in a sibling config/Worlds library (not mounted) and switching is an instant mv-swap; the active world name is tracked in .gsm-worldname and files are chowned to 1000:1000. New worlds are created from the image's built-in CustomWorlds templates or by cloning the active world. Switch/clone require a stopped server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { dirname, join } from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { EmbedBuilder } from 'discord.js';
|
import { EmbedBuilder } from 'discord.js';
|
||||||
import { authenticateToken, optionalAuth, requireRole, rejectGuest } from '../middleware/auth.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 { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig, readHytaleConfig, writeHytaleConfig, listSpaceEngineersWorlds, listSpaceEngineersTemplates, switchSpaceEngineersWorld, createSpaceEngineersWorldFromTemplate, cloneSpaceEngineersWorld, deleteSpaceEngineersWorld, renameSpaceEngineersWorld } from '../services/ssh.js';
|
||||||
import { supportsUpdate, checkForUpdate, performUpdate } from '../services/serverUpdates.js';
|
import { supportsUpdate, checkForUpdate, performUpdate } from '../services/serverUpdates.js';
|
||||||
import { supportsVersionInfo, getVersionInfo, invalidateVersionCache } from '../services/serverVersions.js';
|
import { supportsVersionInfo, getVersionInfo, invalidateVersionCache } from '../services/serverVersions.js';
|
||||||
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
|
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
|
||||||
@@ -303,6 +303,111 @@ router.get("/factorio/current-save", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ SPACE ENGINEERS WORLD ROUTES ============
|
||||||
|
|
||||||
|
function getSEServer() {
|
||||||
|
return loadConfig().servers.find(s => s.type === "spaceengineers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// SE: List worlds (active + library)
|
||||||
|
router.get("/spaceengineers/worlds", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const server = getSEServer();
|
||||||
|
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||||
|
const data = await listSpaceEngineersWorlds(server);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SE: List built-in templates
|
||||||
|
router.get("/spaceengineers/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const server = getSEServer();
|
||||||
|
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||||
|
const templates = await listSpaceEngineersTemplates(server);
|
||||||
|
res.json({ templates });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SE: Create new world from a template
|
||||||
|
router.post("/spaceengineers/worlds", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, template } = req.body || {};
|
||||||
|
if (!name || !template) return res.status(400).json({ error: "name and template required", message: "name und template erforderlich" });
|
||||||
|
const server = getSEServer();
|
||||||
|
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||||
|
const result = await createSpaceEngineersWorldFromTemplate(server, name, template);
|
||||||
|
logActivity(req.user.id, req.user.username, 'se_world_create', server.id, `${result.name} (Vorlage: ${template})`, req.user.discordId, req.user.avatar);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SE: Clone the active world
|
||||||
|
router.post("/spaceengineers/worlds/clone", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body || {};
|
||||||
|
if (!name) return res.status(400).json({ error: "name required", message: "name erforderlich" });
|
||||||
|
const server = getSEServer();
|
||||||
|
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||||
|
const status = await getServerStatus(server);
|
||||||
|
if (status === "online") return res.status(409).json({ error: "Server muss gestoppt sein zum Klonen", message: "Server muss gestoppt sein zum Klonen" });
|
||||||
|
const result = await cloneSpaceEngineersWorld(server, name);
|
||||||
|
logActivity(req.user.id, req.user.username, 'se_world_clone', server.id, result.name, req.user.discordId, req.user.avatar);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SE: Switch the active world (server must be stopped)
|
||||||
|
router.post("/spaceengineers/worlds/:name/activate", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const server = getSEServer();
|
||||||
|
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||||
|
const status = await getServerStatus(server);
|
||||||
|
if (status === "online") return res.status(409).json({ error: "Server muss gestoppt sein zum Wechseln der Welt", message: "Server muss gestoppt sein zum Wechseln der Welt" });
|
||||||
|
const result = await switchSpaceEngineersWorld(server, req.params.name);
|
||||||
|
logActivity(req.user.id, req.user.username, 'se_world_activate', server.id, result.active, req.user.discordId, req.user.avatar);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SE: Rename a world
|
||||||
|
router.patch("/spaceengineers/worlds/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { newName } = req.body || {};
|
||||||
|
if (!newName) return res.status(400).json({ error: "newName required", message: "newName erforderlich" });
|
||||||
|
const server = getSEServer();
|
||||||
|
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||||
|
const result = await renameSpaceEngineersWorld(server, req.params.name, newName);
|
||||||
|
logActivity(req.user.id, req.user.username, 'se_world_rename', server.id, `${req.params.name} -> ${result.name}`, req.user.discordId, req.user.avatar);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SE: Delete a world (not the active one)
|
||||||
|
router.delete("/spaceengineers/worlds/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const server = getSEServer();
|
||||||
|
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||||
|
const result = await deleteSpaceEngineersWorld(server, req.params.name);
|
||||||
|
logActivity(req.user.id, req.user.username, 'se_world_delete', server.id, result.name, req.user.discordId, req.user.avatar);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============ ZOMBOID CONFIG ROUTES ============
|
// ============ ZOMBOID CONFIG ROUTES ============
|
||||||
|
|
||||||
// Zomboid: List config files
|
// Zomboid: List config files
|
||||||
|
|||||||
@@ -782,6 +782,257 @@ export async function getSpaceEngineersPlayers(server) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ SPACE ENGINEERS WORLD MANAGEMENT ============
|
||||||
|
//
|
||||||
|
// Unlike Factorio (which keeps many named saves in one folder and picks one via
|
||||||
|
// an env var on `docker run`), the SE image always loads exactly ONE flat world
|
||||||
|
// directory: host `<workDir>/appdata/space-engineers/config/World` -> container
|
||||||
|
// `/appdata/space-engineers/World`. The image entrypoint even rewrites
|
||||||
|
// <LoadWorld> back to that fixed path on every start, so repointing the cfg is
|
||||||
|
// pointless and the world MUST be flat (Sandbox.sbc directly inside) or the
|
||||||
|
// container exits (code 130).
|
||||||
|
//
|
||||||
|
// We therefore keep a library of inactive worlds in a sibling folder `Worlds`
|
||||||
|
// (plural, NOT mounted into the container) and "switch" by moving directories.
|
||||||
|
// `Worlds` lives on the same filesystem as `World`, so the move is an instant
|
||||||
|
// rename even for multi-GB worlds. The active world's display name is tracked in
|
||||||
|
// a marker file `.gsm-worldname` inside `World`. All files are owned by UID/GID
|
||||||
|
// 1000 (the in-container wine user), so we chown after every mutation.
|
||||||
|
|
||||||
|
function sePaths(server) {
|
||||||
|
const base = `${server.workDir}/appdata/space-engineers`;
|
||||||
|
return {
|
||||||
|
base,
|
||||||
|
config: `${base}/config`,
|
||||||
|
active: `${base}/config/World`,
|
||||||
|
library: `${base}/config/Worlds`,
|
||||||
|
templates: `${base}/bins/SpaceEngineersDedicated/Content/CustomWorlds`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// World/template names map to directory names. Allow letters, numbers, spaces,
|
||||||
|
// underscore and hyphen only — this both keeps the file system tidy and makes it
|
||||||
|
// safe to interpolate the (double-quoted) name into shell commands.
|
||||||
|
function validateSEWorldName(name) {
|
||||||
|
if (!name || typeof name !== 'string') throw new Error('Weltname fehlt');
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed.includes('..')) throw new Error('Ungültiger Weltname');
|
||||||
|
if (!/^[A-Za-z0-9 _-]{1,48}$/.test(trimmed)) {
|
||||||
|
throw new Error('Ungültiger Weltname (erlaubt: Buchstaben, Zahlen, Leerzeichen, _ und -, max. 48 Zeichen)');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSEName(raw) {
|
||||||
|
const s = (raw || '').trim().replace(/[^A-Za-z0-9 _-]/g, '').slice(0, 48).trim();
|
||||||
|
return s || 'World';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the active world's display name. Prefer the marker file; otherwise
|
||||||
|
// derive it from the cfg <WorldName> (falling back to <SessionName>) and persist
|
||||||
|
// a marker so subsequent switches have a stable name to archive under.
|
||||||
|
async function ensureSEActiveMarker(server, ssh) {
|
||||||
|
const p = sePaths(server);
|
||||||
|
const marker = await ssh.execCommand(`cat "${p.active}/.gsm-worldname" 2>/dev/null`);
|
||||||
|
if (marker.stdout.trim()) return marker.stdout.trim();
|
||||||
|
|
||||||
|
let derived = '';
|
||||||
|
const cfg = await ssh.execCommand(
|
||||||
|
`grep -m1 -oE "<WorldName>[^<]*" "${p.config}/SpaceEngineers-Dedicated.cfg" 2>/dev/null | sed -E "s/<WorldName>//"`
|
||||||
|
);
|
||||||
|
derived = cfg.stdout.trim();
|
||||||
|
if (!derived) {
|
||||||
|
const sess = await ssh.execCommand(
|
||||||
|
`grep -m1 -oE "<SessionName>[^<]*" "${p.active}/Sandbox_config.sbc" 2>/dev/null | sed -E "s/<SessionName>//"`
|
||||||
|
);
|
||||||
|
derived = sess.stdout.trim();
|
||||||
|
}
|
||||||
|
derived = sanitizeSEName(derived);
|
||||||
|
// best effort: persist marker so the active name is stable from now on
|
||||||
|
await ssh.execCommand(
|
||||||
|
`echo "${derived}" > "${p.active}/.gsm-worldname" && chown 1000:1000 "${p.active}/.gsm-worldname" 2>/dev/null || true`
|
||||||
|
);
|
||||||
|
return derived;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List the active world + every world in the library, with size and mtime.
|
||||||
|
export async function listSpaceEngineersWorlds(server) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const p = sePaths(server);
|
||||||
|
await ssh.execCommand(`mkdir -p "${p.library}"`);
|
||||||
|
await ensureSEActiveMarker(server, ssh);
|
||||||
|
|
||||||
|
// One pass emits "active|name|hasSandbox|size|mtime" per world. Names are
|
||||||
|
// restricted to a charset without "|" so the delimiter is unambiguous.
|
||||||
|
const script =
|
||||||
|
`ACTIVE="${p.active}"; LIB="${p.library}"; ` +
|
||||||
|
`emit() { d="$1"; act="$2"; nm="$3"; has=0; [ -f "$d/Sandbox.sbc" ] && has=1; ` +
|
||||||
|
`size=$(du -sh "$d" 2>/dev/null | cut -f1); ` +
|
||||||
|
`mt=$(stat -c %Y "$d/Sandbox.sbc" 2>/dev/null || echo 0); ` +
|
||||||
|
`echo "$act|$nm|$has|$size|$mt"; }; ` +
|
||||||
|
`an=$(cat "$ACTIVE/.gsm-worldname" 2>/dev/null); [ -z "$an" ] && an="World"; ` +
|
||||||
|
`emit "$ACTIVE" 1 "$an"; ` +
|
||||||
|
`for d in "$LIB"/*/ ; do [ -d "$d" ] || continue; emit "${'${d%/}'}" 0 "$(basename "$d")"; done`;
|
||||||
|
const r = await ssh.execCommand(script);
|
||||||
|
|
||||||
|
const worlds = [];
|
||||||
|
for (const line of r.stdout.split('\n').map(l => l.trim()).filter(Boolean)) {
|
||||||
|
const parts = line.split('|');
|
||||||
|
if (parts.length < 5) continue;
|
||||||
|
const [act, name, has, size, mt] = parts;
|
||||||
|
worlds.push({
|
||||||
|
name,
|
||||||
|
active: act === '1',
|
||||||
|
valid: has === '1',
|
||||||
|
size: size || '-',
|
||||||
|
modified: mt && mt !== '0' ? Number(mt) * 1000 : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const active = worlds.find(w => w.active)?.name || null;
|
||||||
|
return { active, worlds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in premade worlds shipped with the dedicated server, usable as templates
|
||||||
|
// for a fresh world. Some templates store the world under a PC/ subfolder.
|
||||||
|
export async function listSpaceEngineersTemplates(server) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const p = sePaths(server);
|
||||||
|
const script =
|
||||||
|
`for d in "${p.templates}"/*/ ; do [ -d "$d" ] || continue; nm="$(basename "$d")"; ` +
|
||||||
|
`if [ -f "$d/Sandbox.sbc" ] || [ -f "$d/PC/Sandbox.sbc" ]; then echo "$nm"; fi; done`;
|
||||||
|
const r = await ssh.execCommand(script);
|
||||||
|
return r.stdout.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap the active world. The server MUST be stopped (checked by the route).
|
||||||
|
export async function switchSpaceEngineersWorld(server, targetName) {
|
||||||
|
const name = validateSEWorldName(targetName);
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const p = sePaths(server);
|
||||||
|
|
||||||
|
const current = await ensureSEActiveMarker(server, ssh);
|
||||||
|
if (name === current) return { message: 'Welt ist bereits aktiv', active: name };
|
||||||
|
|
||||||
|
const tgt = await ssh.execCommand(`test -f "${p.library}/${name}/Sandbox.sbc" && echo OK`);
|
||||||
|
if (!tgt.stdout.includes('OK')) {
|
||||||
|
throw new Error(`Welt "${name}" nicht in der Bibliothek gefunden (oder keine Sandbox.sbc)`);
|
||||||
|
}
|
||||||
|
const coll = await ssh.execCommand(`test -e "${p.library}/${current}" && echo EXISTS`);
|
||||||
|
if (coll.stdout.includes('EXISTS')) {
|
||||||
|
throw new Error(`Kann aktive Welt nicht archivieren: "Worlds/${current}" existiert bereits`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd =
|
||||||
|
`set -e; mkdir -p "${p.library}"; ` +
|
||||||
|
`mv "${p.active}" "${p.library}/${current}"; ` +
|
||||||
|
`mv "${p.library}/${name}" "${p.active}"; ` +
|
||||||
|
`echo "${name}" > "${p.active}/.gsm-worldname"; ` +
|
||||||
|
`chown -R 1000:1000 "${p.active}" "${p.library}/${current}";`;
|
||||||
|
const r = await ssh.execCommand(cmd);
|
||||||
|
if (r.code !== 0) throw new Error(r.stderr || 'Weltwechsel fehlgeschlagen');
|
||||||
|
|
||||||
|
const ver = await ssh.execCommand(`test -f "${p.active}/Sandbox.sbc" && echo OK`);
|
||||||
|
if (!ver.stdout.includes('OK')) throw new Error('Weltwechsel-Verifikation fehlgeschlagen (Sandbox.sbc fehlt)');
|
||||||
|
return { message: 'Welt gewechselt', active: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new library world from a built-in template.
|
||||||
|
export async function createSpaceEngineersWorldFromTemplate(server, newName, templateName) {
|
||||||
|
const name = validateSEWorldName(newName);
|
||||||
|
const tmpl = validateSEWorldName(templateName);
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const p = sePaths(server);
|
||||||
|
|
||||||
|
const active = await ensureSEActiveMarker(server, ssh);
|
||||||
|
if (name === active) throw new Error('Eine Welt mit diesem Namen ist gerade aktiv');
|
||||||
|
const exists = await ssh.execCommand(`test -e "${p.library}/${name}" && echo EXISTS`);
|
||||||
|
if (exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" existiert bereits`);
|
||||||
|
|
||||||
|
const src = await ssh.execCommand(
|
||||||
|
`if [ -f "${p.templates}/${tmpl}/Sandbox.sbc" ]; then echo "${p.templates}/${tmpl}"; ` +
|
||||||
|
`elif [ -f "${p.templates}/${tmpl}/PC/Sandbox.sbc" ]; then echo "${p.templates}/${tmpl}/PC"; fi`
|
||||||
|
);
|
||||||
|
const srcDir = src.stdout.trim();
|
||||||
|
if (!srcDir) throw new Error(`Vorlage "${tmpl}" nicht gefunden oder ohne ladbare Welt`);
|
||||||
|
|
||||||
|
const cmd =
|
||||||
|
`set -e; mkdir -p "${p.library}/${name}"; ` +
|
||||||
|
`cp -a "${srcDir}/." "${p.library}/${name}/"; ` +
|
||||||
|
`echo "${name}" > "${p.library}/${name}/.gsm-worldname"; ` +
|
||||||
|
`chown -R 1000:1000 "${p.library}/${name}";`;
|
||||||
|
const r = await ssh.execCommand(cmd, { execOptions: { timeout: 120000 } });
|
||||||
|
if (r.code !== 0) throw new Error(r.stderr || 'Welterstellung fehlgeschlagen');
|
||||||
|
return { message: 'Welt erstellt', name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate the currently active world into the library. Server MUST be stopped
|
||||||
|
// (checked by the route) so the copy is not torn by an autosave.
|
||||||
|
export async function cloneSpaceEngineersWorld(server, newName) {
|
||||||
|
const name = validateSEWorldName(newName);
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const p = sePaths(server);
|
||||||
|
|
||||||
|
const active = await ensureSEActiveMarker(server, ssh);
|
||||||
|
if (name === active) throw new Error('Klon-Name muss sich von der aktiven Welt unterscheiden');
|
||||||
|
const exists = await ssh.execCommand(`test -e "${p.library}/${name}" && echo EXISTS`);
|
||||||
|
if (exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" existiert bereits`);
|
||||||
|
|
||||||
|
const cmd =
|
||||||
|
`set -e; mkdir -p "${p.library}/${name}"; ` +
|
||||||
|
`cp -a "${p.active}/." "${p.library}/${name}/"; ` +
|
||||||
|
`echo "${name}" > "${p.library}/${name}/.gsm-worldname"; ` +
|
||||||
|
`chown -R 1000:1000 "${p.library}/${name}";`;
|
||||||
|
const r = await ssh.execCommand(cmd, { execOptions: { timeout: 300000 } });
|
||||||
|
if (r.code !== 0) throw new Error(r.stderr || 'Klonen fehlgeschlagen');
|
||||||
|
return { message: 'Welt geklont', name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSpaceEngineersWorld(server, worldName) {
|
||||||
|
const name = validateSEWorldName(worldName);
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const p = sePaths(server);
|
||||||
|
|
||||||
|
const active = await ensureSEActiveMarker(server, ssh);
|
||||||
|
if (name === active) throw new Error('Aktive Welt kann nicht gelöscht werden. Wechsle zuerst zu einer anderen Welt.');
|
||||||
|
const exists = await ssh.execCommand(`test -d "${p.library}/${name}" && echo EXISTS`);
|
||||||
|
if (!exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" nicht gefunden`);
|
||||||
|
|
||||||
|
const r = await ssh.execCommand(`rm -rf "${p.library}/${name}"`);
|
||||||
|
if (r.code !== 0) throw new Error(r.stderr || 'Löschen fehlgeschlagen');
|
||||||
|
return { message: 'Welt gelöscht', name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameSpaceEngineersWorld(server, oldName, newName) {
|
||||||
|
const oldN = validateSEWorldName(oldName);
|
||||||
|
const newN = validateSEWorldName(newName);
|
||||||
|
if (oldN === newN) return { message: 'Keine Änderung', name: newN };
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const p = sePaths(server);
|
||||||
|
|
||||||
|
const active = await ensureSEActiveMarker(server, ssh);
|
||||||
|
if (newN === active) throw new Error('Eine Welt mit dem neuen Namen ist gerade aktiv');
|
||||||
|
const collide = await ssh.execCommand(`test -e "${p.library}/${newN}" && echo EXISTS`);
|
||||||
|
if (collide.stdout.includes('EXISTS')) throw new Error(`Welt "${newN}" existiert bereits`);
|
||||||
|
|
||||||
|
if (oldN === active) {
|
||||||
|
const r = await ssh.execCommand(
|
||||||
|
`echo "${newN}" > "${p.active}/.gsm-worldname" && chown 1000:1000 "${p.active}/.gsm-worldname"`
|
||||||
|
);
|
||||||
|
if (r.code !== 0) throw new Error(r.stderr || 'Umbenennen fehlgeschlagen');
|
||||||
|
return { message: 'Aktive Welt umbenannt', name: newN };
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await ssh.execCommand(`test -d "${p.library}/${oldN}" && echo EXISTS`);
|
||||||
|
if (!exists.stdout.includes('EXISTS')) throw new Error(`Welt "${oldN}" nicht gefunden`);
|
||||||
|
const r = await ssh.execCommand(
|
||||||
|
`set -e; mv "${p.library}/${oldN}" "${p.library}/${newN}"; ` +
|
||||||
|
`echo "${newN}" > "${p.library}/${newN}/.gsm-worldname"; ` +
|
||||||
|
`chown 1000:1000 "${p.library}/${newN}/.gsm-worldname";`
|
||||||
|
);
|
||||||
|
if (r.code !== 0) throw new Error(r.stderr || 'Umbenennen fehlgeschlagen');
|
||||||
|
return { message: 'Welt umbenannt', name: newN };
|
||||||
|
}
|
||||||
|
|
||||||
// ============ HYTALE FUNCTIONS ============
|
// ============ HYTALE FUNCTIONS ============
|
||||||
const HYTALE_CONFIG_PATH = "/opt/hytale/Server/config.json";
|
const HYTALE_CONFIG_PATH = "/opt/hytale/Server/config.json";
|
||||||
const HYTALE_LOGS_PATH = "/opt/hytale/Server/logs";
|
const HYTALE_LOGS_PATH = "/opt/hytale/Server/logs";
|
||||||
|
|||||||
@@ -284,6 +284,57 @@ export async function getFactorioWorldSettings(token, saveName) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Space Engineers World Management
|
||||||
|
export async function getSEWorlds(token) {
|
||||||
|
return fetchAPI('/servers/spaceengineers/worlds', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSETemplates(token) {
|
||||||
|
return fetchAPI('/servers/spaceengineers/templates', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSEWorld(token, name, template) {
|
||||||
|
return fetchAPI('/servers/spaceengineers/worlds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ name, template }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cloneSEWorld(token, name) {
|
||||||
|
return fetchAPI('/servers/spaceengineers/worlds/clone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateSEWorld(token, name) {
|
||||||
|
return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}/activate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameSEWorld(token, name, newName) {
|
||||||
|
return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ newName }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSEWorld(token, name) {
|
||||||
|
return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-Shutdown Settings
|
// Auto-Shutdown Settings
|
||||||
export async function getAutoShutdownSettings(token, serverId) {
|
export async function getAutoShutdownSettings(token, serverId) {
|
||||||
return fetchAPI(`/servers/${serverId}/autoshutdown`, {
|
return fetchAPI(`/servers/${serverId}/autoshutdown`, {
|
||||||
|
|||||||
403
gsm-frontend/src/components/SpaceEngineersWorldManager.jsx
Normal file
403
gsm-frontend/src/components/SpaceEngineersWorldManager.jsx
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
getSEWorlds,
|
||||||
|
getSETemplates,
|
||||||
|
createSEWorld,
|
||||||
|
cloneSEWorld,
|
||||||
|
activateSEWorld,
|
||||||
|
renameSEWorld,
|
||||||
|
deleteSEWorld,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
const NAME_RE = /^[a-zA-Z0-9 _-]{1,48}$/
|
||||||
|
|
||||||
|
export default function SpaceEngineersWorldManager({ server, token, onServerAction }) {
|
||||||
|
const [worlds, setWorlds] = useState([])
|
||||||
|
const [templates, setTemplates] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [actionLoading, setActionLoading] = useState(null)
|
||||||
|
|
||||||
|
// modals
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newTemplate, setNewTemplate] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const [showClone, setShowClone] = useState(false)
|
||||||
|
const [cloneName, setCloneName] = useState('')
|
||||||
|
|
||||||
|
const [renaming, setRenaming] = useState(null) // world being renamed
|
||||||
|
const [renameValue, setRenameValue] = useState('')
|
||||||
|
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
|
||||||
|
// The server must be stopped to swap or clone the active world.
|
||||||
|
const running = server.running || server.status === 'starting' || server.status === 'stopping'
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const [worldsData, templatesData] = await Promise.all([
|
||||||
|
getSEWorlds(token),
|
||||||
|
getSETemplates(token),
|
||||||
|
])
|
||||||
|
// active world first, rest alphabetically
|
||||||
|
const list = (worldsData.worlds || []).slice().sort((a, b) => {
|
||||||
|
if (a.active !== b.active) return a.active ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
setWorlds(list)
|
||||||
|
setTemplates(templatesData.templates || [])
|
||||||
|
if (!newTemplate && templatesData.templates?.length) setNewTemplate(templatesData.templates[0])
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const fmtDate = (ms) => (ms ? new Date(ms).toLocaleString() : '—')
|
||||||
|
|
||||||
|
const handleActivate = async (name) => {
|
||||||
|
try {
|
||||||
|
setActionLoading(name)
|
||||||
|
setError('')
|
||||||
|
await activateSEWorld(token, name)
|
||||||
|
await fetchData()
|
||||||
|
if (onServerAction) onServerAction()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!NAME_RE.test(newName.trim())) {
|
||||||
|
setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!newTemplate) {
|
||||||
|
setError('Bitte eine Vorlage auswählen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
await createSEWorld(token, newName.trim(), newTemplate)
|
||||||
|
setShowCreate(false)
|
||||||
|
setNewName('')
|
||||||
|
await fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClone = async () => {
|
||||||
|
if (!NAME_RE.test(cloneName.trim())) {
|
||||||
|
setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
await cloneSEWorld(token, cloneName.trim())
|
||||||
|
setShowClone(false)
|
||||||
|
setCloneName('')
|
||||||
|
await fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (!NAME_RE.test(renameValue.trim())) {
|
||||||
|
setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
await renameSEWorld(token, renaming.name, renameValue.trim())
|
||||||
|
setRenaming(null)
|
||||||
|
setRenameValue('')
|
||||||
|
await fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (name) => {
|
||||||
|
try {
|
||||||
|
setActionLoading(name)
|
||||||
|
setError('')
|
||||||
|
await deleteSEWorld(token, name)
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
await fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-neutral-400">Lade Welten...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
{running && (
|
||||||
|
<div className="card p-4 bg-amber-500/10 border-amber-500/30">
|
||||||
|
<div className="text-amber-300 text-sm">
|
||||||
|
Der Server läuft. Welt wechseln und klonen ist nur bei gestopptem Server möglich.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create World Modal */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="modal-backdrop" onClick={() => !busy && setShowCreate(false)}>
|
||||||
|
<div className="modal max-w-lg fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 className="modal-title">Neue Welt erstellen</h3>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body space-y-4">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Weltname</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="meine-neue-welt"
|
||||||
|
disabled={busy}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Vorlage</label>
|
||||||
|
<select
|
||||||
|
value={newTemplate}
|
||||||
|
onChange={(e) => setNewTemplate(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
{templates.length === 0 && <option value="">Keine Vorlagen gefunden</option>}
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-neutral-500 text-xs mt-2">
|
||||||
|
Die Welt wird aus einer der mitgelieferten Server-Vorlagen erzeugt und in die
|
||||||
|
Bibliothek gelegt. Aktivieren musst du sie danach separat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={() => setShowCreate(false)} disabled={busy} className="btn btn-secondary">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCreate} disabled={busy || !newName.trim() || !newTemplate} className="btn btn-primary">
|
||||||
|
{busy ? 'Erstelle...' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clone Modal */}
|
||||||
|
{showClone && (
|
||||||
|
<div className="modal-backdrop" onClick={() => !busy && setShowClone(false)}>
|
||||||
|
<div className="modal max-w-lg fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 className="modal-title">Aktuelle Welt klonen</h3>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Name des Klons</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cloneName}
|
||||||
|
onChange={(e) => setCloneName(e.target.value)}
|
||||||
|
placeholder="welt-backup"
|
||||||
|
disabled={busy}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-500 text-xs mt-2">
|
||||||
|
Kopiert die aktuell aktive Welt inklusive aller Bauten, Charaktere und Inventare in
|
||||||
|
die Bibliothek. Das kann je nach Weltgröße einen Moment dauern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={() => setShowClone(false)} disabled={busy} className="btn btn-secondary">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button onClick={handleClone} disabled={busy || !cloneName.trim()} className="btn btn-primary">
|
||||||
|
{busy ? 'Klone...' : 'Klonen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rename Modal */}
|
||||||
|
{renaming && (
|
||||||
|
<div className="modal-backdrop" onClick={() => !busy && setRenaming(null)}>
|
||||||
|
<div className="modal max-w-md fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 className="modal-title">Welt umbenennen</h3>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Neuer Name für „{renaming.name}"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={() => setRenaming(null)} disabled={busy} className="btn btn-secondary">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button onClick={handleRename} disabled={busy || !renameValue.trim()} className="btn btn-primary">
|
||||||
|
{busy ? 'Speichere...' : 'Umbenennen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
<div className="modal max-w-md fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 className="modal-title">Welt löschen?</h3>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="text-neutral-300">
|
||||||
|
Welt <span className="text-white font-medium">{deleteConfirm}</span> wirklich löschen?
|
||||||
|
Das kann nicht rückgängig gemacht werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={() => setDeleteConfirm(null)} className="btn btn-secondary">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(deleteConfirm)}
|
||||||
|
disabled={actionLoading === deleteConfirm}
|
||||||
|
className="btn btn-destructive"
|
||||||
|
>
|
||||||
|
{actionLoading === deleteConfirm ? 'Lösche...' : 'Löschen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300">Welten</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowClone(true)}
|
||||||
|
disabled={running}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
title={running ? 'Server zuerst stoppen' : 'Aktuelle Welt klonen'}
|
||||||
|
>
|
||||||
|
Aktuelle klonen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||||
|
+ Neue Welt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* World List */}
|
||||||
|
{worlds.length === 0 ? (
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<div className="text-neutral-400">Keine Welten gefunden</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{worlds.map((world) => (
|
||||||
|
<div key={world.name} className="card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-2xl">{world.active ? '🪐' : '🌑'}</span>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white font-medium">{world.name}</span>
|
||||||
|
{world.active && <span className="badge badge-success">Aktiv</span>}
|
||||||
|
{!world.valid && <span className="badge badge-secondary">⚠️ keine Sandbox.sbc</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-neutral-500 text-xs mt-1">
|
||||||
|
{world.size} · {fmtDate(world.modified)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!world.active && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActivate(world.name)}
|
||||||
|
disabled={running || actionLoading === world.name || !world.valid}
|
||||||
|
className="btn btn-primary text-sm"
|
||||||
|
title={running ? 'Server zuerst stoppen' : 'Diese Welt laden'}
|
||||||
|
>
|
||||||
|
{actionLoading === world.name ? '...' : 'Laden'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setRenaming(world); setRenameValue(world.name) }}
|
||||||
|
className="btn btn-ghost text-neutral-400 hover:text-white"
|
||||||
|
title="Umbenennen"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{!world.active && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(world.name)}
|
||||||
|
disabled={actionLoading === world.name}
|
||||||
|
className="btn btn-ghost text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useUser } from '../context/UserContext'
|
|||||||
import MetricsChart from '../components/MetricsChart'
|
import MetricsChart from '../components/MetricsChart'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
import FactorioWorldManager from '../components/FactorioWorldManager'
|
import FactorioWorldManager from '../components/FactorioWorldManager'
|
||||||
|
import SpaceEngineersWorldManager from '../components/SpaceEngineersWorldManager'
|
||||||
import PalworldConfigEditor from '../components/PalworldConfigEditor'
|
import PalworldConfigEditor from '../components/PalworldConfigEditor'
|
||||||
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
|
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
|
||||||
import TerrariaConfigEditor from '../components/TerrariaConfigEditor'
|
import TerrariaConfigEditor from '../components/TerrariaConfigEditor'
|
||||||
@@ -318,7 +319,7 @@ const formatUptime = (seconds) => {
|
|||||||
...(isModerator && server?.type === 'minecraft' ? [
|
...(isModerator && server?.type === 'minecraft' ? [
|
||||||
{ id: 'whitelist', label: 'Whitelist' },
|
{ id: 'whitelist', label: 'Whitelist' },
|
||||||
] : []),
|
] : []),
|
||||||
...(isModerator && server?.type === 'factorio' ? [
|
...(isModerator && (server?.type === 'factorio' || server?.type === 'spaceengineers') ? [
|
||||||
{ id: 'worlds', label: 'Welten' },
|
{ id: 'worlds', label: 'Welten' },
|
||||||
] : []),
|
] : []),
|
||||||
...(isModerator && server?.type === 'palworld' ? [
|
...(isModerator && server?.type === 'palworld' ? [
|
||||||
@@ -691,6 +692,18 @@ const formatUptime = (seconds) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Worlds Tab - Space Engineers only. The manager stays usable while the
|
||||||
|
server runs (switch/clone are disabled), so no full lock here. */}
|
||||||
|
{activeTab === 'worlds' && isModerator && server.type === 'spaceengineers' && (
|
||||||
|
<div className="tab-content">
|
||||||
|
<SpaceEngineersWorldManager
|
||||||
|
server={server}
|
||||||
|
token={token}
|
||||||
|
onServerAction={fetchServer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Config Tab - Palworld only */}
|
{/* Config Tab - Palworld only */}
|
||||||
{activeTab === 'config' && isModerator && server.type === 'palworld' && (
|
{activeTab === 'config' && isModerator && server.type === 'palworld' && (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
|
|||||||
Reference in New Issue
Block a user