Files
LuHost/utils/server-manager.js
Nathan Schneider 2d3b1166fe Fix server management issues and improve overall stability
Major server management fixes:
- Replace Flatpak-specific pkill with universal process tree termination using pstree + process.kill()
- Fix signal format errors (SIGTERM/SIGKILL instead of TERM/KILL strings)
- Add 5-second cooldown after server stop to prevent race conditions with external detection
- Enable Stop Server button for external servers in UI
- Implement proper timeout handling with process tree killing

ContentDB improvements:
- Fix download retry logic and "closed" error by preventing concurrent zip extraction
- Implement smart root directory detection and stripping during package extraction
- Add game-specific timeout handling (8s for VoxeLibre vs 3s for simple games)

World creation fixes:
- Make world creation asynchronous to prevent browser hangs
- Add WebSocket notifications for world creation completion status

Other improvements:
- Remove excessive debug logging
- Improve error handling and user feedback throughout the application
- Clean up temporary files and unnecessary logging

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 19:17:38 -06:00

1013 lines
35 KiB
JavaScript

const { spawn, exec } = require('child_process');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const EventEmitter = require('events');
const paths = require('./paths');
class ServerManager extends EventEmitter {
constructor() {
super();
this.serverProcess = null;
this.isRunning = false;
this.isReady = false; // Track if server is actually ready to accept connections
this.startTime = null;
this.logBuffer = [];
this.maxLogLines = 1000;
this.serverStats = {
players: 0,
uptime: 0,
memoryUsage: 0,
cpuUsage: 0
};
this.debugFileWatcher = null;
this.lastDebugFilePosition = 0;
this.lastStopTime = 0; // Track when we last stopped a server
}
async getServerStatus() {
// Double-check if process is actually running when we think it is
if (this.isRunning && this.serverProcess && this.serverProcess.pid) {
try {
// Use kill(pid, 0) to check if process exists without sending a signal
process.kill(this.serverProcess.pid, 0);
} catch (error) {
// Process doesn't exist anymore - it was killed externally
this.addLogLine('warning', 'Server process was terminated externally');
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
this.startTime = null;
// Reset player stats when server stops
this.serverStats.players = 0;
this.serverStats.memoryUsage = 0;
this.serverStats.cpuUsage = 0;
this.emit('exit', { code: null, signal: 'external' });
// Emit status change immediately
this.emit('status', {
isRunning: this.isRunning,
isReady: this.isReady,
uptime: 0,
startTime: null,
players: 0,
memoryUsage: 0,
cpuUsage: 0,
processId: null
});
}
}
// Only check for external servers if we truly have no server process at all
// This prevents the state confusion that was causing managed servers to be marked as external
// Also add a cooldown period after stopping a managed server to prevent race conditions
const timeSinceLastStop = Date.now() - this.lastStopTime;
const cooldownPeriod = 5000; // 5 seconds cooldown after stopping a server
if (!this.serverProcess && timeSinceLastStop > cooldownPeriod) {
const externalServer = await this.detectExternalLuantiServer();
if (externalServer) {
console.log(`ServerManager: Detected truly external server (PID: ${externalServer.pid})`);
this.isRunning = true;
this.isReady = true;
this.startTime = externalServer.startTime;
// Try to get player data from debug log for external servers
const playerData = await this.getExternalServerPlayerData();
this.serverStats.players = playerData.count;
this.addLogLine('info', `Detected external Luanti server (PID: ${externalServer.pid}, World: ${externalServer.world})`);
// Create a mock server process object for tracking
this.serverProcess = { pid: externalServer.pid, external: true };
console.log('ServerManager: Set serverProcess.external = true');
// Start monitoring debug file for external server
this.startDebugFileMonitoring();
}
}
return {
isRunning: this.isRunning,
isReady: this.isReady,
uptime: this.isRunning && this.startTime ? Date.now() - this.startTime : 0,
startTime: this.startTime,
players: this.serverStats.players,
memoryUsage: this.serverStats.memoryUsage,
cpuUsage: this.serverStats.cpuUsage,
processId: this.serverProcess?.pid || null
};
}
async startServer(worldName = null) {
if (this.isRunning) {
throw new Error('Server is already running');
}
try {
// Ensure minetest directory exists
paths.ensureDirectories();
// Build command arguments
const args = [
'--server',
'--config', paths.configFile
];
if (worldName && worldName.trim() !== '') {
if (!paths.isValidWorldName(worldName)) {
throw new Error('Invalid world name');
}
// Check if world exists
const worldPath = paths.getWorldPath(worldName);
try {
await fs.access(worldPath);
} catch (error) {
throw new Error(`World "${worldName}" does not exist. Please create it first in the Worlds section.`);
}
// Read the world's game configuration
const worldConfigPath = path.join(worldPath, 'world.mt');
try {
const worldConfig = await fs.readFile(worldConfigPath, 'utf8');
const gameMatch = worldConfig.match(/gameid\s*=\s*(.+)/);
if (gameMatch) {
const gameId = gameMatch[1].trim();
args.push('--gameid', gameId);
this.addLogLine('info', `Using game: ${gameId} for world: ${worldName}`);
}
} catch (error) {
this.addLogLine('warning', `Could not read world config, using default game: ${error.message}`);
}
args.push('--world', worldPath);
} else {
// If no world specified, we need to create a default world or let the server create one
this.addLogLine('info', 'Starting server without specifying a world. Server will use default world settings.');
}
// Check if minetest/luanti executable exists
const executableInfo = await this.findMinetestExecutable();
this.serverProcess = spawn(executableInfo.command, [...executableInfo.args, ...args], {
cwd: paths.minetestDir,
stdio: ['pipe', 'pipe', 'pipe']
});
this.isRunning = true;
this.isReady = false; // Server started but not ready yet
this.startTime = Date.now();
// Handle process events
this.serverProcess.on('error', (error) => {
this.emit('error', error);
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
});
this.serverProcess.on('exit', (code, signal) => {
this.emit('exit', { code, signal });
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
this.startTime = null;
this.stopDebugFileMonitoring();
});
// Handle output streams
this.serverProcess.stdout.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
lines.forEach(line => this.addLogLine('stdout', line));
this.parseServerStats(data.toString());
});
this.serverProcess.stderr.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
lines.forEach(line => this.addLogLine('stderr', line));
});
this.emit('started', { pid: this.serverProcess.pid });
// Start monitoring debug.txt file for server ready messages
this.startDebugFileMonitoring();
return {
success: true,
pid: this.serverProcess.pid,
message: `Server started successfully with PID ${this.serverProcess.pid}`
};
} catch (error) {
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
throw error;
}
}
async stopServer(force = false) {
if (!this.isRunning || !this.serverProcess) {
throw new Error('Server is not running');
}
const pid = this.serverProcess.pid;
const isExternal = this.serverProcess.external;
console.log(`ServerManager: Stopping server (PID: ${pid}, External: ${isExternal}, Force: ${force})`);
return new Promise((resolve, reject) => {
let timeoutHandle = null;
let resolved = false;
const cleanup = (message) => {
if (resolved) return;
resolved = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
// Clean up state immediately
this.cleanupServerState();
// Add a delay to prevent race conditions with external detection
setTimeout(() => {
resolve({ success: true, message });
}, 2000);
};
// Set timeout for force kill
timeoutHandle = setTimeout(() => {
console.log(`ServerManager: Timeout reached, force-killing process tree for ${pid}`);
this.killProcessTree(pid, () => {
console.log(`ServerManager: Process tree killed for PID ${pid} (timeout)`);
cleanup('Server force-stopped after timeout');
});
}, isExternal ? 3000 : 10000);
// Set up exit handler for managed processes
if (this.serverProcess.on && !isExternal) {
this.serverProcess.on('exit', () => {
console.log('ServerManager: Managed server process exited');
cleanup('Server stopped gracefully');
});
}
// For external servers or force stop, kill immediately
if (isExternal || force) {
console.log(`ServerManager: Killing process tree for PID ${pid}`);
this.killProcessTree(pid, () => {
console.log(`ServerManager: Process tree killed for PID ${pid}`);
cleanup('Server stopped');
});
} else {
// Try graceful shutdown first for managed servers
try {
this.serverProcess.stdin.write('/shutdown\n');
console.log('ServerManager: Sent shutdown command to server');
} catch (stdinError) {
console.warn('Failed to send shutdown command, using SIGTERM:', stdinError.message);
this.killProcessTree(pid, () => {
console.log(`ServerManager: Process tree killed for PID ${pid} (after stdin failure)`);
cleanup('Server stopped');
});
}
}
});
}
killProcessTree(pid, callback) {
console.log(`ServerManager: Attempting to kill process tree for PID ${pid}`);
const { spawn } = require('child_process');
// First, try to kill the process gracefully with SIGTERM
this.killProcessRecursive(pid, 'TERM', (termSuccess) => {
if (termSuccess) {
console.log('ServerManager: Process terminated gracefully');
callback();
return;
}
// If SIGTERM didn't work, wait a moment and try SIGKILL
console.log('ServerManager: SIGTERM failed, trying SIGKILL...');
setTimeout(() => {
this.killProcessRecursive(pid, 'KILL', (killSuccess) => {
if (killSuccess) {
console.log('ServerManager: Process force killed');
} else {
console.warn('ServerManager: Could not kill process');
}
callback();
});
}, 2000);
});
}
killProcessRecursive(pid, signal, callback) {
const { spawn } = require('child_process');
// Get all child processes first
const pstree = spawn('pstree', ['-p', pid.toString()]);
let pids = [pid];
let pstreeOutput = '';
pstree.stdout.on('data', (data) => {
pstreeOutput += data.toString();
});
pstree.on('close', (code) => {
// Extract all PIDs from pstree output
const pidMatches = pstreeOutput.match(/\((\d+)\)/g);
if (pidMatches) {
const childPids = pidMatches.map(match => parseInt(match.slice(1, -1)));
// Add child PIDs to our list, avoiding duplicates
childPids.forEach(childPid => {
if (!pids.includes(childPid)) {
pids.push(childPid);
}
});
}
console.log(`ServerManager: Found ${pids.length} processes to kill:`, pids);
// Kill all processes in reverse order (children first, then parent)
pids.reverse();
let processesKilled = 0;
let processesTotal = pids.length;
const killNextProcess = (index) => {
if (index >= pids.length) {
// All done
console.log(`ServerManager: Killed ${processesKilled}/${processesTotal} processes`);
callback(processesKilled > 0);
return;
}
const currentPid = pids[index];
console.log(`ServerManager: Sending SIG${signal} to PID ${currentPid}`);
try {
process.kill(currentPid, `SIG${signal}`);
processesKilled++;
console.log(`ServerManager: Successfully sent SIG${signal} to PID ${currentPid}`);
} catch (error) {
console.log(`ServerManager: Could not kill PID ${currentPid}:`, error.message);
}
// Move to next process after a short delay
setTimeout(() => killNextProcess(index + 1), 100);
};
killNextProcess(0);
});
pstree.on('error', (error) => {
console.warn('ServerManager: pstree failed, falling back to simple kill:', error.message);
// Fallback: just try to kill the main PID
try {
process.kill(pid, `SIG${signal}`);
console.log(`ServerManager: Successfully sent SIG${signal} to PID ${pid} (fallback)`);
callback(true);
} catch (error) {
console.log(`ServerManager: Could not kill PID ${pid}:`, error.message);
callback(false);
}
});
}
cleanupServerState() {
console.log('ServerManager: Cleaning up server state');
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
this.startTime = null;
this.lastStopTime = Date.now(); // Record when we stopped the server
this.stopDebugFileMonitoring();
// Reset player stats when server stops
this.serverStats.players = 0;
this.serverStats.memoryUsage = 0;
this.serverStats.cpuUsage = 0;
}
async restartServer(worldName = null) {
if (this.isRunning) {
await this.stopServer();
// Wait a moment for clean shutdown
await new Promise(resolve => setTimeout(resolve, 2000));
}
return await this.startServer(worldName);
}
async findGamePath(gameId) {
try {
// Use the paths utility to find installed games
const games = await paths.getInstalledGames();
const game = games.find(g => g.name === gameId);
if (game) {
return game.path;
}
return null;
} catch (error) {
this.addLogLine('warning', `Error finding game path for "${gameId}": ${error.message}`);
return null;
}
}
async findMinetestExecutable() {
// Check different installation methods for Luanti
const installationMethods = [
// System packages
{ command: 'luanti', args: [] },
{ command: 'minetest', args: [] },
{ command: 'minetestserver', args: [] },
// Flatpak
{ command: 'flatpak', args: ['run', 'org.luanti.luanti'] },
{ command: 'flatpak', args: ['run', 'net.minetest.Minetest'] },
// Snap
{ command: 'snap', args: ['run', 'luanti'] },
{ command: 'snap', args: ['run', 'minetest'] }
];
for (const method of installationMethods) {
try {
// Test if the command exists and works
const testResult = await new Promise((resolve, reject) => {
const { spawn } = require('child_process');
const testProcess = spawn(method.command, [...method.args, '--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000
});
let stdout = '';
let stderr = '';
testProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
testProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
testProcess.on('close', (code) => {
if ((code === 0 || code === null) && (stdout.includes('Luanti') || stdout.includes('Minetest'))) {
resolve({ command: method.command, args: method.args, output: stdout });
} else {
reject(new Error(`Command failed with code ${code}`));
}
});
testProcess.on('error', (error) => {
reject(error);
});
});
const executableInfo = {
command: testResult.command,
args: testResult.args,
fullCommand: testResult.args.length > 0 ? `${testResult.command} ${testResult.args.join(' ')}` : testResult.command
};
this.addLogLine('info', `Found Luanti: ${executableInfo.fullCommand}`);
return executableInfo;
} catch (error) {
// Continue to next method
continue;
}
}
// Provide detailed error message with setup instructions
const errorMsg = `Luanti/Minetest not found or not working properly.\n\n` +
`Please install Luanti using one of these methods:\n` +
`• System package: sudo apt install luanti (Ubuntu/Debian)\n` +
`• Flatpak: flatpak install flathub org.luanti.luanti\n` +
`• Snap: sudo snap install luanti\n` +
`• Download from: https://www.luanti.org/downloads/\n\n` +
`This web interface requires Luanti to manage worlds and run servers.`;
this.addLogLine('error', errorMsg);
throw new Error(errorMsg);
}
async createWorld(worldName, gameId = 'minetest_game') {
try {
const executableInfo = await this.findMinetestExecutable();
const worldPath = paths.getWorldPath(worldName);
// Create world using Luanti --world command
// This will initialize the world properly with the correct structure
const createWorldArgs = [
'--world', worldPath,
'--gameid', gameId,
'--server',
'--go', // Skip main menu
'--quiet' // Reduce output
];
this.addLogLine('info', `Creating world "${worldName}" with game "${gameId}"`);
return new Promise((resolve, reject) => {
const createProcess = spawn(executableInfo.command, [...executableInfo.args, ...createWorldArgs], {
cwd: paths.minetestDir,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30000 // 30 second timeout
});
let stdout = '';
let stderr = '';
createProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
createProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
// Stop the server after a short time - we just want to initialize the world
// Complex games like VoxeLibre/MineCLone2 need more time to initialize
const timeout = gameId === 'mineclone2' || gameId.includes('clone') || gameId.includes('voxel') ? 8000 : 3000;
this.addLogLine('info', `Using ${timeout}ms timeout for game: ${gameId}`);
setTimeout(() => {
if (createProcess && !createProcess.killed) {
createProcess.kill('SIGTERM');
}
}, timeout);
createProcess.on('close', (code) => {
this.addLogLine('info', `World creation process finished with code: ${code}`);
// Check if world directory was created successfully
if (fsSync.existsSync(worldPath)) {
this.addLogLine('info', `World "${worldName}" created successfully`);
resolve({ success: true, worldPath });
} else {
this.addLogLine('error', `World directory not created: ${worldPath}`);
reject(new Error(`Failed to create world directory: ${worldPath}`));
}
});
createProcess.on('error', (error) => {
this.addLogLine('error', `World creation failed: ${error.message}`);
reject(error);
});
});
} catch (error) {
this.addLogLine('error', `Failed to create world: ${error.message}`);
throw error;
}
}
addLogLine(type, content) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
type,
content: content.trim()
};
this.logBuffer.push(logEntry);
// Keep only the last N lines
if (this.logBuffer.length > this.maxLogLines) {
this.logBuffer = this.logBuffer.slice(-this.maxLogLines);
}
this.emit('log', logEntry);
}
parseServerStats(output) {
// Parse server output for statistics and ready state
const lines = output.split('\n');
for (const line of lines) {
// Look for player count
const playerMatch = line.match(/(\d+) players? online/i);
if (playerMatch) {
this.serverStats.players = parseInt(playerMatch[1]);
}
// Look for performance stats if available
const memMatch = line.match(/Memory usage: ([\d.]+)MB/i);
if (memMatch) {
this.serverStats.memoryUsage = parseFloat(memMatch[1]);
}
// Check if server is ready - look for common Luanti server ready messages
if (!this.isReady && this.isRunning) {
const readyIndicators = [
/Server for gameid=".*?" listening on/i,
/listening on \[::\]:\d+/i,
/listening on 0\.0\.0\.0:\d+/i,
/World at \[.*?\]/i,
/Server started/i,
/Loading environment/i
];
for (const indicator of readyIndicators) {
if (indicator.test(line)) {
this.isReady = true;
this.addLogLine('info', 'Server is now ready to accept connections');
console.log(`Server ready detected from line: ${line}`); // Debug log
// Emit status change when server becomes ready
this.emit('status', {
isRunning: this.isRunning,
isReady: this.isReady,
uptime: this.startTime ? Date.now() - this.startTime : 0,
startTime: this.startTime,
players: this.serverStats.players,
memoryUsage: this.serverStats.memoryUsage,
cpuUsage: this.serverStats.cpuUsage,
processId: this.serverProcess?.pid || null
});
break;
}
}
// Also check for error conditions that indicate startup failure
const errorIndicators = [
/ERROR\[Main\]:/i,
/FATAL ERROR/i,
/Could not find or load game/i,
/Failed to/i
];
for (const errorIndicator of errorIndicators) {
if (errorIndicator.test(line)) {
// Don't mark as ready if we see critical errors
this.addLogLine('warning', 'Server startup may have failed - check logs for errors');
break;
}
}
}
}
this.emit('stats', this.serverStats);
}
getLogs(lines = 100) {
return this.logBuffer.slice(-lines);
}
getRecentLogs(since = null) {
if (!since) {
return this.logBuffer.slice(-50);
}
const sinceTime = new Date(since);
return this.logBuffer.filter(log =>
new Date(log.timestamp) > sinceTime
);
}
async sendCommand(command) {
if (!this.isRunning || !this.serverProcess) {
throw new Error('Server is not running');
}
// Check if this is an external server
if (this.serverProcess.external) {
throw new Error('Cannot send commands to external servers. Commands can only be sent to servers started through this dashboard.');
}
// Validate and sanitize command
const sanitizedCommand = this.validateServerCommand(command);
return new Promise((resolve, reject) => {
try {
this.serverProcess.stdin.write(sanitizedCommand + '\n');
this.addLogLine('info', `Command sent: ${sanitizedCommand}`);
resolve({ success: true, message: 'Command sent successfully' });
} catch (error) {
reject(error);
}
});
}
validateServerCommand(command) {
if (!command || typeof command !== 'string') {
throw new Error('Command must be a non-empty string');
}
// Remove any control characters and limit length
const sanitized = command.replace(/[\x00-\x1F\x7F]/g, '').trim();
if (sanitized.length === 0) {
throw new Error('Command cannot be empty after sanitization');
}
if (sanitized.length > 500) {
throw new Error('Command too long (max 500 characters)');
}
// Whitelist of allowed command prefixes for safety
const allowedCommands = [
'/say', '/tell', '/kick', '/ban', '/unban', '/status', '/time', '/weather',
'/give', '/teleport', '/tp', '/spawn', '/help', '/list', '/who', '/shutdown',
'/stop', '/save-all', '/whitelist', '/op', '/deop', '/gamemode', '/difficulty',
'/seed', '/defaultgamemode', '/gamerule', '/reload', '/clear', '/experience',
'/xp', '/effect', '/enchant', '/summon', '/kill', '/scoreboard', '/team',
'/trigger', '/clone', '/execute', '/fill', '/setblock', '/testforblock',
'/blockdata', '/entitydata', '/testfor', '/stats', '/worldborder'
];
// Check if command starts with allowed prefix or is a direct server command
const isAllowed = allowedCommands.some(prefix =>
sanitized.toLowerCase().startsWith(prefix.toLowerCase())
) || /^[a-zA-Z0-9_-]+(\s+[a-zA-Z0-9_.-]+)*$/.test(sanitized);
if (!isAllowed) {
throw new Error('Command not allowed or contains invalid characters');
}
return sanitized;
}
async getServerInfo() {
try {
const configExists = await fs.access(paths.configFile).then(() => true).catch(() => false);
const debugLogExists = await fs.access(paths.debugFile).then(() => true).catch(() => false);
let configMtime = null;
if (configExists) {
const stats = await fs.stat(paths.configFile);
configMtime = stats.mtime;
}
return {
configFile: {
exists: configExists,
path: paths.configFile,
lastModified: configMtime
},
debugLog: {
exists: debugLogExists,
path: paths.debugFile
},
directories: {
minetest: paths.minetestDir,
worlds: paths.worldsDir,
mods: paths.modsDir
}
};
} catch (error) {
throw new Error(`Failed to get server info: ${error.message}`);
}
}
startDebugFileMonitoring() {
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
try {
// Get initial file size to start monitoring from the end
const stats = fsSync.existsSync(debugFilePath) ? fsSync.statSync(debugFilePath) : null;
this.lastDebugFilePosition = stats ? stats.size : 0;
// Watch for changes to debug.txt
this.debugFileWatcher = fsSync.watchFile(debugFilePath, { interval: 500 }, (current, previous) => {
if (current.mtime > previous.mtime) {
this.readDebugFileChanges(debugFilePath);
}
});
} catch (error) {
this.addLogLine('warning', `Could not monitor debug.txt: ${error.message}`);
}
}
stopDebugFileMonitoring() {
if (this.debugFileWatcher) {
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
fsSync.unwatchFile(debugFilePath);
this.debugFileWatcher = null;
}
}
async readDebugFileChanges(debugFilePath) {
try {
const stats = fsSync.statSync(debugFilePath);
if (stats.size > this.lastDebugFilePosition) {
const stream = fsSync.createReadStream(debugFilePath, {
start: this.lastDebugFilePosition,
end: stats.size - 1
});
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString();
});
stream.on('end', () => {
const lines = buffer.split('\n').filter(line => line.trim());
lines.forEach(line => {
this.addLogLine('debug-file', line);
this.parseServerStats(line); // Parse each line for ready indicators
// For external servers, also update player count from new log entries
if (this.serverProcess?.external) {
this.updatePlayerCountFromLogLine(line);
}
});
});
this.lastDebugFilePosition = stats.size;
}
} catch (error) {
// Ignore errors when reading debug file changes
}
}
updatePlayerCountFromLogLine(line) {
// Update player count based on join/leave messages in log
const joinMatch = line.match(/\[Server\]: (\w+) joined the game/);
const leaveMatch = line.match(/\[Server\]: (\w+) left the game/);
if (joinMatch || leaveMatch) {
// Player joined or left - update player data
this.getExternalServerPlayerData().then(playerData => {
this.serverStats.players = playerData.count;
});
}
}
async detectExternalLuantiServer() {
try {
const { spawn } = require('child_process');
return new Promise((resolve) => {
const psProcess = spawn('ps', ['aux'], { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
psProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
psProcess.on('close', (code) => {
if (code === 0) {
const lines = stdout.split('\n');
for (const line of lines) {
// Look for luanti or minetest server processes (exclude this dashboard process)
if ((line.includes('luanti') || line.includes('minetest')) &&
(line.includes('--server') || line.includes('--worldname')) &&
!line.includes('node app.js')) {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[1]);
if (pid && !isNaN(pid)) {
// Extract world name from command line
let world = 'unknown';
const worldNameMatch = line.match(/--worldname\s+(\S+)/);
const worldPathMatch = line.match(/--world\s+(\S+)/);
if (worldNameMatch) {
world = worldNameMatch[1];
} else if (worldPathMatch) {
world = path.basename(worldPathMatch[1]);
}
// Estimate start time (this is rough, but better than nothing)
const startTime = Date.now() - 60000; // Assume started 1 minute ago
resolve({
pid: pid,
world: world,
startTime: startTime
});
return;
}
}
}
}
resolve(null);
});
psProcess.on('error', () => {
resolve(null);
});
});
} catch (error) {
return null;
}
}
async getExternalServerPlayerData() {
try {
const fs = require('fs').promises;
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
// Read the last 100 lines of debug.txt to find recent player activity
const data = await fs.readFile(debugFilePath, 'utf8');
const lines = data.split('\n').slice(-100);
// Look for recent player actions to determine who's online
const playerData = new Map(); // Map to store player name -> player info
const cutoffTime = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago (extended from 5)
for (const line of lines.reverse()) {
// Parse timestamp from log line
const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}):/);
if (timestampMatch) {
const logTime = new Date(timestampMatch[1]);
if (logTime < cutoffTime) break; // Stop looking at older entries
// Look for player actions with more detail
const actionPatterns = [
{ pattern: /ACTION\[Server\]: (\w+) (.+)/, type: 'action' },
{ pattern: /\[Server\]: (\w+) joined the game/, type: 'joined' },
{ pattern: /\[Server\]: (\w+) left the game/, type: 'left' }
];
for (const { pattern, type } of actionPatterns) {
const match = line.match(pattern);
if (match && match[1]) {
const playerName = match[1];
const actionDescription = match[2] || type;
// Filter out obvious non-player names and false positives
if (!playerName.includes('Entity') &&
!playerName.includes('SAO') &&
!playerName.includes('Explosion') &&
playerName !== 'Player' && // Generic "Player" is not a real username
playerName !== 'Server' &&
playerName !== 'Main' &&
playerName.length > 2 && // Too short usernames are likely false positives
playerName.length < 20 &&
/^[a-zA-Z0-9_]+$/.test(playerName)) {
// Update player data with most recent activity
if (!playerData.has(playerName) || logTime > playerData.get(playerName).lastSeen) {
let lastAction = actionDescription;
// Simplify common actions for display
if (lastAction.includes('digs ')) {
lastAction = 'Mining';
} else if (lastAction.includes('places ') || lastAction.includes('puts ')) {
lastAction = 'Building';
} else if (lastAction.includes('uses ') || lastAction.includes('activates ')) {
lastAction = 'Using items';
} else if (lastAction.includes('punched ') || lastAction.includes('damage')) {
lastAction = 'Combat';
} else if (type === 'joined') {
lastAction = 'Just joined';
} else if (type === 'left') {
lastAction = 'Left game';
} else {
lastAction = 'Active';
}
// Count activities for this player
const existingData = playerData.get(playerName) || { activityCount: 0 };
playerData.set(playerName, {
name: playerName,
lastSeen: logTime,
lastAction: lastAction,
activityCount: existingData.activityCount + 1,
online: type !== 'left' // Mark as offline if they left
});
}
} else {
}
}
}
}
}
// Convert to array of player objects, filtering out players who left
const players = Array.from(playerData.values())
.filter(player => player.online)
.map(player => ({
name: player.name,
lastSeen: player.lastSeen,
lastAction: player.lastAction,
activityCount: player.activityCount,
online: true
}));
return {
count: players.length,
players: players
};
} catch (error) {
console.error('Error reading debug file for player data:', error);
return { count: 0, players: [] };
}
}
}
module.exports = ServerManager;