A modern web interface for Luanti (Minetest) server management with ContentDB integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
629 lines
24 KiB
JavaScript
629 lines
24 KiB
JavaScript
let socket;
|
|
let autoScroll = true;
|
|
let serverRunning = false;
|
|
let isExternalServer = false;
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Initialize WebSocket connection for real-time updates
|
|
initializeWebSocket();
|
|
|
|
// Load initial data
|
|
loadWorlds();
|
|
updateServerStatus();
|
|
|
|
// Set up periodic status updates (every 3 seconds for better responsiveness)
|
|
setInterval(updateServerStatus, 3000);
|
|
|
|
// Add event listeners for buttons
|
|
document.getElementById('startBtn').addEventListener('click', startServer);
|
|
document.getElementById('stopBtn').addEventListener('click', stopServer);
|
|
document.getElementById('restartBtn').addEventListener('click', restartServer);
|
|
document.getElementById('downloadBtn').addEventListener('click', downloadLogs);
|
|
document.getElementById('clearBtn').addEventListener('click', clearLogs);
|
|
document.getElementById('autoScrollBtn').addEventListener('click', toggleAutoScroll);
|
|
document.getElementById('sendBtn').addEventListener('click', sendCommand);
|
|
|
|
// Add enter key handler for console input
|
|
document.getElementById('consoleInput').addEventListener('keypress', function(event) {
|
|
if (event.key === 'Enter') {
|
|
sendCommand();
|
|
}
|
|
});
|
|
});
|
|
|
|
function initializeWebSocket() {
|
|
socket = io();
|
|
|
|
socket.on('server:log', function(logEntry) {
|
|
addLogEntry(logEntry.type, logEntry.content, logEntry.timestamp);
|
|
});
|
|
|
|
socket.on('server:status', function(status) {
|
|
isExternalServer = status.isExternal || false;
|
|
updateStatusDisplay(status);
|
|
});
|
|
|
|
socket.on('server:players', function(players) {
|
|
updatePlayersList(players, isExternalServer);
|
|
});
|
|
}
|
|
|
|
async function updateServerStatus() {
|
|
try {
|
|
const response = await fetch('/api/server/status');
|
|
|
|
// Check for authentication redirect
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('text/html')) {
|
|
console.warn('Authentication required for server status');
|
|
// Silently fail for status updates, don't redirect automatically
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('HTTP error! status: ' + response.status);
|
|
}
|
|
|
|
const status = await response.json();
|
|
isExternalServer = status.isExternal || false;
|
|
updateStatusDisplay(status);
|
|
} catch (error) {
|
|
console.error('Failed to update server status:', error);
|
|
}
|
|
}
|
|
|
|
async function checkServerStatus() {
|
|
try {
|
|
const response = await fetch('/api/server/status');
|
|
|
|
// Check for authentication redirect
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('text/html')) {
|
|
return null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Failed to check server status:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function updateStatusDisplay(status) {
|
|
const statusLight = document.getElementById('statusLight');
|
|
const statusText = document.getElementById('statusText');
|
|
const startBtn = document.getElementById('startBtn');
|
|
const stopBtn = document.getElementById('stopBtn');
|
|
const restartBtn = document.getElementById('restartBtn');
|
|
const consoleInputGroup = document.getElementById('consoleInputGroup');
|
|
|
|
const wasRunning = serverRunning;
|
|
serverRunning = status.isRunning;
|
|
|
|
if (status.isRunning) {
|
|
if (status.isReady) {
|
|
// Server is running and ready to accept connections
|
|
statusLight.className = 'status-light online';
|
|
statusText.textContent = status.isExternal ? 'Running (External - Monitor Only)' : 'Running';
|
|
} else {
|
|
// Server process is running but not ready yet
|
|
statusLight.className = 'status-light starting';
|
|
statusText.textContent = 'Starting...';
|
|
}
|
|
|
|
// For external servers, disable control buttons
|
|
if (status.isExternal) {
|
|
startBtn.disabled = true;
|
|
stopBtn.disabled = true;
|
|
restartBtn.disabled = true;
|
|
consoleInputGroup.style.display = 'none';
|
|
} else {
|
|
startBtn.disabled = true;
|
|
stopBtn.disabled = false;
|
|
restartBtn.disabled = false;
|
|
consoleInputGroup.style.display = 'block';
|
|
}
|
|
} else {
|
|
statusLight.className = 'status-light offline';
|
|
statusText.textContent = 'Offline';
|
|
startBtn.disabled = false;
|
|
stopBtn.disabled = true;
|
|
restartBtn.disabled = true;
|
|
consoleInputGroup.style.display = 'none';
|
|
|
|
|
|
// Reset button states if server stopped unexpectedly
|
|
if (startBtn.textContent === '⏳ Starting...') {
|
|
startBtn.textContent = '▶️ Start Server';
|
|
}
|
|
if (restartBtn.textContent === '⏳ Restarting...') {
|
|
restartBtn.textContent = '🔄 Restart Server';
|
|
}
|
|
|
|
// Log if server stopped unexpectedly
|
|
if (wasRunning && !status.isRunning) {
|
|
addLogEntry('warning', 'Server has stopped. Check logs for details.');
|
|
}
|
|
}
|
|
|
|
// Update stats
|
|
document.getElementById('uptime').textContent = formatUptime(status.uptime);
|
|
document.getElementById('playerCount').textContent = status.players || 0;
|
|
document.getElementById('memoryUsage').textContent = status.memoryUsage ?
|
|
Math.round(status.memoryUsage) + ' MB' : '--';
|
|
|
|
// Debug: Log the status to see what we're getting
|
|
console.log('Server status update:', {
|
|
isRunning: status.isRunning,
|
|
players: status.players,
|
|
uptime: status.uptime
|
|
});
|
|
}
|
|
|
|
function formatUptime(milliseconds) {
|
|
if (!milliseconds) return '--';
|
|
|
|
const seconds = Math.floor(milliseconds / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
|
|
if (days > 0) return days + 'd ' + (hours % 24) + 'h';
|
|
if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
|
|
if (minutes > 0) return minutes + 'm ' + (seconds % 60) + 's';
|
|
return seconds + 's';
|
|
}
|
|
|
|
async function loadWorlds() {
|
|
console.log('loadWorlds() called');
|
|
try {
|
|
const response = await fetch('/api/worlds');
|
|
|
|
// Check for authentication redirect
|
|
const contentType = response.headers.get('content-type');
|
|
console.log('Response status:', response.status, 'Content-Type:', contentType);
|
|
if (contentType && contentType.includes('text/html')) {
|
|
console.warn('Authentication required for loading worlds');
|
|
document.getElementById('worldSelect').innerHTML =
|
|
'<option value="">Please log in to load worlds</option>' +
|
|
'<option value="" disabled>───────────────────</option>' +
|
|
'<option value="" disabled>🔒 Authentication required</option>';
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('HTTP error! status: ' + response.status);
|
|
}
|
|
|
|
const worlds = await response.json();
|
|
console.log('Worlds received:', worlds);
|
|
const worldSelect = document.getElementById('worldSelect');
|
|
|
|
if (worlds.length === 0) {
|
|
worldSelect.innerHTML =
|
|
'<option value="">No worlds found - server will create default world</option>' +
|
|
'<option value="" disabled>───────────────────</option>' +
|
|
'<option value="" disabled>💡 Create worlds in the Worlds section</option>';
|
|
} else {
|
|
worldSelect.innerHTML = '<option value="" disabled selected>Choose a world to run</option>';
|
|
worlds.forEach(world => {
|
|
const option = document.createElement('option');
|
|
option.value = world.name;
|
|
option.textContent = '🌍 ' + (world.displayName || world.name);
|
|
worldSelect.appendChild(option);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load worlds:', error);
|
|
document.getElementById('worldSelect').innerHTML =
|
|
'<option value="">Error loading worlds - will use defaults</option>';
|
|
}
|
|
}
|
|
|
|
async function startServer() {
|
|
const worldName = document.getElementById('worldSelect').value;
|
|
console.log('Starting server with world:', worldName);
|
|
const startBtn = document.getElementById('startBtn');
|
|
|
|
// Validate that a world is selected
|
|
if (!worldName) {
|
|
addLogEntry('error', 'Please select a world before starting the server');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
startBtn.disabled = true;
|
|
startBtn.textContent = '⏳ Starting...';
|
|
|
|
const response = await fetch('/api/server/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ worldName: worldName })
|
|
});
|
|
|
|
// Check if response is HTML (redirect to login) instead of JSON
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('text/html')) {
|
|
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
|
startBtn.disabled = false;
|
|
startBtn.textContent = '▶️ Start Server';
|
|
// Optionally redirect to login
|
|
setTimeout(() => {
|
|
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
|
}, 2000);
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
startBtn.disabled = false;
|
|
startBtn.textContent = '▶️ Start Server';
|
|
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
addLogEntry('info', result.message || 'Server started successfully');
|
|
await updateServerStatus();
|
|
// Monitor for early server crash
|
|
setTimeout(async () => {
|
|
const status = await checkServerStatus();
|
|
if (status && !status.isRunning) {
|
|
addLogEntry('warning', 'Server appears to have stopped unexpectedly. Check logs for errors.');
|
|
startBtn.disabled = false;
|
|
startBtn.textContent = '▶️ Start Server';
|
|
}
|
|
}, 3000); // Check after 3 seconds
|
|
} else {
|
|
addLogEntry('error', 'Failed to start server: ' + (result.error || 'Unknown error'));
|
|
startBtn.disabled = false;
|
|
startBtn.textContent = '▶️ Start Server';
|
|
}
|
|
} catch (error) {
|
|
console.error('Server start error:', error);
|
|
addLogEntry('error', 'Failed to start server: ' + error.message);
|
|
startBtn.disabled = false;
|
|
startBtn.textContent = '▶️ Start Server';
|
|
}
|
|
}
|
|
|
|
async function stopServer() {
|
|
const stopBtn = document.getElementById('stopBtn');
|
|
|
|
try {
|
|
stopBtn.disabled = true;
|
|
stopBtn.textContent = '⏳ Stopping...';
|
|
|
|
const response = await fetch('/api/server/stop', { method: 'POST' });
|
|
|
|
// Check for authentication redirect
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('text/html')) {
|
|
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
|
stopBtn.disabled = false;
|
|
stopBtn.textContent = '⏹️ Stop Server';
|
|
setTimeout(() => {
|
|
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
|
}, 2000);
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('HTTP error! status: ' + response.status);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
addLogEntry('info', result.message || 'Server stopped successfully');
|
|
await updateServerStatus();
|
|
} else {
|
|
addLogEntry('error', 'Failed to stop server: ' + (result.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Server stop error:', error);
|
|
addLogEntry('error', 'Failed to stop server: ' + error.message);
|
|
} finally {
|
|
stopBtn.disabled = false;
|
|
stopBtn.textContent = '⏹️ Stop Server';
|
|
}
|
|
}
|
|
|
|
async function restartServer() {
|
|
const worldName = document.getElementById('worldSelect').value;
|
|
const restartBtn = document.getElementById('restartBtn');
|
|
|
|
// Validate that a world is selected
|
|
if (!worldName) {
|
|
addLogEntry('error', 'Please select a world before restarting the server');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
restartBtn.disabled = true;
|
|
restartBtn.textContent = '⏳ Restarting...';
|
|
|
|
const response = await fetch('/api/server/restart', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ worldName: worldName || null })
|
|
});
|
|
|
|
// Check for authentication redirect
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('text/html')) {
|
|
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
|
restartBtn.disabled = false;
|
|
restartBtn.textContent = '🔄 Restart Server';
|
|
setTimeout(() => {
|
|
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
|
}, 2000);
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
restartBtn.disabled = false;
|
|
restartBtn.textContent = '🔄 Restart Server';
|
|
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
addLogEntry('info', result.message || 'Server restarted successfully');
|
|
await updateServerStatus();
|
|
// Monitor for early server crash
|
|
setTimeout(async () => {
|
|
const status = await checkServerStatus();
|
|
if (status && !status.isRunning) {
|
|
addLogEntry('warning', 'Server appears to have stopped unexpectedly after restart. Check logs for errors.');
|
|
restartBtn.disabled = false;
|
|
restartBtn.textContent = '🔄 Restart Server';
|
|
}
|
|
}, 3000); // Check after 3 seconds
|
|
} else {
|
|
addLogEntry('error', 'Failed to restart server: ' + (result.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Server restart error:', error);
|
|
addLogEntry('error', 'Failed to restart server: ' + error.message);
|
|
} finally {
|
|
restartBtn.disabled = false;
|
|
restartBtn.textContent = '🔄 Restart Server';
|
|
}
|
|
}
|
|
|
|
function addLogEntry(type, message, timestamp) {
|
|
const consoleContent = document.getElementById('consoleContent');
|
|
const logEntry = document.createElement('div');
|
|
|
|
timestamp = timestamp || new Date().toLocaleTimeString();
|
|
|
|
logEntry.className = 'log-entry ' + type;
|
|
logEntry.innerHTML = '<span class="timestamp">' + timestamp + '</span>' +
|
|
'<span class="message">' + escapeHtml(message) + '</span>';
|
|
|
|
consoleContent.appendChild(logEntry);
|
|
|
|
// Auto-scroll to bottom if enabled
|
|
if (autoScroll) {
|
|
consoleContent.scrollTop = consoleContent.scrollHeight;
|
|
}
|
|
|
|
// Limit log entries to prevent memory issues
|
|
const maxEntries = 1000;
|
|
while (consoleContent.children.length > maxEntries) {
|
|
consoleContent.removeChild(consoleContent.firstChild);
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function clearLogs() {
|
|
document.getElementById('consoleContent').innerHTML = '';
|
|
addLogEntry('info', 'Console cleared');
|
|
}
|
|
|
|
function toggleAutoScroll() {
|
|
autoScroll = !autoScroll;
|
|
document.getElementById('autoScrollText').textContent = 'Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
|
|
}
|
|
|
|
async function sendCommand() {
|
|
const input = document.getElementById('consoleInput');
|
|
const command = input.value.trim();
|
|
|
|
if (!command) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/server/command', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command })
|
|
});
|
|
|
|
// Check for authentication redirect
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('text/html')) {
|
|
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
|
setTimeout(() => {
|
|
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
|
}, 2000);
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('HTTP error! status: ' + response.status);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
addLogEntry('info', 'Command sent: ' + command);
|
|
input.value = '';
|
|
} else {
|
|
addLogEntry('error', 'Failed to send command: ' + (result.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Send command error:', error);
|
|
addLogEntry('error', 'Failed to send command: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function updatePlayersList(players, isExternal) {
|
|
const playersList = document.getElementById('playersList');
|
|
|
|
if (!players || players.length === 0) {
|
|
playersList.innerHTML = '<p class="text-muted">No players online</p>';
|
|
return;
|
|
}
|
|
|
|
// Create a table for better formatting with kick functionality
|
|
const playersHtml = '<table class="table table-sm">' +
|
|
'<thead>' +
|
|
'<tr>' +
|
|
'<th>Player</th>' +
|
|
'<th>Last Activity</th>' +
|
|
'<th>Actions</th>' +
|
|
'</tr>' +
|
|
'</thead>' +
|
|
'<tbody>' +
|
|
players.map((player, index) => {
|
|
// Format the last seen time
|
|
let lastActivity = '--';
|
|
if (player.lastSeen) {
|
|
const now = new Date();
|
|
const lastSeenTime = new Date(player.lastSeen);
|
|
const diffMinutes = Math.floor((now - lastSeenTime) / (1000 * 60));
|
|
|
|
if (diffMinutes < 1) {
|
|
lastActivity = 'Just now';
|
|
} else if (diffMinutes < 60) {
|
|
lastActivity = diffMinutes + 'm ago';
|
|
} else {
|
|
lastActivity = Math.floor(diffMinutes / 60) + 'h ago';
|
|
}
|
|
}
|
|
|
|
return '<tr>' +
|
|
'<td><strong>' + escapeHtml(player.name) + '</strong></td>' +
|
|
'<td>' +
|
|
'<small class="text-muted">' + lastActivity + '</small><br>' +
|
|
'<span class="badge badge-secondary">' + (player.lastAction || 'Active') + '</span>' +
|
|
'</td>' +
|
|
'<td>' +
|
|
'<button class="btn btn-sm btn-outline-danger kick-player-btn" data-player-name="' + escapeHtml(player.name) + '"' +
|
|
(isExternal ? ' disabled title="Cannot kick players on external servers"' : '') + '>' +
|
|
'<i class="fas fa-user-slash"></i> Kick' +
|
|
'</button>' +
|
|
'</td>' +
|
|
'</tr>';
|
|
}).join('') +
|
|
'</tbody>' +
|
|
'</table>';
|
|
|
|
playersList.innerHTML = playersHtml;
|
|
|
|
// Add event listeners for kick buttons
|
|
const kickButtons = playersList.querySelectorAll('.kick-player-btn');
|
|
kickButtons.forEach(button => {
|
|
button.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const playerName = this.getAttribute('data-player-name');
|
|
kickPlayer(playerName);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function kickPlayer(playerName) {
|
|
console.log('kickPlayer() called for player:', playerName);
|
|
addLogEntry('info', 'Attempting to kick player: ' + playerName);
|
|
|
|
if (!confirm('Are you sure you want to kick ' + playerName + '?')) {
|
|
console.log('Kick cancelled by user');
|
|
return;
|
|
}
|
|
|
|
console.log('Sending kick request...');
|
|
|
|
try {
|
|
const response = await fetch('/api/server/command', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
command: '/kick ' + playerName
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
addLogEntry('error', 'Authentication required to kick players. Please refresh the page.');
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 2000);
|
|
return;
|
|
}
|
|
throw new Error('HTTP error! status: ' + response.status);
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
addLogEntry('success', 'Kicked player: ' + playerName);
|
|
// Refresh player list after a short delay
|
|
setTimeout(updateServerStatus, 1000);
|
|
} else {
|
|
addLogEntry('error', 'Failed to kick player: ' + (result.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error kicking player:', error);
|
|
addLogEntry('error', 'Error kicking player: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function downloadLogs() {
|
|
try {
|
|
const response = await fetch('/api/server/logs');
|
|
|
|
// Check for authentication redirect
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('text/html')) {
|
|
addLogEntry('error', 'Authentication required to download logs');
|
|
setTimeout(() => {
|
|
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
|
}, 2000);
|
|
return;
|
|
}
|
|
|
|
if (response.ok) {
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'server-logs-' + new Date().toISOString().split('T')[0] + '.txt';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
} else {
|
|
addLogEntry('error', 'Failed to download logs: HTTP ' + response.status);
|
|
}
|
|
} catch (error) {
|
|
console.error('Download logs error:', error);
|
|
addLogEntry('error', 'Failed to download logs: ' + error.message);
|
|
}
|
|
} |