commit 612ae128637e9f1e103d97390db11103c17452ef Author: MCHost Date: Mon Jun 16 10:11:55 2025 -0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f5e467 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +package-lock.json +.env \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..06b4477 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "my-mc-panel", + "version": "1.0.0", + "description": "Web panel for My-MC API with Docker integration", + "main": "server.js", + "scripts": { + "start": "node server.js", + "build:css": "postcss public/css/styles.css -o public/css/styles.min.css", + "watch:css": "postcss public/css/styles.css -o public/css/styles.min.css --watch" + }, + "dependencies": { + "@tailwindcss/cli": "^4.1.8", + "@tailwindcss/postcss": "^4.1.8", + "cors": "^2.8.5", + "dockerode": "^4.0.2", + "dotenv": "^16.5.0", + "express": "^4.21.0", + "node-fetch": "^2.7.0", + "ssh2-sftp-client": "^12.0.0", + "unirest": "^0.6.0", + "ws": "^8.18.2" + }, + "devDependencies": { + "autoprefixer": "^10.4.21", + "postcss": "^8.5.4", + "postcss-cli": "^11.0.1", + "tailwindcss": "^4.1.8" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2ce651a --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, + } \ No newline at end of file diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..543cf66 --- /dev/null +++ b/public/app.js @@ -0,0 +1,1405 @@ +document.addEventListener('DOMContentLoaded', () => { + let apiKey = localStorage.getItem('apiKey') || ''; + let ws = null; + const pendingRequests = new Map(); + let responseTimeout = null; + let terminal = null; + let fitAddon = null; + let memoryMeter = null; + let cpuMeter = null; + let currentPage = 1; + let totalResults = 0; + const resultsPerPage = 10; + let allProperties = {}; + const filteredSettings = [ + 'bug-report-link', + 'query.port', + 'rcon.password', + 'region-file-compression', + 'rcon.port', + 'server-port', + 'level-type', + 'enable-rcon', + 'enable-status' + ]; + + const elements = { + loginPage: document.getElementById('loginPage'), + loginApiKey: document.getElementById('loginApiKey'), + loginBtn: document.getElementById('loginBtn'), + loginError: document.getElementById('loginError'), + authControls: document.getElementById('authControls'), + apiKeyInput: document.getElementById('apiKey'), + user: document.getElementById('user'), + serverStatus: document.getElementById('serverStatus'), + memoryPercent: document.getElementById('memoryPercent'), + cpuPercent: document.getElementById('cpuPercent'), + keyExpiry: document.getElementById('keyExpiry'), + playerList: document.getElementById('playerList'), + modList: document.getElementById('modList'), + logUrl: document.getElementById('logUrl'), + websiteUrl: document.getElementById('websiteUrl'), + mapUrl: document.getElementById('mapUrl'), + myLink: document.getElementById('myLink'), + geyserLink: document.getElementById('geyserLink'), + sftpLink: document.getElementById('sftpLink'), + modResults: document.getElementById('modResults'), + consoleOutput: document.getElementById('consoleOutput'), + consoleInput: document.getElementById('consoleInput'), + dockerLogsTerminal: document.getElementById('dockerLogsTerminal'), + mainContent: document.getElementById('mainContent'), + notificationContainer: document.getElementById('notificationContainer'), + generateMyLinkBtn: document.getElementById('generateMyLinkBtn'), + generateGeyserLinkBtn: document.getElementById('generateGeyserLinkBtn'), + generateSftpLinkBtn: document.getElementById('generateSftpLinkBtn'), + pagination: document.getElementById('pagination'), + modSearch: document.getElementById('modSearch'), + closeSearchBtn: document.getElementById('closeSearchBtn'), + tellModal: document.getElementById('tellModal'), + tellPlayerName: document.getElementById('tellPlayerName'), + tellMessage: document.getElementById('tellMessage'), + tellForm: document.getElementById('tellForm'), + giveModal: document.getElementById('giveModal'), + givePlayerName: document.getElementById('givePlayerName'), + loadoutSelect: document.getElementById('loadoutSelect'), + customGiveFields: document.getElementById('customGiveFields'), + giveForm: document.getElementById('giveForm'), + addItemBtn: document.getElementById('addItemBtn'), + itemList: document.getElementById('itemList'), + editPropertiesBtn: document.getElementById('editPropertiesBtn'), + editPropertiesModal: document.getElementById('editPropertiesModal'), + propertiesFields: document.getElementById('propertiesFields'), + editPropertiesForm: document.getElementById('editPropertiesForm'), + stopBtn: document.getElementById('stopBtn'), + restartBtn: document.getElementById('restartBtn'), + connectionStatus: document.getElementById('connectionStatus'), + geyserStatus: document.getElementById('geyserStatus'), + sftpStatus: document.getElementById('sftpStatus') + }; + + const loadouts = { + starter: [ + { item: 'torch', amount: 16 }, + { item: 'cooked_beef', amount: 8 }, + { item: 'stone_pickaxe', amount: 1 } + ], + builder: [ + { item: 'stone', amount: 64 }, + { item: 'oak_planks', amount: 64 }, + { item: 'glass', amount: 32 }, + { item: 'ladder', amount: 16 } + ], + combat: [ + { item: 'iron_sword', amount: 1 }, + { item: 'leather_chestplate', amount: 1 }, + { item: 'shield', amount: 1 }, + { item: 'arrow', amount: 32 } + ], + miner: [ + { item: 'iron_pickaxe', amount: 1, enchantments: [{ name: 'efficiency', level: 2 }] }, + { item: 'torch', amount: 64 }, + { item: 'iron_shovel', amount: 1 }, + { item: 'bucket', amount: 1 } + ], + adventurer: [ + { item: 'bow', amount: 1, enchantments: [{ name: 'power', level: 1 }] }, + { item: 'arrow', amount: 64 }, + { item: 'compass', amount: 1 }, + { item: 'map', amount: 1 } + ], + alchemist: [ + { item: 'brewing_stand', amount: 1 }, + { item: 'potion', amount: 3, type: 'healing' }, + { item: 'potion', amount: 2, type: 'swiftness' }, + { item: 'nether_wart', amount: 16 } + ], + enchanter: [ + { item: 'enchanting_table', amount: 1 }, + { item: 'book', amount: 10 }, + { item: 'lapis_lazuli', amount: 32 }, + { item: 'experience_bottle', amount: 16 } + ], + farmer: [ + { item: 'wheat_seeds', amount: 32 }, + { item: 'iron_hoe', amount: 1 }, + { item: 'bone_meal', amount: 16 }, + { item: 'carrot', amount: 8 } + ], + nether: [ + { item: 'potion', amount: 2, type: 'fire_resistance' }, + { item: 'obsidian', amount: 10 }, + { item: 'flint_and_steel', amount: 1 }, + { item: 'golden_apple', amount: 1 } + ], + end: [ + { item: 'ender_pearl', amount: 16 }, + { item: 'blaze_rod', amount: 8 }, + { item: 'diamond_sword', amount: 1, enchantments: [{ name: 'sharpness', level: 2 }] }, + { item: 'pumpkin', amount: 1 } + ] + }; + + let state = { + user: '', + serverStatus: '', + memoryPercent: 0, + cpuPercent: 0, + keyExpiry: '', + playerList: '', + modListHtml: '', + logUrl: '', + websiteUrl: '', + mapUrl: '', + myLink: '', + geyserLink: '', + sftpLink: '', + hasShownStartNotification: false, + connectionStatus: '', + geyserStatus: '', + sftpStatus: '' + }; + + function showNotification(message, type = 'loading') { + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.innerHTML = ` + ${type === 'loading' ? '
' : ''} + ${message} + `; + elements.notificationContainer.appendChild(notification); + if (type !== 'loading') { + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => notification.remove(), 300); + }, 3000); + } + return notification; + } + + function updateNotification(notification, message, type) { + notification.className = `notification ${type}`; + notification.innerHTML = `${message}`; + if (type !== 'loading') { + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => notification.remove(), 300); + }, 3000); + } + } + + function initializeCharts() { + if (memoryMeter || cpuMeter) { + //console.log('Charts already initialized, skipping'); + return; + } + + const memoryCanvas = document.getElementById('memoryMeter'); + if (!memoryCanvas) { + console.error('Memory Meter canvas not found'); + showNotification('Failed to initialize memory chart: Canvas not found', 'error'); + return; + } + const memoryCtx = memoryCanvas.getContext('2d'); + if (!memoryCtx) { + console.error('Failed to acquire 2D context for Memory Meter'); + showNotification('Failed to initialize memory chart: Invalid canvas context', 'error'); + return; + } + memoryMeter = new Chart(memoryCtx, { + type: 'doughnut', + data: { + datasets: [{ + data: [0, 100], + backgroundColor: ['#EF4444', '#4B5563'], + borderWidth: 0, + circumference: 180, + rotation: 270 + }] + }, + options: { + cutout: '80%', + responsive: false, + plugins: { title: { display: false }, tooltip: { enabled: false } } + } + }); + + const cpuCanvas = document.getElementById('cpuMeter'); + if (!cpuCanvas) { + console.error('CPU Meter canvas not found'); + showNotification('Failed to initialize CPU chart: Canvas not found', 'error'); + return; + } + const cpuCtx = cpuCanvas.getContext('2d'); + if (!cpuCtx) { + console.error('Failed to acquire 2D context for CPU Meter'); + showNotification('Failed to initialize CPU chart: Invalid canvas context', 'error'); + return; + } + cpuMeter = new Chart(cpuCtx, { + type: 'doughnut', + data: { + datasets: [{ + data: [0, 600], // Initialize with max 600 for 6 cores + backgroundColor: ['#3B82F6', '#4B5563'], + borderWidth: 0, + circumference: 180, + rotation: 270 + }] + }, + options: { + cutout: '80%', + responsive: false, + plugins: { title: { display: false }, tooltip: { enabled: false } } + } + }); + } + + function initializeTerminal() { + if (terminal) { + terminal.clear(); + } else { + terminal = new Terminal({ + rows: 8, + fontSize: 14, + fontFamily: 'monospace', + theme: { + background: '#1f2937', + foreground: '#ffffff', + cursor: '#ffffff' + }, + scrollback: 1000, + rendererType: 'canvas' + }); + fitAddon = new FitAddon.FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(elements.dockerLogsTerminal); + terminal.element.style.border = 'none'; + } + fitAddon.fit(); + + window.addEventListener('resize', () => { + if (fitAddon && terminal) { + fitAddon.fit(); + } + }); + } + + function connectWebSocket() { + if (ws && ws.readyState === WebSocket.OPEN) { + //console.log('WebSocket already connected, skipping'); + return; + } + + ws = new WebSocket(`wss://${window.location.host}/ws?apiKey=${encodeURIComponent(apiKey)}`); + + ws.onopen = () => { + //console.log('WebSocket connected'); + showMainContent(); + ws.send(JSON.stringify({ + type: 'subscribe', + endpoints: ['docker', 'docker-logs', 'hello', 'time', 'list-players', 'mod-list', 'log', 'website', 'map', 'my-link-cache', 'my-geyser-cache', 'my-sftp-cache'] + })); + responseTimeout = setTimeout(() => { + showNotification('No response from server. Please check connection or API key.', 'error'); + handleLogout(); + }, 5000); + }; + + ws.onmessage = (event) => { + try { + clearTimeout(responseTimeout); + const message = JSON.parse(event.data); + //console.log('WebSocket message received:', message); + if (message.requestId && pendingRequests.has(message.requestId)) { + const { resolve, reject, notification } = pendingRequests.get(message.requestId); + pendingRequests.delete(message.requestId); + if (message.error) { + updateNotification(notification, `Error: ${message.error}`, 'error'); + if (message.error.includes('Missing token') || message.error.includes('HTTP 403')) { + showNotification('Invalid or missing API key. Please log in again.', 'error'); + elements.loginError.classList.remove('hidden'); + elements.loginError.textContent = 'Invalid API key. Please try again.'; + handleLogout(); + } + reject(new Error(message.error)); + } else { + updateNotification(notification, message.message || 'Action completed', 'success'); + resolve(message.data || message); + } + } else { + updateUI(message); + } + } catch (error) { + console.error('WebSocket message parsing error:', error); + showNotification('Error processing server data', 'error'); + } + }; + + ws.onclose = () => { + //console.log('WebSocket disconnected'); + showLoginPage(); + clearTimeout(responseTimeout); + if (terminal) { + terminal.clear(); + } + setTimeout(connectWebSocket, 3000); + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + showNotification('WebSocket connection error', 'error'); + }; + } + + function wsRequest(endpoint, method = 'GET', body = null) { + return new Promise((resolve, reject) => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + showNotification('No connection to server', 'error'); + reject(new Error('WebSocket not connected')); + return; + } + const requestId = crypto.randomUUID(); + const notification = showNotification(`${method} ${endpoint}...`); + pendingRequests.set(requestId, { resolve, reject, notification }); + ws.send(JSON.stringify({ type: 'request', requestId, endpoint, method, body })); + }); + } + + function updateUI(message) { + if (message.type === 'docker') { + updateDockerUI(message); + } else if (message.type === 'docker-logs') { + updateDockerLogsUI(message); + } else if (message.type === 'connection-status') { + updateConnectionStatusUI(message); + } else if (message.type === 'geyser-status') { + updateGeyserStatusUI(message); + } else if (message.type === 'sftp-status') { + updateSftpStatusUI(message); + } else { + updateNonDockerUI(message); + } + } + + + function updateDockerUI(message) { + //console.log('Updating Docker UI:', message); + if (message.error) { + if (elements.serverStatus) elements.serverStatus.textContent = 'Not Running'; + toggleSections('error'); + return; + } + + const memoryPercent = parseFloat(message.data?.memory?.percent) || 0; + if (state.memoryPercent !== memoryPercent && elements.memoryPercent && memoryMeter) { + //console.log(`Updating memory meter: ${memoryPercent}%`); + memoryMeter.data.datasets[0].data = [memoryPercent, 100 - memoryPercent]; + memoryMeter.update(); + elements.memoryPercent.textContent = `${memoryPercent.toFixed(1)}%`; + state.memoryPercent = memoryPercent; + } + + const cpuPercent = parseFloat(message.data?.cpu) || 0; + if (state.cpuPercent !== cpuPercent && elements.cpuPercent && cpuMeter) { + //console.log(`Updating CPU meter: ${cpuPercent}%`); + // Scale CPU percent to 0-100 for the chart (600% max becomes 100% on chart) + const scaledCpuPercent = Math.min((cpuPercent / 600) * 100, 100); + cpuMeter.data.datasets[0].data = [scaledCpuPercent, 100 - scaledCpuPercent]; + cpuMeter.update(); + // Display actual CPU percent (up to 600%) + elements.cpuPercent.textContent = `${cpuPercent.toFixed(1)}%`; + state.cpuPercent = cpuPercent; + } + + const status = message.data?.status || 'Unknown'; + if (state.serverStatus !== status && elements.serverStatus) { + //console.log(`Updating status: ${status}`); + elements.serverStatus.textContent = status; + state.serverStatus = status; + toggleSections(status); + } + } + + function toggleSections(status) { + const sections = document.querySelectorAll('.bg-gray-800.p-6.rounded-lg.shadow-lg.mb-6'); + const serverStatusSection = document.querySelector('.bg-gray-800.p-6.rounded-lg.shadow-lg.mb-6[data-section="server-status"]'); + const editPropertiesBtn = elements.editPropertiesBtn; + const startBtn = document.getElementById('startBtn'); + const stopBtn = elements.stopBtn; + const restartBtn = elements.restartBtn; + + if (startBtn) { + if (status.toLowerCase() === 'running') { + startBtn.disabled = true; + startBtn.classList.add('disabled-btn'); + } else { + startBtn.disabled = false; + startBtn.classList.remove('disabled-btn'); + } + } + + if (status.toLowerCase() !== 'running') { + sections.forEach(section => { + if (section !== serverStatusSection) { + section.classList.add('hidden'); + } + }); + if (editPropertiesBtn) { + editPropertiesBtn.classList.add('hidden'); + } + if (stopBtn) { + stopBtn.disabled = true; + stopBtn.classList.add('disabled-btn'); + } + if (restartBtn) { + restartBtn.disabled = true; + restartBtn.classList.add('disabled-btn'); + } + if (!state.hasShownStartNotification) { + showNotification('Server is not running. Please click the "Start" button to enable all features.', 'error'); + state.hasShownStartNotification = true; + } + } else { + sections.forEach(section => { + section.classList.remove('hidden'); + }); + if (editPropertiesBtn) { + editPropertiesBtn.classList.remove('hidden'); + } + if (stopBtn) { + stopBtn.disabled = false; + stopBtn.classList.remove('disabled-btn'); + } + if (restartBtn) { + restartBtn.disabled = false; + restartBtn.classList.remove('disabled-btn'); + } + state.hasShownStartNotification = false; + } + } + + function updateDockerLogsUI(message) { + //console.log('Updating Docker Logs UI:', message); + if (message.error) { + return; + } + if (message.data?.log && terminal) { + const logLines = message.data.log.split('\n'); + const cleanedLog = logLines + .map(line => + line + .replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s*/, '') + .replace(/\u001b\[32m0\|MineCraft Server\s*\|\s*\u001b\[39m/, '') + ) + .join('\n'); + terminal.write(cleanedLog); + const lines = terminal.buffer.active.length; + if (lines > 8) { + terminal.scrollToLine(lines - 8); + } + if (fitAddon) { + fitAddon.fit(); + } + } + } + + function updateConnectionStatusUI(message) { + //console.log('Updating Connection Status UI:', message); + if (message.data?.isOnline !== undefined && elements.connectionStatus) { + const statusIcon = message.data.isOnline ? '✅' : '❌'; + if (state.connectionStatus !== statusIcon) { + elements.connectionStatus.textContent = statusIcon; + state.connectionStatus = statusIcon; + } + } + } + + function updateGeyserStatusUI(message) { + //console.log('Updating Geyser Status UI:', message); + if (message.data?.isOnline !== undefined && elements.geyserStatus) { + const statusIcon = message.data.isOnline ? '✅' : '❌'; + if (state.geyserStatus !== statusIcon) { + elements.geyserStatus.textContent = statusIcon; + state.geyserStatus = statusIcon; + } + } + } + + function updateSftpStatusUI(message) { + //console.log('Updating SFTP Status UI:', message); + if (message.data?.isOnline !== undefined && elements.sftpStatus) { + const statusIcon = message.data.isOnline ? '✅' : '❌'; + if (state.sftpStatus !== statusIcon) { + elements.sftpStatus.textContent = statusIcon; + state.sftpStatus = statusIcon; + } + } + } + + function updateNonDockerUI(message) { + //console.log('Updating Non-Docker UI:', message); + if (message.error) { + if (message.error.includes('Missing token') || message.error.includes('HTTP 403')) { + showNotification('Invalid or missing API key. Please log in again.', 'error'); + elements.loginError.classList.remove('hidden'); + elements.loginError.textContent = 'Invalid API key. Please try again.'; + handleLogout(); + } + return; + } + + if (message.type === 'hello' && message.data?.message) { + const user = message.data.message.split(', ')[1]?.replace('!', '').trim() || 'Unknown'; + if (state.user !== user && elements.user) { + //console.log(`Updating user: ${user}`); + elements.user.textContent = user; + state.user = user; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'updateUser', user })); + } + } + } + + if (message.type === 'time' && message.data?.keyexpireString) { + if (state.keyExpiry !== message.data.keyexpireString && elements.keyExpiry) { + //console.log(`Updating key expiry: ${message.data.keyexpireString}`); + const expiryDate = new Date(message.data.keyexpireString); + const formattedDate = expiryDate.toLocaleString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + timeZoneName: 'short' + }); + elements.keyExpiry.textContent = formattedDate; + state.keyExpiry = message.data.keyexpireString; + } + } + + if (message.type === 'list-players' && message.data?.players) { + const players = message.data.players || []; + const playerListHtml = players.length > 0 + ? players.map(player => ` +
+ ${player} +
+ + + + + + +
+
+ `).join('') + : 'None'; + if (state.playerList !== playerListHtml && elements.playerList) { + //console.log(`Updating player list: ${playerListHtml}`); + elements.playerList.innerHTML = playerListHtml; + state.playerList = playerListHtml; + document.querySelectorAll('.kick-player').forEach(button => { + button.addEventListener('click', () => { + const player = button.getAttribute('data-player').trim(); + if (!player) { + showNotification('Invalid player name', 'error'); + return; + } + try { + const requestId = crypto.randomUUID(); + const notification = showNotification(`Kicking ${player}...`); + pendingRequests.set(requestId, { + resolve: () => { + updateNotification(notification, `${player} kicked`, 'success'); + wsRequest('/list-players').then(response => { + updateNonDockerUI({ type: 'list-players', data: response }); + }); + }, + reject: (error) => { + updateNotification(notification, `Failed to kick ${player}: ${error.message}`, 'error'); + }, + notification + }); + ws.send(JSON.stringify({ type: 'kick-player', requestId, player })); + } catch (error) { + console.error(`Kick player ${player} error:`, error); + showNotification(`Failed to kick ${player}: ${error.message}`, 'error'); + } + }); + }); + + document.querySelectorAll('.ban-player').forEach(button => { + button.addEventListener('click', () => { + const player = button.getAttribute('data-player').trim(); + if (!player) { + showNotification('Invalid player name', 'error'); + return; + } + try { + const requestId = crypto.randomUUID(); + const notification = showNotification(`Banning ${player}...`); + pendingRequests.set(requestId, { + resolve: () => { + updateNotification(notification, `${player} banned`, 'success'); + wsRequest('/list-players').then(response => { + updateNonDockerUI({ type: 'list-players', data: response }); + }); + }, + reject: (error) => { + updateNotification(notification, `Failed to ban ${player}: ${error.message}`, 'error'); + }, + notification + }); + ws.send(JSON.stringify({ type: 'ban-player', requestId, player })); + } catch (error) { + console.error(`Ban player ${player} error:`, error); + showNotification(`Failed to ban ${player}: ${error.message}`, 'error'); + } + }); + }); + + document.querySelectorAll('.op-player').forEach(button => { + button.addEventListener('click', () => { + const player = button.getAttribute('data-player').trim(); + if (!player) { + showNotification('Invalid player name', 'error'); + return; + } + try { + const requestId = crypto.randomUUID(); + const notification = showNotification(`Opping ${player}...`); + pendingRequests.set(requestId, { + resolve: () => { + updateNotification(notification, `${player} opped`, 'success'); + wsRequest('/list-players').then(response => { + updateNonDockerUI({ type: 'list-players', data: response }); + }); + }, + reject: (error) => { + updateNotification(notification, `Failed to op ${player}: ${error.message}`, 'error'); + }, + notification + }); + ws.send(JSON.stringify({ type: 'op-player', requestId, player })); + } catch (error) { + console.error(`Op player ${player} error:`, error); + showNotification(`Failed to op ${player}: ${error.message}`, 'error'); + } + }); + }); + + document.querySelectorAll('.deop-player').forEach(button => { + button.addEventListener('click', () => { + const player = button.getAttribute('data-player').trim(); + if (!player) { + showNotification('Invalid player name', 'error'); + return; + } + try { + const requestId = crypto.randomUUID(); + const notification = showNotification(`Deopping ${player}...`); + pendingRequests.set(requestId, { + resolve: () => { + updateNotification(notification, `${player} deopped`, 'success'); + wsRequest('/list-players').then(response => { + updateNonDockerUI({ type: 'list-players', data: response }); + }); + }, + reject: (error) => { + updateNotification(notification, `Failed to deop ${player}: ${error.message}`, 'error'); + }, + notification + }); + ws.send(JSON.stringify({ type: 'deop-player', requestId, player })); + } catch (error) { + console.error(`Deop player ${player} error:`, error); + showNotification(`Failed to deop ${player}: ${error.message}`, 'error'); + } + }); + }); + + document.querySelectorAll('.tell-player').forEach(button => { + button.addEventListener('click', () => { + const player = button.getAttribute('data-player').trim(); + if (!player) { + showNotification('Invalid player name', 'error'); + return; + } + elements.tellPlayerName.textContent = player; + elements.tellMessage.value = ''; + elements.tellModal.classList.remove('hidden'); + }); + }); + + document.querySelectorAll('.give-player').forEach(button => { + button.addEventListener('click', () => { + const player = button.getAttribute('data-player').trim(); + if (!player) { + showNotification('Invalid player name', 'error'); + return; + } + elements.givePlayerName.textContent = player; + elements.loadoutSelect.value = 'custom'; + elements.customGiveFields.classList.remove('hidden'); + resetItemFields(); + elements.giveModal.classList.remove('hidden'); + }); + }); + } + } + + if (message.type === 'mod-list' && message.data?.mods) { + const modListHtml = message.data.mods.map(mod => ` +
+

${mod.name} (${mod.version})

+

ID: ${mod.id}

+ +
+ `).join(''); + if (state.modListHtml !== modListHtml && elements.modList) { + //console.log('Updating mod list'); + elements.modList.innerHTML = modListHtml; + state.modListHtml = modListHtml; + document.querySelectorAll('.uninstall-mod').forEach(button => { + button.addEventListener('click', async () => { + const modId = button.getAttribute('data-mod-id'); + try { + await wsRequest('/uninstall', 'POST', { mod: modId }); + const response = await wsRequest('/mod-list'); + updateNonDockerUI({ type: 'mod-list', data: response }); + } catch (error) { + console.error('Uninstall mod error:', error); + } + }); + }); + } + } + + if (message.type === 'log' && message.data?.message) { + if (state.logUrl !== message.data.message && elements.logUrl) { + //console.log(`Updating log URL: ${message.data.message}`); + elements.logUrl.href = message.data.message; + elements.logUrl.textContent = message.data.message; + state.logUrl = message.data.message; + } + } + + if (message.type === 'website' && message.data?.message) { + if (state.websiteUrl !== message.data.message && elements.websiteUrl) { + //console.log(`Updating website URL: ${message.data.message}`); + elements.websiteUrl.href = message.data.message; + elements.websiteUrl.textContent = message.data.message; + state.websiteUrl = message.data.message; + } + } + + if (message.type === 'map' && message.data?.message) { + if (state.mapUrl !== message.data.message && elements.mapUrl) { + //console.log(`Updating map URL: ${message.data.message}`); + elements.mapUrl.href = message.data.message; + elements.mapUrl.textContent = message.data.message; + state.mapUrl = message.data.message; + } + } + + if (message.type === 'my-link-cache' && message.data?.hostname && message.data?.port) { + const myLinkText = `${message.data.hostname}:${message.data.port}`; + if (state.myLink !== myLinkText && elements.myLink) { + //console.log(`Updating my link: ${myLinkText}`); + elements.myLink.textContent = myLinkText; + state.myLink = myLinkText; + } + } + + if (message.type === 'my-geyser-cache' && message.data?.hostname && message.data?.port) { + const geyserLinkText = `${message.data.hostname}:${message.data.port}`; + if (state.geyserLink !== geyserLinkText && elements.geyserLink) { + //console.log(`Updating geyser link: ${geyserLinkText}`); + elements.geyserLink.textContent = geyserLinkText; + state.geyserLink = geyserLinkText; + } + } + + if (message.type === 'my-sftp-cache' && message.data?.hostname && message.data?.port && message.data?.user && message.data?.password) { + const sftpLinkText = `${message.data.hostname}:${message.data.port} (Auth: User: ${message.data.user} | Pass: ${message.data.password})`; + if (state.sftpLink !== sftpLinkText && elements.sftpLink) { + //console.log(`Updating SFTP link: ${sftpLinkText}`); + elements.sftpLink.textContent = sftpLinkText; + state.sftpLink = sftpLinkText; + } + } + + if (message.type === 'my-link' && message.data?.hostname && message.data?.port) { + const myLinkText = `${message.data.hostname}:${message.data.port}`; + if (state.myLink !== myLinkText && elements.myLink) { + //console.log(`Updating my link from generate: ${myLinkText}`); + elements.myLink.textContent = myLinkText; + state.myLink = myLinkText; + } + } + + if (message.type === 'my-geyser-link' && message.data?.hostname && message.data?.port) { + const geyserLinkText = `${message.data.hostname}:${message.data.port}`; + if (state.geyserLink !== geyserLinkText && elements.geyserLink) { + //console.log(`Updating geyser link from generate: ${geyserLinkText}`); + elements.geyserLink.textContent = geyserLinkText; + state.geyserLink = geyserLinkText; + } + } + + if (message.type === 'my-sftp' && message.data?.hostname && message.data?.port && message.data?.user) { + const sftpLinkText = `${message.data.hostname}:${message.data.port} (User: ${message.data.user})`; + if (state.sftpLink !== sftpLinkText && elements.sftpLink) { + //console.log(`Updating SFTP link from generate: ${sftpLinkText}`); + elements.sftpLink.textContent = sftpLinkText; + state.sftpLink = sftpLinkText; + } + } + } + + function showLoginPage() { + if (!elements.loginPage || !elements.mainContent) { + console.error('Required elements not found:', { + loginPage: elements.loginPage, + mainContent: elements.mainContent + }); + showNotification('Error: Page elements not found. Please refresh the page.', 'error'); + return; + } + elements.loginPage.classList.remove('hidden'); + elements.mainContent.classList.add('hidden'); + elements.authControls.innerHTML = ''; + elements.apiKeyInput = document.getElementById('apiKey'); + elements.apiKeyInput.addEventListener('change', handleApiKeyChange); + if (ws) { + ws.close(); + ws = null; + } + if (memoryMeter) { + memoryMeter.destroy(); + memoryMeter = null; + } + if (cpuMeter) { + cpuMeter.destroy(); + cpuMeter = null; + } + if (terminal) { + terminal.clear(); + } + } + + function showMainContent() { + if (!elements.loginPage || !elements.mainContent) { + console.error('Required elements not found:', { + loginPage: elements.loginPage, + mainContent: elements.mainContent + }); + showNotification('Error: Page elements not found. Please refresh the page.', 'error'); + return; + } + elements.loginPage.classList.add('hidden'); + elements.mainContent.classList.remove('hidden'); + elements.authControls.innerHTML = ''; + const logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', handleLogout); + } else { + console.error('Logout button not found after insertion'); + } + initializeCharts(); + initializeTerminal(); + } + + function handleApiKeyChange(e) { + apiKey = e.target.value.trim(); + if (apiKey) { + localStorage.setItem('apiKey', apiKey); + elements.loginError.classList.add('hidden'); + connectWebSocket(); + } + } + + function handleLogout() { + apiKey = ''; + localStorage.removeItem('apiKey'); + showLoginPage(); + } + + function updatePagination() { + //console.log(`Updating pagination: totalResults=${totalResults}, currentPage=${currentPage}`); + const totalPages = Math.max(1, Math.ceil(totalResults / resultsPerPage)); + elements.pagination.innerHTML = ''; + + const createPageButton = (page, text, disabled = false) => { + const button = document.createElement('button'); + button.textContent = text; + button.className = `px-3 py-1 rounded ${disabled || page === currentPage + ? 'bg-gray-600 cursor-not-allowed' + : 'bg-blue-600 hover:bg-blue-700' + }`; + if (!disabled && page !== currentPage) { + button.addEventListener('click', () => { + //console.log(`Navigating to page ${page}`); + currentPage = page; + searchMods(); + }); + } + elements.pagination.appendChild(button); + }; + + if (totalResults > 0) { + createPageButton(currentPage - 1, 'Previous', currentPage === 1); + const maxButtons = 5; + const startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2)); + const endPage = Math.min(totalPages, startPage + maxButtons - 1); + for (let i = startPage; i <= endPage; i++) { + createPageButton(i, i.toString()); + } + createPageButton(currentPage + 1, 'Next', currentPage === totalPages); + } + //console.log(`Pagination updated: ${totalPages} pages`); + } + + function closeSearch() { + //console.log('Closing search results'); + elements.modSearch.value = ''; + elements.modResults.innerHTML = ''; + elements.pagination.innerHTML = ''; + elements.closeSearchBtn.classList.add('hidden'); + currentPage = 1; + totalResults = 0; + } + + async function searchMods(page = currentPage) { + currentPage = page; + const mod = elements.modSearch.value.trim(); + if (mod) { + try { + const offset = (currentPage - 1) * resultsPerPage; + //console.log(`Searching mods: mod=${mod}, offset=${offset}, page=${currentPage}`); + const response = await wsRequest('/search', 'POST', { mod, offset }); + //console.log('Search response:', response); + if (elements.modResults) { + totalResults = response.totalHits || response.results?.length || 0; + //console.log(`Total results set to: ${totalResults}`); + elements.modResults.innerHTML = response.results?.length > 0 ? response.results.map(result => ` +
+

${result.title}

+

${result.description}

+

Downloads: ${result.downloads}

+ +
+ `).join('') : '

No results found.

'; + updatePagination(); + elements.closeSearchBtn.classList.remove('hidden'); + document.querySelectorAll('.install-mod').forEach(button => { + button.addEventListener('click', async () => { + const modId = button.getAttribute('data-mod-id'); + try { + await wsRequest('/install', 'POST', { mod: modId }); + const modResponse = await wsRequest('/mod-list'); + updateNonDockerUI({ type: 'mod-list', data: modResponse }); + } catch (error) { + console.error('Install mod error:', error); + showNotification('Failed to install mod', 'error'); + } + }); + }); + } + } catch (error) { + console.error('Search mods error:', error); + showNotification('Failed to search mods', 'error'); + } + } else { + showNotification('Please enter a search term', 'error'); + } + } + + async function sendConsoleCommand() { + const command = elements.consoleInput.value.trim(); + if (command) { + try { + const response = await wsRequest('/console', 'POST', { command }); + if (elements.consoleOutput) { + elements.consoleOutput.textContent += `> ${command}\n${response.message}\n`; + elements.consoleInput.value = ''; + } + } catch (error) { + if (elements.consoleOutput) { + elements.consoleOutput.textContent += `> ${command}\nError: ${error.message}\n`; + } + console.error('Console command error:', error); + } + } + } + + async function sendTellMessage() { + const player = elements.tellPlayerName.textContent.trim(); + const message = elements.tellMessage.value.trim(); + if (!player || !message) { + showNotification('Player name and message are required', 'error'); + return; + } + try { + const requestId = crypto.randomUUID(); + const notification = showNotification(`Sending message to ${player}...`); + pendingRequests.set(requestId, { + resolve: () => { + updateNotification(notification, `Message sent to ${player}`, 'success'); + elements.tellModal.classList.add('hidden'); + }, + reject: (error) => { + updateNotification(notification, `Failed to send message: ${error.message}`, 'error'); + }, + notification + }); + ws.send(JSON.stringify({ type: 'tell-player', requestId, player, message })); + } catch (error) { + console.error(`Send message to ${player} error:`, error); + showNotification(`Failed to send message: ${error.message}`, 'error'); + } + } + + function addItemField() { + const itemList = elements.itemList; + const itemEntry = document.createElement('div'); + itemEntry.className = 'item-entry flex space-x-2 items-center'; + itemEntry.innerHTML = ` + + + + `; + itemList.appendChild(itemEntry); + + itemEntry.querySelector('.remove-item').addEventListener('click', () => { + itemEntry.remove(); + }); + } + + function resetItemFields() { + elements.itemList.innerHTML = ''; + addItemField(); + } + + async function sendGiveCommand() { + const player = elements.givePlayerName.textContent.trim(); + const loadout = elements.loadoutSelect.value; + let items = []; + + if (loadout === 'custom') { + const itemEntries = document.querySelectorAll('.item-entry'); + items = Array.from(itemEntries).map(entry => { + const item = entry.querySelector('.item-name').value.trim(); + const amount = parseInt(entry.querySelector('.item-amount').value, 10); + return { item, amount }; + }).filter(itemData => itemData.item && itemData.amount > 0); + + if (items.length === 0) { + showNotification('At least one valid item and amount are required for custom give', 'error'); + return; + } + } else { + items = loadouts[loadout] || []; + } + + try { + const notification = showNotification(`Giving items to ${player}...`); + for (const itemData of items) { + const { item, amount, enchantments, type } = itemData; + const requestId = crypto.randomUUID(); + pendingRequests.set(requestId, { + resolve: () => { + updateNotification(notification, `Gave ${amount} ${item}${type ? ` (${type})` : ''} to ${player}`, 'success'); + }, + reject: (error) => { + updateNotification(notification, `Failed to give ${item}: ${error.message}`, 'error'); + }, + notification + }); + ws.send(JSON.stringify({ + type: 'give-player', + requestId, + player, + item, + amount, + enchantments: enchantments || undefined, + potionType: type || undefined + })); + } + elements.giveModal.classList.add('hidden'); + } catch (error) { + console.error(`Give items to ${player} error:`, error); + showNotification(`Failed to give items: ${error.message}`, 'error'); + } + } + + function parseServerProperties(content) { + const properties = {}; + const lines = content.split('\n'); + lines.forEach(line => { + if (line.trim() && !line.trim().startsWith('#')) { + const [key, value] = line.split('=', 2).map(part => part.trim()); + if (key && value !== undefined) { + properties[key] = value; + } + } + }); + return properties; + } + + function renderPropertiesFields(properties) { + const fieldsContainer = elements.propertiesFields; + fieldsContainer.innerHTML = ''; + + Object.entries(properties).forEach(([key, value]) => { + if (filteredSettings.includes(key)) { + return; + } + + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'flex items-center space-x-2'; + + let inputType = 'text'; + let isBoolean = value.toLowerCase() === 'true' || value.toLowerCase() === 'false'; + if (isBoolean) { + inputType = 'checkbox'; + } else if (/^\d+$/.test(value) && !isNaN(parseInt(value))) { + inputType = 'number'; + } + + const label = document.createElement('label'); + label.textContent = key; + label.className = 'w-1/3 text-sm font-medium'; + label.setAttribute('for', `prop-${key}`); + + const input = document.createElement('input'); + input.id = `prop-${key}`; + input.name = key; + input.className = 'bg-gray-700 px-4 py-2 rounded text-white w-2/3'; + + if (inputType === 'checkbox') { + input.type = 'checkbox'; + input.checked = value.toLowerCase() === 'true'; + } else { + input.type = inputType; + input.value = value; + if (inputType === 'number') { + input.min = '0'; + } + } + + fieldDiv.appendChild(label); + fieldDiv.appendChild(input); + fieldsContainer.appendChild(fieldDiv); + }); + } + + function propertiesToString(properties) { + let header = `#Minecraft server properties\n#${new Date().toUTCString()}\n`; + return header + Object.entries(properties) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + } + + async function fetchServerProperties() { + try { + const response = await wsRequest('/server-properties', 'GET'); + if (response.error) { + showNotification(`Failed to load server.properties: ${response.error}`, 'error'); + return; + } + if (response.content && response.content.length > 4000) { + showNotification(`File too large to edit (${response.content.length} characters, max 4000)`, 'error'); + return; + } + allProperties = parseServerProperties(response.content || ''); + const displayProperties = Object.fromEntries( + Object.entries(allProperties).filter(([key]) => !filteredSettings.includes(key)) + ); + renderPropertiesFields(displayProperties); + elements.editPropertiesModal.classList.remove('hidden'); + } catch (error) { + console.error('Fetch server properties error:', error); + showNotification(`Failed to load server.properties: ${error.message}`, 'error'); + } + } + + async function saveServerProperties() { + try { + const properties = {}; + const inputs = elements.propertiesFields.querySelectorAll('input'); + inputs.forEach(input => { + const key = input.name; + let value = input.type === 'checkbox' ? input.checked.toString() : input.value.trim(); + if (value !== '') { + properties[key] = value; + } + }); + + const fullProperties = { ...allProperties, ...properties }; + const content = propertiesToString(fullProperties); + const response = await wsRequest('/server-properties', 'POST', { content }); + if (response.error) { + updateNotification(notification, `Failed to save server.properties: ${response.error}`, 'error'); + return; + } + elements.editPropertiesModal.classList.add('hidden'); + } catch (error) { + console.error('Save server properties error:', error); + showNotification(`Failed to save server.properties: ${error.message}`, 'error'); + } + } + + elements.loginBtn.addEventListener('click', () => { + apiKey = elements.loginApiKey.value.trim(); + if (apiKey) { + localStorage.setItem('apiKey', apiKey); + elements.loginError.classList.add('hidden'); + connectWebSocket(); + } else { + elements.loginError.classList.remove('hidden'); + elements.loginError.textContent = 'Please enter an API key.'; + } + }); + + elements.apiKeyInput.addEventListener('change', handleApiKeyChange); + + elements.generateMyLinkBtn.addEventListener('click', async () => { + try { + await wsRequest('/my-link'); + } catch (error) { + console.error('Generate connection link error:', error); + } + }); + + elements.generateGeyserLinkBtn.addEventListener('click', async () => { + try { + await wsRequest('/my-geyser-link'); + } catch (error) { + console.error('Generate geyser link error:', error); + } + }); + + elements.generateSftpLinkBtn.addEventListener('click', async () => { + try { + await wsRequest('/my-sftp'); + } catch (error) { + console.error('Generate SFTP link error:', error); + } + }); + + document.getElementById('refresh').addEventListener('click', async () => { + if (ws && ws.readyState === WebSocket.OPEN) { + const notification = showNotification('Refreshing data...'); + ws.send(JSON.stringify({ type: 'refresh' })); + initializeTerminal(); + setTimeout(() => updateNotification(notification, 'Data refresh requested', 'success'), 1000); + } else { + showNotification('WebSocket not connected', 'error'); + } + }); + + document.getElementById('startBtn').addEventListener('click', async () => { + try { + await wsRequest('/start'); + initializeTerminal(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'subscribe', endpoints: ['docker-logs'] })); + //console.log('Re-subscribed to docker-logs after starting server'); + } else { + console.warn('WebSocket not connected, cannot subscribe to docker-logs'); + } + } catch (error) { + console.error('Start server error:', error); + showNotification(`Failed to start server: ${error.message}`, 'error'); + } + }); + + document.getElementById('stopBtn').addEventListener('click', async () => { + try { + await wsRequest('/stop'); + } catch (error) { + console.error('Stop server error:', error); + } + }); + + document.getElementById('restartBtn').addEventListener('click', async () => { + try { + await wsRequest('/restart'); + initializeTerminal(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'subscribe', endpoints: ['docker-logs'] })); + } + } catch (error) { + console.error('Restart server error:', error); + } + }); + + document.getElementById('searchBtn').addEventListener('click', () => { + searchMods(1); + }); + + document.getElementById('sendConsole').addEventListener('click', sendConsoleCommand); + + elements.closeSearchBtn.addEventListener('click', closeSearch); + + elements.tellModal.querySelector('.modal-close').addEventListener('click', () => { + elements.tellModal.classList.add('hidden'); + }); + + elements.giveModal.querySelector('.modal-close').addEventListener('click', () => { + elements.giveModal.classList.add('hidden'); + }); + + elements.loadoutSelect.addEventListener('change', () => { + const isCustom = elements.loadoutSelect.value === 'custom'; + elements.customGiveFields.classList.toggle('hidden', !isCustom); + if (isCustom) { + resetItemFields(); + } + }); + + elements.tellMessage.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendTellMessage(); + } + }); + + elements.itemList.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.target.classList.contains('item-name') || e.target.classList.contains('item-amount'))) { + e.preventDefault(); + sendGiveCommand(); + } + }); + + elements.tellForm.addEventListener('submit', (e) => { + e.preventDefault(); + sendTellMessage(); + }); + + elements.giveForm.addEventListener('submit', (e) => { + e.preventDefault(); + sendGiveCommand(); + }); + + elements.addItemBtn.addEventListener('click', addItemField); + + elements.editPropertiesBtn.addEventListener('click', fetchServerProperties); + + elements.editPropertiesModal.querySelector('.modal-close').addEventListener('click', () => { + elements.editPropertiesModal.classList.add('hidden'); + }); + + elements.editPropertiesForm.addEventListener('submit', (e) => { + e.preventDefault(); + saveServerProperties(); + }); + + if (apiKey) { + connectWebSocket(); + } else { + showLoginPage(); + } +}); \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..965f91b --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,222 @@ +@import "tailwindcss"; + +@layer base { + /* Sticky footer base styles */ + html { + height: 100%; + } + + body { + min-height: 100%; + display: flex; + flex-direction: column; + } + + #app { + flex: 1 0 auto; /* Make #app grow to fill available space */ + display: flex; + flex-direction: column; + } + + main { + flex-grow: 1; /* Ensure main grows within #app */ + } + + footer { + flex-shrink: 0; /* Prevent footer from shrinking */ + } +} + +@layer components { + .spinner { + border: 4px solid rgba(255, 225, 225, 0.3); + border-top: 4px solid #ffffff; + border-radius: 50%; + width: 24px; + height: 24px; + animation: spin 1s linear infinite; + display: inline-block; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + #notificationContainer { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + } + + .notification { + background-color: #1f2937; + color: white; + padding: 16px; + border-radius: 8px; + margin-top: 8px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: opacity 0.3s ease; + } + + .notification.success { + background-color: #158106; + } + + .notification.error { + background-color: #b91c1c; + } + + #dockerLogsTerminal { + background-color: #1f2937; + padding: 1rem; + border-radius: 0.5rem; + max-height: 12rem; + width: 100%; + } + + .xterm .xterm-viewport { + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #4b5563 #1f2937; + } + + .xterm .xterm-viewport::-webkit-scrollbar { + width: 8px; + } + + .xterm .xterm-viewport::-webkit-scrollbar-track { + background: #1f2937; + } + + .xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 4px; + } + + .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: #6b7280; + } + + .xterm .xterm-screen { + width: 100% !important; + } + + .control-btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + line-height: 1.25rem; + transition: all 0.2s ease; + min-width: 80px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .control-btn:hover:not(.disabled-btn) { + transform: translateY(-1px); + } + + .control-btn:active:not(.disabled-btn) { + transform: translateY(0); + } + + .modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .modal-content { + background: #1f2937; + padding: 1.5rem; + border-radius: 0.5rem; + width: 100%; + max-width: 600px; + position: relative; + max-height: 80vh; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #4b5563 #1f2937; + } + + .modal-content::-webkit-scrollbar { + width: 8px; + } + + .modal-content::-webkit-scrollbar-track { + background: #1f2937; + } + + .modal-content::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 4px; + } + + .modal-content::-webkit-scrollbar-thumb:hover { + background: #6b7280; + } + + .modal-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: none; + border: none; + color: white; + font-size: 1.25rem; + cursor: pointer; + } + + .disabled-btn { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } +} + +@layer utilities { + /* Add any custom utilities if needed */ +} + +/* Media queries */ +@media (max-width: 640px) { + .bg-gray-800.p-6.rounded-lg.shadow-lg .grid { + grid-template-columns: 1fr; + } + + .bg-gray-800.p-6.rounded-lg.shadow-lg p { + display: flex; + flex-direction: column; + gap: 0.5rem; + word-break: break-all; + } + + .bg-gray-800.p-6.rounded-lg.shadow-lg a, + .bg-gray-800.p-6.rounded-lg.shadow-lg span { + word-break: break-all; + overflow-wrap: anywhere; + white-space: normal; + } + + .bg-gray-800.p-6.rounded-lg.shadow-lg button { + width: 100%; + margin-top: 0.5rem; + } +} + +/* Additional styles */ +.bg-gray-800.p-6.rounded-lg.shadow-lg .grid { + overflow-x: hidden; +} + +.bg-gray-800.p-6.rounded-lg.shadow p { + max-width: 100%; +} \ No newline at end of file diff --git a/public/css/styles.min.css b/public/css/styles.min.css new file mode 100644 index 0000000..5512e55 --- /dev/null +++ b/public/css/styles.min.css @@ -0,0 +1,871 @@ +/*! tailwindcss v4.1.8 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-yellow-600: oklch(68.1% 0.162 75.834); + --color-yellow-700: oklch(55.4% 0.135 66.442); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-700: oklch(52.7% 0.154 150.069); + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-purple-600: oklch(55.8% 0.288 302.321); + --color-purple-700: oklch(49.6% 0.265 301.924); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-gray-900: oklch(21% 0.034 264.665); + --color-white: #fff; + --spacing: 0.25rem; + --container-md: 28rem; + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --radius-lg: 0.5rem; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + /* vertical-align: middle; */ + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::-moz-placeholder { + opacity: 1; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::-moz-placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + -webkit-appearance: button; + -moz-appearance: button; + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .fixed { + position: fixed; + } + .static { + position: static; + } + .inset-0 { + inset: calc(var(--spacing) * 0); + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .mx-auto { + margin-inline: auto; + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + .block { + display: block; + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .h-24 { + height: calc(var(--spacing) * 24); + } + .h-48 { + height: calc(var(--spacing) * 48); + } + .h-full { + height: 100%; + } + .min-h-full { + min-height: 100%; + } + .w-1\/3 { + width: calc(1/3 * 100%); + } + .w-2\/3 { + width: calc(2/3 * 100%); + } + .w-20 { + width: calc(var(--spacing) * 20); + } + .w-32 { + width: calc(var(--spacing) * 32); + } + .w-full { + width: 100%; + } + .max-w-md { + max-width: var(--container-md); + } + .flex-grow { + flex-grow: 1; + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .cursor-not-allowed { + cursor: not-allowed; + } + .resize { + resize: both; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-x-2 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); + } + } + .space-x-4 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + } + .overflow-x-hidden { + overflow-x: hidden; + } + .overflow-y-auto { + overflow-y: auto; + } + .rounded { + border-radius: 0.25rem; + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .bg-blue-600 { + background-color: var(--color-blue-600); + } + .bg-gray-600 { + background-color: var(--color-gray-600); + } + .bg-gray-700 { + background-color: var(--color-gray-700); + } + .bg-gray-800 { + background-color: var(--color-gray-800); + } + .bg-gray-900 { + background-color: var(--color-gray-900); + } + .bg-green-600 { + background-color: var(--color-green-600); + } + .bg-purple-600 { + background-color: var(--color-purple-600); + } + .bg-red-600 { + background-color: var(--color-red-600); + } + .bg-yellow-600 { + background-color: var(--color-yellow-600); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .p-8 { + padding: calc(var(--spacing) * 8); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .text-center { + text-align: center; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .text-blue-400 { + color: var(--color-blue-400); + } + .text-gray-400 { + color: var(--color-gray-400); + } + .text-red-500 { + color: var(--color-red-500); + } + .text-white { + color: var(--color-white); + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .hover\:bg-blue-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-700); + } + } + } + .hover\:bg-gray-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-700); + } + } + } + .hover\:bg-green-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-green-700); + } + } + } + .hover\:bg-purple-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-purple-700); + } + } + } + .hover\:bg-red-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-red-700); + } + } + } + .hover\:bg-yellow-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-yellow-700); + } + } + } + .hover\:text-blue-500 { + &:hover { + @media (hover: hover) { + color: var(--color-blue-500); + } + } + } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .md\:grid-cols-3 { + @media (width >= 48rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } +} +@layer base { + html { + height: 100%; + } + body { + min-height: 100%; + display: flex; + flex-direction: column; + } + #app { + flex: 1 0 auto; + display: flex; + flex-direction: column; + } + main { + flex-grow: 1; + } + footer { + flex-shrink: 0; + } +} +@layer components { + .spinner { + border: 4px solid rgba(255, 225, 225, 0.3); + border-top: 4px solid #ffffff; + border-radius: 50%; + width: 24px; + height: 24px; + animation: spin 1s linear infinite; + display: inline-block; + } + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + #notificationContainer { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + } + .notification { + background-color: #1f2937; + color: white; + padding: 16px; + border-radius: 8px; + margin-top: 8px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: opacity 0.3s ease; + } + .notification.success { + background-color: #158106; + } + .notification.error { + background-color: #b91c1c; + } + #dockerLogsTerminal { + background-color: #1f2937; + padding: 1rem; + border-radius: 0.5rem; + max-height: 12rem; + width: 100%; + } + .xterm .xterm-viewport { + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #4b5563 #1f2937; + } + .xterm .xterm-viewport::-webkit-scrollbar { + width: 8px; + } + .xterm .xterm-viewport::-webkit-scrollbar-track { + background: #1f2937; + } + .xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 4px; + } + .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: #6b7280; + } + .xterm .xterm-screen { + width: 100% !important; + } + .control-btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + line-height: 1.25rem; + transition: all 0.2s ease; + min-width: 80px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + .control-btn:hover:not(.disabled-btn) { + transform: translateY(-1px); + } + .control-btn:active:not(.disabled-btn) { + transform: translateY(0); + } + .modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + .modal-content { + background: #1f2937; + padding: 1.5rem; + border-radius: 0.5rem; + width: 100%; + max-width: 600px; + position: relative; + max-height: 80vh; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #4b5563 #1f2937; + } + .modal-content::-webkit-scrollbar { + width: 8px; + } + .modal-content::-webkit-scrollbar-track { + background: #1f2937; + } + .modal-content::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 4px; + } + .modal-content::-webkit-scrollbar-thumb:hover { + background: #6b7280; + } + .modal-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: none; + border: none; + color: white; + font-size: 1.25rem; + cursor: pointer; + } + .disabled-btn { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } +} +@layer utilities; +@media (max-width: 640px) { + .bg-gray-800.p-6.rounded-lg.shadow-lg .grid { + grid-template-columns: 1fr; + } + .bg-gray-800.p-6.rounded-lg.shadow-lg p { + display: flex; + flex-direction: column; + gap: 0.5rem; + word-break: break-all; + } + .bg-gray-800.p-6.rounded-lg.shadow-lg a, + .bg-gray-800.p-6.rounded-lg.shadow-lg span { + word-break: break-all; + overflow-wrap: anywhere; + white-space: normal; + } + .bg-gray-800.p-6.rounded-lg.shadow-lg button { + width: 100%; + margin-top: 0.5rem; + } +} +.bg-gray-800.p-6.rounded-lg.shadow-lg .grid { + overflow-x: hidden; +} +.bg-gray-800.p-6.rounded-lg.shadow p { + max-width: 100%; +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-space-x-reverse: 0; + --tw-border-style: solid; + --tw-font-weight: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + } + } +} \ No newline at end of file diff --git a/public/favicon/android-chrome-192x192.png b/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..0f02c8c Binary files /dev/null and b/public/favicon/android-chrome-192x192.png differ diff --git a/public/favicon/android-chrome-512x512.png b/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..1be9760 Binary files /dev/null and b/public/favicon/android-chrome-512x512.png differ diff --git a/public/favicon/apple-touch-icon.png b/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000..9953847 Binary files /dev/null and b/public/favicon/apple-touch-icon.png differ diff --git a/public/favicon/favicon-16x16.png b/public/favicon/favicon-16x16.png new file mode 100644 index 0000000..222cae3 Binary files /dev/null and b/public/favicon/favicon-16x16.png differ diff --git a/public/favicon/favicon-32x32.png b/public/favicon/favicon-32x32.png new file mode 100644 index 0000000..b6f483c Binary files /dev/null and b/public/favicon/favicon-32x32.png differ diff --git a/public/favicon/favicon.ico b/public/favicon/favicon.ico new file mode 100644 index 0000000..47d7f0e Binary files /dev/null and b/public/favicon/favicon.ico differ diff --git a/public/favicon/site.webmanifest b/public/favicon/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/public/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..64f7cd7 --- /dev/null +++ b/public/index.html @@ -0,0 +1,221 @@ + + + + + + + My-MC Server Panel + + + + + + + + + + + + +
+ + +
+ + + + + + + + + +
+
+

Server Status

+
+
+

User: Loading...

+

Key Expiry: Loading...

+

Status: Loading...

+
+
+
+ +

Memory Usage

+

0%

+
+
+ +

CPU Usage

+

0%

+
+
+
+
+ + + +
+ +
+
+
+ +
+

Player Management

+
+

Connected Players:
Loading...

+
+ +
+

Server Logs

+
+
+ +
+

Server Console

+
+ + +
+

+      
+ +
+

Mod Management

+
+
+ + + +
+
+
+ +

Installed Mods

+
+
+ +
+

Server Links

+
+

Advanced Log URL: Loading...

+

Website URL: Loading...

+

BlueMap URL: Loading... +

+
+

Connection Link: Link Not Created

+ +
+
+

Geyser Link: Link Not Created

+ +
+
+

SFTP Link: Link Not Created

+ +
+
+
+
+ + +
+ + + + + \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..bbe9f7c --- /dev/null +++ b/server.js @@ -0,0 +1,1540 @@ +require('dotenv').config(); +const express = require('express'); +const Docker = require('dockerode'); +const fetch = require('node-fetch'); +const path = require('path'); +const cors = require('cors'); +const WebSocket = require('ws'); +const crypto = require('crypto'); +const unirest = require('unirest'); +const { exec } = require('child_process'); +const util = require('util'); +const fs = require('fs').promises; +const os = require('os'); +const net = require('net'); + +const execPromise = util.promisify(exec); + +const app = express(); +const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET_PATH }); +const API_URL = process.env.API_URL; +const PORT = parseInt(process.env.PORT, 10); +const ADMIN_SECRET_KEY = process.env.ADMIN_SECRET_KEY; +const LINK_EXPIRY_SECONDS = parseInt(process.env.LINK_EXPIRY_SECONDS, 10); +const temporaryLinks = new Map(); + +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +const wss = new WebSocket.Server({ noServer: true }); + +async function apiRequest(endpoint, apiKey, method = 'GET', body = null) { + const headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'x-my-mc-auth': apiKey + }; + try { + const response = await fetch(`${API_URL}${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : null + }); + const data = await response.json(); + if (!response.ok) { + console.error(`API error for ${endpoint}: ${response.status} - ${JSON.stringify(data)}`); + return { error: data.message || `HTTP ${response.status}` }; + } + return data; + } catch (error) { + console.error(`Network error for ${endpoint}:`, error.message); + return { error: `Network error: ${error.message}` }; + } +} + +async function getContainerStats(containerName) { + try { + const containers = await docker.listContainers({ all: true }); + const containerExists = containers.some(c => c.Names.includes(`/${containerName}`)); + if (!containerExists) { + console.error(`Container ${containerName} not found`); + return { error: `Container ${containerName} not found` }; + } + + const container = docker.getContainer(containerName); + const info = await container.inspect(); + const stats = await container.stats({ stream: false }); + + const memoryUsage = stats.memory_stats.usage / 1024 / 1024; + const memoryLimit = stats.memory_stats.limit / 1024 / 1024 / 1024; + const memoryPercent = ((memoryUsage / (memoryLimit * 1024)) * 100).toFixed(2); + + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats.cpu_usage?.total_usage || 0); + const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats.system_cpu_usage || 0); + const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100).toFixed(2) : 0; + + return { + status: info.State.Status, + memory: { raw: `${memoryUsage.toFixed(2)}MiB / ${memoryLimit.toFixed(2)}GiB`, percent: memoryPercent }, + cpu: cpuPercent + }; + } catch (error) { + console.error(`Docker stats error for ${containerName}:`, error.message); + return { error: `Failed to fetch stats for ${containerName}: ${error.message}` }; + } +} + +async function checkConnectionStatus(hostname, port) { + try { + const command = `${process.env.STATUS_CHECK_PATH} -host ${hostname} -port ${port}`; + console.log(`Executing status check: ${command}`); + const { stdout, stderr } = await execPromise(command); + if (stderr) { + console.error(`Status check error: ${stderr}`); + return { isOnline: false, error: stderr }; + } + const result = JSON.parse(stdout); + return { isOnline: true, data: result }; + } catch (error) { + console.error(`Status check failed: ${error.message}`); + return { isOnline: false, error: error.message }; + } +} + +async function checkGeyserStatus(hostname, port) { + try { + const command = `${process.env.GEYSER_STATUS_CHECK_PATH} -host ${hostname} -port ${port}`; + console.log(`Executing Geyser status check: ${command}`); + const { stdout, stderr } = await execPromise(command); + if (stderr) { + console.error(`Geyser status check error: ${stderr}`); + return { isOnline: false, error: stderr }; + } + const result = JSON.parse(stdout); + return { isOnline: true, data: result }; + } catch (error) { + console.error(`Geyser status check failed: ${error.message}`); + return { isOnline: false, error: error.message }; + } +} + +async function checkSftpStatus(hostname, port) { + return new Promise((resolve) => { + const socket = new net.Socket(); + const timeout = parseInt(process.env.SFTP_CONNECTION_TIMEOUT_MS, 10); + + socket.setTimeout(timeout); + + socket.on('connect', () => { + console.log(`SFTP port check succeeded for ${hostname}:${port}`); + socket.destroy(); + resolve({ isOnline: true }); + }); + + socket.on('timeout', () => { + console.error(`SFTP port check timed out for ${hostname}:${port}`); + socket.destroy(); + resolve({ isOnline: false, error: 'Connection timed out' }); + }); + + socket.on('error', (error) => { + console.error(`SFTP port check failed for ${hostname}:${port}: ${error.message}`); + socket.destroy(); + resolve({ isOnline: false, error: error.message }); + }); + + socket.connect(port, process.env.SFTP_HOSTNAME); + }); +} + +async function streamContainerLogs(ws, containerName, client) { + let isStreaming = true; + let isStartingStream = false; + + async function startLogStream() { + if (isStartingStream) { + console.log(`Stream start already in progress for ${containerName}`); + return false; + } + isStartingStream = true; + + try { + const containers = await docker.listContainers({ all: true }); + const containerExists = containers.some(c => c.Names.includes(`/${containerName}`)); + if (!containerExists) { + console.error(`Container ${containerName} not found for logs`); + if (isStreaming) { + ws.send(JSON.stringify({ type: 'docker-logs', error: `Container ${containerName} not found` })); + } + return false; + } + + const container = docker.getContainer(containerName); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + console.log(`Container ${containerName} is not running (status: ${inspect.State.Status})`); + if (isStreaming) { + ws.send(JSON.stringify({ type: 'docker-logs', error: `Container ${containerName} is not running` })); + } + return false; + } + + if (client.logStream) { + console.log(`Destroying existing log stream for ${containerName}`); + client.logStream.removeAllListeners(); + client.logStream.destroy(); + client.logStream = null; + } + + const logStream = await container.logs({ + follow: true, + stdout: true, + stderr: true, + tail: parseInt(process.env.LOG_STREAM_TAIL_LINES, 10), + timestamps: true + }); + + logStream.on('data', (chunk) => { + if (isStreaming && client.logStream === logStream) { + const log = chunk.toString('utf8'); + ws.send(JSON.stringify({ type: 'docker-logs', data: { log } })); + } + }); + + logStream.on('error', (error) => { + console.error(`Log stream error for ${containerName}:`, error.message); + if (isStreaming) { + ws.send(JSON.stringify({ type: 'docker-logs', error: `Log stream error: ${error.message}` })); + } + }); + + client.logStream = logStream; + console.log(`Log stream started for ${containerName}`); + return true; + } catch (error) { + console.error(`Error streaming logs for ${containerName}:`, error.message); + if (isStreaming) { + ws.send(JSON.stringify({ type: 'docker-logs', error: `Failed to stream logs: ${error.message}` })); + } + return false; + } finally { + isStartingStream = false; + } + } + + async function monitorContainer() { + try { + const container = docker.getContainer(containerName); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + console.log(`Container ${containerName} is not running (status: ${inspect.State.Status})`); + if (client.logStream) { + client.logStream.removeAllListeners(); + client.logStream.destroy(); + client.logStream = null; + } + return false; + } + return true; + } catch (error) { + console.error(`Error monitoring container ${containerName}:`, error.message); + return false; + } + } + + if (!(await startLogStream())) { + const monitorInterval = setInterval(async () => { + if (!isStreaming) { + clearInterval(monitorInterval); + return; + } + const isRunning = await monitorContainer(); + if (isRunning && !client.logStream && !isStartingStream) { + console.log(`Container ${containerName} is running, attempting to start log stream`); + await startLogStream(); + } else if (!isRunning) { + console.log(`Container ${containerName} not running, waiting for restart`); + } + }, parseInt(process.env.LOG_STREAM_MONITOR_INTERVAL_MS, 10)); + + ws.on('close', () => { + console.log(`WebSocket closed for ${containerName}, cleaning up`); + isStreaming = false; + clearInterval(monitorInterval); + if (client.logStream) { + client.logStream.removeAllListeners(); + client.logStream.destroy(); + client.logStream = null; + } + }); + + return; + } + + const monitorInterval = setInterval(async () => { + if (!isStreaming) { + clearInterval(monitorInterval); + return; + } + const isRunning = await monitorContainer(); + if (isRunning && !client.logStream && !isStartingStream) { + console.log(`Container ${containerName} is running, attempting to restart log stream`); + await startLogStream(); + } else if (!isRunning) { + console.log(`Container ${containerName} not running, waiting for restart`); + } + }, parseInt(process.env.LOG_STREAM_MONITOR_INTERVAL_MS, 10)); + + ws.on('close', () => { + console.log(`WebSocket closed for ${containerName}, cleaning up`); + isStreaming = false; + clearInterval(monitorInterval); + if (client.logStream) { + client.logStream.removeAllListeners(); + client.logStream.destroy(); + client.logStream = null; + } + }); +} + +const clients = new Map(); +const staticEndpoints = ['log', 'website', 'map', 'my-link-cache', 'my-geyser-cache', 'my-sftp-cache', 'my-link', 'my-geyser-link', 'my-sftp']; +const dynamicEndpoints = ['hello', 'time', 'mod-list']; + +wss.on('connection', (ws, req) => { + try { + console.log('WebSocket connection established'); + const urlParams = new URLSearchParams(req.url.split('?')[1]); + const apiKey = urlParams.get('apiKey'); + if (!apiKey) { + console.error('WebSocket connection rejected: Missing API key'); + ws.send(JSON.stringify({ error: 'API key required' })); + ws.close(); + return; + } + + clients.set(ws, { + apiKey, + subscriptions: new Set(), + user: null, + intervals: [], + logStream: null, + cache: {}, + connectionStatusInterval: null, + geyserStatusInterval: null, + sftpStatusInterval: null, + statusCheckMonitorInterval: null + }); + console.log('WebSocket client registered'); + + ws.on('message', async (message) => { + try { + const data = JSON.parse(message.toString()); + console.log('WebSocket message received:', data); + const client = clients.get(ws); + + if (data.type === 'subscribe') { + data.endpoints.forEach(endpoint => client.subscriptions.add(endpoint)); + console.log(`Client subscribed to: ${Array.from(client.subscriptions)}`); + + let hello = client.cache['hello']; + if (!hello) { + hello = await apiRequest('/hello', client.apiKey); + if (!hello.error) { + client.cache['hello'] = hello; + } + } + + if (hello.error) { + console.error('Failed to fetch /hello:', hello.error); + ws.send(JSON.stringify({ type: 'hello', error: hello.error })); + return; + } + + if (hello.message && typeof hello.message === 'string') { + const user = hello.message.split(', ')[1]?.replace('!', '').trim() || 'Unknown'; + client.user = user; + console.log(`User identified: ${user}`); + ws.send(JSON.stringify({ type: 'hello', data: hello })); + + async function manageStatusChecks() { + try { + const container = docker.getContainer(user); + const inspect = await container.inspect(); + const isRunning = inspect.State.Status === 'running'; + console.log(`Container ${user} status: ${inspect.State.Status}`); + + // Clear any existing status check intervals + if (client.connectionStatusInterval) { + clearInterval(client.connectionStatusInterval); + client.connectionStatusInterval = null; + } + if (client.geyserStatusInterval) { + clearInterval(client.geyserStatusInterval); + client.geyserStatusInterval = null; + } + if (client.sftpStatusInterval) { + clearInterval(client.sftpStatusInterval); + client.sftpStatusInterval = null; + } + + if (isRunning && user !== 'Unknown') { + if (client.subscriptions.has('my-link-cache')) { + console.log(`Starting connection status check for ${user}`); + client.connectionStatusInterval = setInterval(async () => { + try { + const containerCheck = docker.getContainer(user); + const inspectCheck = await containerCheck.inspect(); + if (inspectCheck.State.Status !== 'running') { + console.log(`Container ${user} stopped, clearing connection status interval`); + clearInterval(client.connectionStatusInterval); + client.connectionStatusInterval = null; + return; + } + const linkData = client.cache['my-link-cache']; + if (linkData && linkData.hostname && linkData.port) { + const status = await checkConnectionStatus(linkData.hostname, linkData.port); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: status.isOnline } })); + } + } catch (error) { + console.error(`Error in connection status check for ${user}:`, error.message); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: false } })); + } + }, parseInt(process.env.CONNECTION_STATUS_INTERVAL_MS, 10)); + client.intervals.push(client.connectionStatusInterval); + // Initial check + const linkData = client.cache['my-link-cache']; + if (linkData && linkData.hostname && linkData.port) { + console.log(`Performing initial connection status check for ${user}`); + const status = await checkConnectionStatus(linkData.hostname, linkData.port); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: status.isOnline } })); + } + } + + if (client.subscriptions.has('my-geyser-cache')) { + console.log(`Starting Geyser status check for ${user}`); + client.geyserStatusInterval = setInterval(async () => { + try { + const containerCheck = docker.getContainer(user); + const inspectCheck = await containerCheck.inspect(); + if (inspectCheck.State.Status !== 'running') { + console.log(`Container ${user} stopped, clearing Geyser status interval`); + clearInterval(client.geyserStatusInterval); + client.geyserStatusInterval = null; + return; + } + const geyserData = client.cache['my-geyser-cache']; + if (geyserData && geyserData.hostname && geyserData.port) { + const status = await checkGeyserStatus(geyserData.hostname, geyserData.port); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: status.isOnline } })); + } + } catch (error) { + console.error(`Error in Geyser status check for ${user}:`, error.message); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: false } })); + } + }, parseInt(process.env.GEYSER_STATUS_INTERVAL_MS, 10)); + client.intervals.push(client.geyserStatusInterval); + // Initial check + const geyserData = client.cache['my-geyser-cache']; + if (geyserData && geyserData.hostname && geyserData.port) { + console.log(`Performing initial Geyser status check for ${user}`); + const status = await checkGeyserStatus(geyserData.hostname, geyserData.port); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: status.isOnline } })); + } + } + + if (client.subscriptions.has('my-sftp-cache')) { + console.log(`Starting SFTP status check for ${user}`); + client.sftpStatusInterval = setInterval(async () => { + try { + const containerCheck = docker.getContainer(user); + const inspectCheck = await containerCheck.inspect(); + if (inspectCheck.State.Status !== 'running') { + console.log(`Container ${user} stopped, clearing SFTP status interval`); + clearInterval(client.sftpStatusInterval); + client.sftpStatusInterval = null; + return; + } + const sftpData = client.cache['my-sftp-cache']; + if (sftpData && sftpData.hostname && sftpData.port) { + const status = await checkSftpStatus(sftpData.hostname, sftpData.port); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: status.isOnline } })); + } + } catch (error) { + console.error(`Error in SFTP status check for ${user}:`, error.message); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: false } })); + } + }, parseInt(process.env.SFTP_STATUS_INTERVAL_MS, 10)); + client.intervals.push(client.sftpStatusInterval); + // Initial check + const sftpData = client.cache['my-sftp-cache']; + if (sftpData && sftpData.hostname && sftpData.port) { + console.log(`Performing initial SFTP status check for ${user}`); + const status = await checkSftpStatus(sftpData.hostname, sftpData.port); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: status.isOnline } })); + } + } + } else { + console.log(`Container ${user} is not running or user is Unknown, skipping status checks`); + if (client.subscriptions.has('my-link-cache')) { + ws.send(JSON.stringify({ type: 'connection-status', error: `Container ${user} is not running` })); + } + if (client.subscriptions.has('my-geyser-cache')) { + ws.send(JSON.stringify({ type: 'geyser-status', error: `Container ${user} is not running` })); + } + if (client.subscriptions.has('my-sftp-cache')) { + ws.send(JSON.stringify({ type: 'sftp-status', error: `Container ${user} is not running` })); + } + } + + // Clear any existing monitor interval + if (client.statusCheckMonitorInterval) { + clearInterval(client.statusCheckMonitorInterval); + client.statusCheckMonitorInterval = null; + } + + // Start monitoring if container is not running and status checks are subscribed + if (!isRunning && (client.subscriptions.has('my-link-cache') || client.subscriptions.has('my-geyser-cache') || client.subscriptions.has('my-sftp-cache')) && user !== 'Unknown') { + console.log(`Starting container status monitor for ${user}`); + client.statusCheckMonitorInterval = setInterval(async () => { + try { + const monitorContainer = docker.getContainer(user); + const monitorInspect = await monitorContainer.inspect(); + if (monitorInspect.State.Status === 'running') { + console.log(`Container ${user} is running, restarting status checks`); + await manageStatusChecks(); + clearInterval(client.statusCheckMonitorInterval); + client.statusCheckMonitorInterval = null; + } + } catch (error) { + console.error(`Error monitoring container ${user}:`, error.message); + } + }, parseInt(process.env.CONTAINER_STATUS_MONITOR_INTERVAL_MS, 10)); + client.intervals.push(client.statusCheckMonitorInterval); + } + } catch (error) { + console.error(`Error checking container status for ${user}:`, error.message); + if (client.subscriptions.has('my-link-cache')) { + ws.send(JSON.stringify({ type: 'connection-status', error: `Failed to check container status: ${error.message}` })); + } + if (client.subscriptions.has('my-geyser-cache')) { + ws.send(JSON.stringify({ type: 'geyser-status', error: `Failed to check container status: ${error.message}` })); + } + if (client.subscriptions.has('my-sftp-cache')) { + ws.send(JSON.stringify({ type: 'sftp-status', error: `Failed to check container status: ${error.message}` })); + } + } + } + + if (client.subscriptions.has('docker') && user !== 'Unknown') { + try { + const container = docker.getContainer(user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running') { + console.log(`Starting docker interval for ${user}`); + client.intervals.push(setInterval(async () => { + try { + const stats = await getContainerStats(user); + ws.send(JSON.stringify({ type: 'docker', data: { ...stats, user } })); + } catch (error) { + console.error(`Error in docker interval for ${user}:`, error.message); + } + }, parseInt(process.env.DOCKER_STATS_INTERVAL_MS, 10))); + } else { + console.log(`Container ${user} is not running, skipping docker interval`); + ws.send(JSON.stringify({ type: 'docker', error: `Container ${user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${user}:`, error.message); + ws.send(JSON.stringify({ type: 'docker', error: `Failed to check container status: ${error.message}` })); + } + } else if (user === 'Unknown') { + console.warn('Cannot start docker interval: User is Unknown'); + ws.send(JSON.stringify({ type: 'docker', error: 'User not identified' })); + } + + if (client.subscriptions.has('docker-logs') && user !== 'Unknown') { + console.log(`Starting docker logs stream for ${user}`); + await streamContainerLogs(ws, user, client); + } else if (user === 'Unknown') { + console.warn('Cannot start docker logs stream: User is Unknown'); + ws.send(JSON.stringify({ type: 'docker-logs', error: 'User not identified' })); + } + + // Manage status checks based on container status + await manageStatusChecks(); + + await Promise.all([ + ...staticEndpoints.filter(e => client.subscriptions.has(e)).map(e => fetchAndSendUpdate(ws, e, client)), + ...dynamicEndpoints.filter(e => client.subscriptions.has(e)).map(async (e) => { + if (e === 'hello' && client.cache['hello']) { + ws.send(JSON.stringify({ type: 'hello', data: client.cache['hello'] })); + return; + } + if (e === 'time' && client.cache['time']) { + ws.send(JSON.stringify({ type: 'time', data: client.cache['time'] })); + return; + } + await fetchAndSendUpdate(ws, e, client); + }), + client.subscriptions.has('list-players') ? fetchAndSendUpdate(ws, 'list-players', client) : null + ].filter(Boolean)); + + client.intervals.push(setInterval(async () => { + try { + for (const endpoint of dynamicEndpoints) { + if (client.subscriptions.has(endpoint)) { + if ((endpoint === 'hello' && client.cache['hello']) || + (endpoint === 'time' && client.cache['time'])) { + continue; + } + await fetchAndSendUpdate(ws, endpoint, client); + } + } + } catch (error) { + console.error('Error in dynamic endpoints interval:', error.message); + } + }, parseInt(process.env.DYNAMIC_ENDPOINTS_INTERVAL_MS, 10))); + + client.intervals.push(setInterval(async () => { + try { + for (const endpoint of staticEndpoints) { + if (client.subscriptions.has(endpoint)) { + await fetchAndSendUpdate(ws, endpoint, client); + } + } + } catch (error) { + console.error('Error in static endpoints interval:', error.message); + } + }, parseInt(process.env.STATIC_ENDPOINTS_INTERVAL_MS, 10))); + + if (client.subscriptions.has('list-players') && user !== 'Unknown') { + try { + const container = docker.getContainer(user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running') { + client.intervals.push(setInterval(async () => { + try { + await fetchAndSendUpdate(ws, 'list-players', client); + } catch (error) { + console.error('Error in list-players interval:', error.message); + } + }, parseInt(process.env.LIST_PLAYERS_INTERVAL_MS, 10))); + } else { + console.log(`Container ${user} is not running, skipping list-players interval`); + ws.send(JSON.stringify({ type: 'list-players', error: `Container ${user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${user}:`, error.message); + ws.send(JSON.stringify({ type: 'list-players', error: `Failed to check container status: ${error.message}` })); + } + } + } else { + console.error('Invalid /hello response:', hello); + ws.send(JSON.stringify({ type: 'hello', error: 'Invalid hello response' })); + } + } else if (data.type === 'updateUser') { + client.user = data.user; + console.log(`Updated user to: ${client.user}`); + if (client.user !== 'Unknown') { + client.intervals.forEach(clearInterval); + client.intervals = []; + if (client.connectionStatusInterval) { + clearInterval(client.connectionStatusInterval); + client.connectionStatusInterval = null; + } + if (client.geyserStatusInterval) { + clearInterval(client.geyserStatusInterval); + client.geyserStatusInterval = null; + } + if (client.sftpStatusInterval) { + clearInterval(client.sftpStatusInterval); + client.sftpStatusInterval = null; + } + if (client.statusCheckMonitorInterval) { + clearInterval(client.statusCheckMonitorInterval); + client.statusCheckMonitorInterval = null; + } + + async function manageStatusChecks() { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + const isRunning = inspect.State.Status === 'running'; + console.log(`Container ${client.user} status: ${inspect.State.Status}`); + + // Clear any existing status check intervals + if (client.connectionStatusInterval) { + clearInterval(client.connectionStatusInterval); + client.connectionStatusInterval = null; + } + if (client.geyserStatusInterval) { + clearInterval(client.geyserStatusInterval); + client.geyserStatusInterval = null; + } + if (client.sftpStatusInterval) { + clearInterval(client.sftpStatusInterval); + client.sftpStatusInterval = null; + } + + if (isRunning) { + if (client.subscriptions.has('my-link-cache')) { + console.log(`Starting new connection status check for ${client.user}`); + client.connectionStatusInterval = setInterval(async () => { + try { + const containerCheck = docker.getContainer(client.user); + const inspectCheck = await containerCheck.inspect(); + if (inspectCheck.State.Status !== 'running') { + console.log(`Container ${client.user} stopped, clearing connection status interval`); + clearInterval(client.connectionStatusInterval); + client.connectionStatusInterval = null; + return; + } + const linkData = client.cache['my-link-cache']; + if (linkData && linkData.hostname && linkData.port) { + const status = await checkConnectionStatus(linkData.hostname, linkData.port); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: status.isOnline } })); + } + } catch (error) { + console.error(`Error in connection status check for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: false } })); + } + }, parseInt(process.env.CONNECTION_STATUS_NEW_USER_INTERVAL_MS, 10)); + client.intervals.push(client.connectionStatusInterval); + // Initial check + const linkData = client.cache['my-link-cache']; + if (linkData && linkData.hostname && linkData.port) { + console.log(`Performing initial connection status check for ${client.user}`); + const status = await checkConnectionStatus(linkData.hostname, linkData.port); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: status.isOnline } })); + } + } + + if (client.subscriptions.has('my-geyser-cache')) { + console.log(`Starting new Geyser status check for ${client.user}`); + client.geyserStatusInterval = setInterval(async () => { + try { + const containerCheck = docker.getContainer(client.user); + const inspectCheck = await containerCheck.inspect(); + if (inspectCheck.State.Status !== 'running') { + console.log(`Container ${client.user} stopped, clearing Geyser status interval`); + clearInterval(client.geyserStatusInterval); + client.geyserStatusInterval = null; + return; + } + const geyserData = client.cache['my-geyser-cache']; + if (geyserData && geyserData.hostname && geyserData.port) { + const status = await checkGeyserStatus(geyserData.hostname, geyserData.port); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: status.isOnline } })); + } + } catch (error) { + console.error(`Error in Geyser status check for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: false } })); + } + }, parseInt(process.env.GEYSER_STATUS_INTERVAL_MS, 10)); + client.intervals.push(client.geyserStatusInterval); + // Initial check + const geyserData = client.cache['my-geyser-cache']; + if (geyserData && geyserData.hostname && geyserData.port) { + console.log(`Performing initial Geyser status check for ${client.user}`); + const status = await checkGeyserStatus(geyserData.hostname, geyserData.port); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: status.isOnline } })); + } + } + + if (client.subscriptions.has('my-sftp-cache')) { + console.log(`Starting new SFTP status check for ${client.user}`); + client.sftpStatusInterval = setInterval(async () => { + try { + const containerCheck = docker.getContainer(client.user); + const inspectCheck = await containerCheck.inspect(); + if (inspectCheck.State.Status !== 'running') { + console.log(`Container ${client.user} stopped, clearing SFTP status interval`); + clearInterval(client.sftpStatusInterval); + client.sftpStatusInterval = null; + return; + } + const sftpData = client.cache['my-sftp-cache']; + if (sftpData && sftpData.hostname && sftpData.port) { + const status = await checkSftpStatus(sftpData.hostname, sftpData.port); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: status.isOnline } })); + } + } catch (error) { + console.error(`Error in SFTP status check for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: false } })); + } + }, parseInt(process.env.SFTP_STATUS_INTERVAL_MS, 10)); + client.intervals.push(client.sftpStatusInterval); + // Initial check + const sftpData = client.cache['my-sftp-cache']; + if (sftpData && sftpData.hostname && sftpData.port) { + console.log(`Performing initial SFTP status check for ${client.user}`); + const status = await checkSftpStatus(sftpData.hostname, sftpData.port); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: status.isOnline } })); + } + } + } else { + console.log(`Container ${client.user} is not running, skipping status checks`); + if (client.subscriptions.has('my-link-cache')) { + ws.send(JSON.stringify({ type: 'connection-status', error: `Container ${client.user} is not running` })); + } + if (client.subscriptions.has('my-geyser-cache')) { + ws.send(JSON.stringify({ type: 'geyser-status', error: `Container ${client.user} is not running` })); + } + if (client.subscriptions.has('my-sftp-cache')) { + ws.send(JSON.stringify({ type: 'sftp-status', error: `Container ${client.user} is not running` })); + } + } + + // Clear any existing monitor interval + if (client.statusCheckMonitorInterval) { + clearInterval(client.statusCheckMonitorInterval); + client.statusCheckMonitorInterval = null; + } + + // Start monitoring if container is not running and status checks are subscribed + if (!isRunning && (client.subscriptions.has('my-link-cache') || client.subscriptions.has('my-geyser-cache') || client.subscriptions.has('my-sftp-cache'))) { + console.log(`Starting container status monitor for ${client.user}`); + client.statusCheckMonitorInterval = setInterval(async () => { + try { + const monitorContainer = docker.getContainer(client.user); + const monitorInspect = await monitorContainer.inspect(); + if (monitorInspect.State.Status === 'running') { + console.log(`Container ${client.user} is running, restarting status checks`); + await manageStatusChecks(); + clearInterval(client.statusCheckMonitorInterval); + client.statusCheckMonitorInterval = null; + } + } catch (error) { + console.error(`Error monitoring container ${client.user}:`, error.message); + } + }, parseInt(process.env.CONTAINER_STATUS_MONITOR_INTERVAL_MS, 10)); + client.intervals.push(client.statusCheckMonitorInterval); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + if (client.subscriptions.has('my-link-cache')) { + ws.send(JSON.stringify({ type: 'connection-status', error: `Failed to check container status: ${error.message}` })); + } + if (client.subscriptions.has('my-geyser-cache')) { + ws.send(JSON.stringify({ type: 'geyser-status', error: `Failed to check container status: ${error.message}` })); + } + if (client.subscriptions.has('my-sftp-cache')) { + ws.send(JSON.stringify({ type: 'sftp-status', error: `Failed to check container status: ${error.message}` })); + } + } + } + + if (client.subscriptions.has('docker')) { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running') { + console.log(`Starting new docker interval for ${client.user}`); + client.intervals.push(setInterval(async () => { + try { + const stats = await getContainerStats(client.user); + ws.send(JSON.stringify({ type: 'docker', data: { ...stats, user: client.user } })); + } catch (error) { + console.error(`Error in docker interval for ${client.user}:`, error.message); + } + }, parseInt(process.env.DOCKER_STATS_INTERVAL_MS, 10))); + } else { + console.log(`Container ${client.user} is not running, skipping docker interval`); + ws.send(JSON.stringify({ type: 'docker', error: `Container ${client.user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'docker', error: `Failed to check container status: ${error.message}` })); + } + } + + if (client.subscriptions.has('list-players')) { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running') { + client.intervals.push(setInterval(async () => { + try { + await fetchAndSendUpdate(ws, 'list-players', client); + } catch (error) { + console.error('Error in list-players interval:', error.message); + } + }, parseInt(process.env.LIST_PLAYERS_NEW_USER_INTERVAL_MS, 10))); + } else { + console.log(`Container ${client.user} is not running, skipping list-players interval`); + ws.send(JSON.stringify({ type: 'list-players', error: `Container ${client.user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'list-players', error: `Failed to check container status: ${error.message}` })); + } + } + + // Manage status checks for the new user + await manageStatusChecks(); + + if (client.subscriptions.has('docker-logs')) { + if (client.logStream) { + client.logStream.destroy(); + client.logStream = null; + } + console.log(`Starting new docker logs stream for ${client.user}`); + await streamContainerLogs(ws, client.user, client); + } + } + } else if (data.type === 'request') { + const { requestId, endpoint, method, body } = data; + let response; + if (endpoint.startsWith('/docker') || endpoint === '/docker') { + const containerName = client.user || 'Unknown'; + if (containerName === 'Unknown') { + console.error('Cannot fetch docker stats: User not identified'); + response = { error: 'User not identified' }; + } else { + response = await getContainerStats(containerName); + } + } else if (endpoint === '/search' && method === 'POST' && body) { + response = await apiRequest(endpoint, client.apiKey, method, body); + response.totalResults = response.totalResults || (response.results ? response.results.length : 0); + } else if (endpoint === '/server-properties' && method === 'GET') { + if (!client.user || client.user === 'Unknown') { + response = { error: 'User not identified' }; + } else { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + response = { error: `Container ${client.user} is not running` }; + } else { + const filePath = process.env.SERVER_PROPERTIES_PATH; + const command = `docker exec ${client.user} bash -c "cat ${filePath}"`; + console.log(`Executing: ${command}`); + const { stdout, stderr } = await execPromise(command); + if (stderr) { + console.error(`Error reading server.properties: ${stderr}`); + response = { error: 'Failed to read server.properties' }; + } else { + response = { content: stdout }; + } + } + } catch (error) { + console.error(`Error reading server.properties: ${error.message}`); + response = { error: `Failed to read server.properties: ${error.message}` }; + } + } + } else if (endpoint === '/server-properties' && method === 'POST' && body && body.content) { + if (!client.user || client.user === 'Unknown') { + response = { error: 'User not identified' }; + } else { + try { + const filePath = process.env.SERVER_PROPERTIES_PATH; + const tmpDir = process.env.TEMP_DIR; + const randomId = crypto.randomBytes(parseInt(process.env.TEMP_FILE_RANDOM_ID_BYTES, 10)).toString('hex'); + const tmpFile = path.join(tmpDir, `server_properties_${randomId}.tmp`); + const containerFilePath = `${process.env.CONTAINER_TEMP_FILE_PREFIX}${randomId}.tmp`; + + await fs.writeFile(tmpFile, body.content); + const copyCommand = `docker cp ${tmpFile} ${client.user}:${containerFilePath}`; + console.log(`Executing: ${copyCommand}`); + await execPromise(copyCommand); + + const moveCommand = `docker exec ${client.user} bash -c "mv ${containerFilePath} ${filePath} && chown mc:mc ${filePath}"`; + console.log(`Executing: ${moveCommand}`); + await execPromise(moveCommand); + + await fs.unlink(tmpFile).catch(err => console.error(`Error deleting temp file: ${err.message}`)); + response = { message: 'Server properties updated' }; + } catch (error) { + console.error(`Error writing server.properties: ${error.message}`); + response = { error: `Failed to write server.properties: ${error.message}` }; + } + } + } else { + response = await apiRequest(endpoint, client.apiKey, method, body); + } + ws.send(JSON.stringify({ requestId, ...response })); + if (['my-link', 'my-geyser-link', 'my-sftp'].includes(endpoint) && !response.error) { + await fetchAndSendUpdate(ws, endpoint, client); + if (endpoint === 'my-link') { + const linkData = await apiRequest('/my-link-cache', client.apiKey); + if (!linkData.error) { + client.cache['my-link-cache'] = linkData; + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running') { + console.log(`Performing status check after my-link request for ${client.user}`); + const status = await checkConnectionStatus(linkData.hostname, linkData.port); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: status.isOnline } })); + } else { + ws.send(JSON.stringify({ type: 'connection-status', error: `Container ${client.user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'connection-status', error: `Failed to check container status: ${error.message}` })); + } + } + } else if (endpoint === 'my-geyser-link') { + const geyserData = await apiRequest('/my-geyser-cache', client.apiKey); + if (!geyserData.error) { + client.cache['my-geyser-cache'] = geyserData; + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running') { + console.log(`Performing status check after my-geyser-link request for ${client.user}`); + const status = await checkGeyserStatus(geyserData.hostname, geyserData.port); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: status.isOnline } })); + } else { + ws.send(JSON.stringify({ type: 'geyser-status', error: `Container ${client.user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'geyser-status', error: `Failed to check container status: ${error.message}` })); + } + } + } else if (endpoint === 'my-sftp') { + const sftpData = await apiRequest('/my-sftp-cache', client.apiKey); + if (!sftpData.error) { + client.cache['my-sftp-cache'] = sftpData; + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running') { + console.log(`Performing status check after my-sftp request for ${client.user}`); + const status = await checkSftpStatus(sftpData.hostname, sftpData.port); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: status.isOnline } })); + } else { + ws.send(JSON.stringify({ type: 'sftp-status', error: `Container ${client.user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'sftp-status', error: `Failed to check container status: ${error.message}` })); + } + } + } + } + } else if (data.type === 'kick-player') { + const { requestId, player } = data; + let response; + if (!player) { + response = { error: 'Player name is required' }; + } else if (!client.user || client.user === 'Unknown') { + response = { error: 'User not identified' }; + } else { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + response = { error: `Container ${client.user} is not running` }; + } else { + response = await apiRequest('/console', client.apiKey, 'POST', { command: `kick ${player.toString()}` }); + if (!response.error) { + const playerListResponse = await apiRequest('/list-players', client.apiKey); + if (playerListResponse.error) { + console.error(`Failed to fetch updated player list after kick: ${playerListResponse.error}`); + } else { + ws.send(JSON.stringify({ type: 'list-players', data: playerListResponse })); + } + } + } + } catch (error) { + console.error(`Error kicking player: ${error.message}`); + response = { error: `Failed to kick player: ${error.message}` }; + } + } + ws.send(JSON.stringify({ requestId, ...response })); + } else if (data.type === 'ban-player') { + const { requestId, player } = data; + let response; + if (!player) { + response = { error: 'Player name is required' }; + } else if (!client.user || client.user === 'Unknown') { + response = { error: 'User not identified' }; + } else { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + response = { error: `Container ${client.user} is not running` }; + } else { + response = await apiRequest('/console', client.apiKey, 'POST', { command: `ban ${player}` }); + if (!response.error) { + const playerListResponse = await apiRequest('/list-players', client.apiKey); + if (playerListResponse.error) { + console.error(`Failed to fetch updated player list after ban: ${playerListResponse.error}`); + } else { + ws.send(JSON.stringify({ type: 'list-players', data: playerListResponse })); + } + } + } + } catch (error) { + console.error(`Error banning player: ${error.message}`); + response = { error: `Failed to ban player: ${error.message}` }; + } + } + ws.send(JSON.stringify({ requestId, ...response })); + } else if (data.type === 'op-player') { + const { requestId, player } = data; + let response; + if (!player) { + response = { error: 'Player name is required' }; + } else if (!client.user || client.user === 'Unknown') { + response = { error: 'User not identified' }; + } else { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + response = { error: `Container ${client.user} is not running` }; + } else { + response = await apiRequest('/console', client.apiKey, 'POST', { command: `op ${player}` }); + if (!response.error) { + const playerListResponse = await apiRequest('/list-players', client.apiKey); + if (playerListResponse.error) { + console.error(`Failed to fetch updated player list after op: ${playerListResponse.error}`); + } else { + ws.send(JSON.stringify({ type: 'list-players', data: playerListResponse })); + } + } + } + } catch (error) { + console.error(`Error op-ing player: ${error.message}`); + response = { error: `Failed to op player: ${error.message}` }; + } + } + ws.send(JSON.stringify({ requestId, ...response })); + } else if (data.type === 'deop-player') { + const { requestId, player } = data; + let response; + if (!player) { + response = { error: 'Player name is required' }; + } else if (!client.user || client.user === 'Unknown') { + response = { error: 'User not identified' }; + } else { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + response = { error: `Container ${client.user} is not running` }; + } else { + response = await apiRequest('/console', client.apiKey, 'POST', { command: `deop ${player}` }); + if (!response.error) { + const playerListResponse = await apiRequest('/list-players', client.apiKey); + if (playerListResponse.error) { + console.error(`Failed to fetch updated player list after deop: ${playerListResponse.error}`); + } else { + ws.send(JSON.stringify({ type: 'list-players', data: playerListResponse })); + } + } + } + } catch (error) { + console.error(`Error deop-ing player: ${error.message}`); + response = { error: `Failed to deop player: ${error.message}` }; + } + } + ws.send(JSON.stringify({ requestId, ...response })); + } else if (data.type === 'tell-player') { + const { requestId, player, message } = data; + let response; + if (!player || !message) { + response = { error: 'Player name and message are required' }; + } else if (!client.user || client.user === 'Unknown') { + response = { error: 'User not identified' }; + } else { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + response = { error: `Container ${client.user} is not running` }; + } else { + response = await apiRequest('/tell', client.apiKey, 'POST', { username: player, message }); + if (!response.error) { + console.log(`Message sent to ${player}: ${message}`); + } + } + } catch (error) { + console.error(`Error sending message to player: ${error.message}`); + response = { error: `Failed to send message: ${error.message}` }; + } + } + ws.send(JSON.stringify({ requestId, ...response })); + } else if (data.type === 'give-player') { + const { requestId, player, item, amount } = data; + let response; + if (!player || !item || !amount) { + response = { error: 'Player name, item, and amount are required' }; + } else if (!client.user || client.user === 'Unknown') { + response = { error: 'User not identified' }; + } else { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + response = { error: `Container ${client.user} is not running` }; + } else { + response = await apiRequest('/give', client.apiKey, 'POST', { username: player, item, amount }); + if (!response.error) { + console.log(`Gave ${amount} ${item} to ${player}`); + } + } + } catch (error) { + console.error(`Error giving item to player: ${error.message}`); + response = { error: `Failed to give item: ${error.message}` }; + } + } + ws.send(JSON.stringify({ requestId, ...response })); + } else if (data.type === 'refresh') { + console.log('Processing refresh request'); + delete client.cache['hello']; + delete client.cache['time']; + await Promise.all([ + ...staticEndpoints.filter(e => client.subscriptions.has(e)).map(e => fetchAndSendUpdate(ws, e, client)), + ...dynamicEndpoints.filter(e => client.subscriptions.has(e)).map(e => fetchAndSendUpdate(ws, e, client)), + client.subscriptions.has('list-players') ? fetchAndSendUpdate(ws, 'list-players', client) : null + ].filter(Boolean)); + if (client.user && client.user !== 'Unknown') { + const stats = await getContainerStats(client.user); + ws.send(JSON.stringify({ type: 'docker', data: { ...stats, user: client.user } })); + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running') { + const linkData = client.cache['my-link-cache']; + if (linkData && linkData.hostname && linkData.port && client.subscriptions.has('my-link-cache')) { + console.log(`Performing refresh connection status check for ${client.user}`); + const status = await checkConnectionStatus(linkData.hostname, linkData.port); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: status.isOnline } })); + } + const geyserData = client.cache['my-geyser-cache']; + if (geyserData && geyserData.hostname && geyserData.port && client.subscriptions.has('my-geyser-cache')) { + console.log(`Performing refresh Geyser status check for ${client.user}`); + const status = await checkGeyserStatus(geyserData.hostname, geyserData.port); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: status.isOnline } })); + } + const sftpData = client.cache['my-sftp-cache']; + if (sftpData && sftpData.hostname && sftpData.port && client.subscriptions.has('my-sftp-cache')) { + console.log(`Performing refresh SFTP status check for ${client.user}`); + const status = await checkSftpStatus(sftpData.hostname, sftpData.port); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: status.isOnline } })); + } + } else { + if (client.subscriptions.has('my-link-cache')) { + ws.send(JSON.stringify({ type: 'connection-status', error: `Container ${client.user} is not running` })); + } + if (client.subscriptions.has('my-geyser-cache')) { + ws.send(JSON.stringify({ type: 'geyser-status', error: `Container ${client.user} is not running` })); + } + if (client.subscriptions.has('my-sftp-cache')) { + ws.send(JSON.stringify({ type: 'sftp-status', error: `Container ${client.user} is not running` })); + } + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + if (client.subscriptions.has('my-link-cache')) { + ws.send(JSON.stringify({ type: 'connection-status', error: `Failed to check container status: ${error.message}` })); + } + if (client.subscriptions.has('my-geyser-cache')) { + ws.send(JSON.stringify({ type: 'geyser-status', error: `Failed to check container status: ${error.message}` })); + } + if (client.subscriptions.has('my-sftp-cache')) { + ws.send(JSON.stringify({ type: 'sftp-status', error: `Failed to check container status: ${error.message}` })); + } + } + } + } + } catch (error) { + console.error('WebSocket message error:', error.message); + ws.send(JSON.stringify({ error: `Invalid message: ${error.message}` })); + } + }); + + ws.on('close', () => { + try { + const client = clients.get(ws); + client.intervals.forEach(clearInterval); + if (client.logStream) { + client.logStream.destroy(); + client.logStream = null; + } + if (client.connectionStatusInterval) { + clearInterval(client.connectionStatusInterval); + } + if (client.geyserStatusInterval) { + clearInterval(client.geyserStatusInterval); + } + if (client.sftpStatusInterval) { + clearInterval(client.sftpStatusInterval); + } + if (client.statusCheckMonitorInterval) { + clearInterval(client.statusCheckMonitorInterval); + } + clients.delete(ws); + console.log('WebSocket client disconnected'); + } catch (error) { + console.error('Error on WebSocket close:', error.message); + } + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error.message); + }); + } catch (error) { + console.error('WebSocket connection error:', error.message); + ws.send(JSON.stringify({ error: `Connection error: ${error.message}` })); + ws.close(); + } +}); + +async function fetchAndSendUpdate(ws, endpoint, client) { + try { + if (['mod-list', 'list-players'].includes(endpoint) && client.user && client.user !== 'Unknown') { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status !== 'running') { + console.log(`Skipping update for ${endpoint} as container ${client.user} is not running`); + ws.send(JSON.stringify({ type: endpoint, error: `Container ${client.user} is not running` })); + return; + } + } catch (error) { + console.error(`Error checking container status for ${endpoint}:`, error.message); + ws.send(JSON.stringify({ type: endpoint, error: `Failed to check container status: ${error.message}` })); + return; + } + } + + if (endpoint === 'time' && client.cache['time']) { + ws.send(JSON.stringify({ type: endpoint, data: client.cache['time'] })); + return; + } + + const response = await apiRequest(`/${endpoint}`, client.apiKey); + if (!response.error) { + if (endpoint === 'time') { + client.cache['time'] = response; + } + if (endpoint === 'my-link-cache') { + client.cache['my-link-cache'] = response; + if (client.subscriptions.has('my-link-cache') && client.user !== 'Unknown') { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running' && response.hostname && response.port) { + console.log(`Performing status check for my-link-cache update for ${client.user}`); + const status = await checkConnectionStatus(response.hostname, response.port); + ws.send(JSON.stringify({ type: 'connection-status', data: { isOnline: status.isOnline } })); + } else { + ws.send(JSON.stringify({ type: 'connection-status', error: `Container ${client.user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'connection-status', error: `Failed to check container status: ${error.message}` })); + } + } + } + if (endpoint === 'my-geyser-cache') { + client.cache['my-geyser-cache'] = response; + if (client.subscriptions.has('my-geyser-cache') && client.user !== 'Unknown') { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running' && response.hostname && response.port) { + console.log(`Performing status check for my-geyser-cache update for ${client.user}`); + const status = await checkGeyserStatus(response.hostname, response.port); + ws.send(JSON.stringify({ type: 'geyser-status', data: { isOnline: status.isOnline } })); + } else { + ws.send(JSON.stringify({ type: 'geyser-status', error: `Container ${client.user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'geyser-status', error: `Failed to check container status: ${error.message}` })); + } + } + } + if (endpoint === 'my-sftp-cache') { + client.cache['my-sftp-cache'] = response; + if (client.subscriptions.has('my-sftp-cache') && client.user !== 'Unknown') { + try { + const container = docker.getContainer(client.user); + const inspect = await container.inspect(); + if (inspect.State.Status === 'running' && response.hostname && response.port) { + console.log(`Performing status check for my-sftp-cache update for ${client.user}`); + const status = await checkSftpStatus(response.hostname, response.port); + ws.send(JSON.stringify({ type: 'sftp-status', data: { isOnline: status.isOnline } })); + } else { + ws.send(JSON.stringify({ type: 'sftp-status', error: `Container ${client.user} is not running` })); + } + } catch (error) { + console.error(`Error checking container status for ${client.user}:`, error.message); + ws.send(JSON.stringify({ type: 'sftp-status', error: `Failed to check container status: ${error.message}` })); + } + } + } + ws.send(JSON.stringify({ type: endpoint, data: response })); + } else { + console.error(`Error fetching ${endpoint}:`, response.error); + ws.send(JSON.stringify({ type: endpoint, error: response.error })); + } + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error.message); + ws.send(JSON.stringify({ type: endpoint, error: error.message })); + } +} + +app.post('/generate-login-link', async (req, res) => { + try { + const { secretKey, username } = req.body; + + if (!secretKey || secretKey !== process.env.ADMIN_SECRET_KEY) { + return res.status(401).json({ error: 'Invalid secret key' }); + } + + if (!username) { + return res.status(400).json({ error: 'Username is required' }); + } + + const tokenResponse = await unirest + .post(process.env.AUTH_ENDPOINT) + .headers({ 'Accept': 'application/json', 'Content-Type': 'application/json' }) + .send({ + username: username, + password: process.env.AUTH_PASSWORD + }); + + if (!tokenResponse.body.token) { + return res.status(500).json({ error: 'Failed to generate API key' }); + } + + const apiKey = tokenResponse.body.token; + + const linkId = crypto.randomBytes(parseInt(process.env.LINK_ID_BYTES, 10)).toString('hex'); + const loginLink = `${process.env.AUTO_LOGIN_LINK_PREFIX}${linkId}`; + + temporaryLinks.set(linkId, { + apiKey, + username, + expiresAt: Date.now() + LINK_EXPIRY_SECONDS * 1000 + }); + + setTimeout(() => { + temporaryLinks.delete(linkId); + }, LINK_EXPIRY_SECONDS * 1000); + + res.json({ loginLink }); + } catch (error) { + console.error('Error generating login link:', error.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/auto-login/:linkId', (req, res) => { + const { linkId } = req.params; + const linkData = temporaryLinks.get(linkId); + + if (!linkData || linkData.expiresAt < Date.now()) { + temporaryLinks.delete(linkId); + return res.send(` + + + + + + +
+ +

Login Expired.

+

Redirecting...

+
+ + +`); + } + + temporaryLinks.delete(linkId); + + res.send(` + + + + Auto Login + + + +

Logging in...

+ + + `); +}); + +setInterval(() => { + const now = Date.now(); + for (const [linkId, linkData] of temporaryLinks.entries()) { + if (linkData.expiresAt < now) { + temporaryLinks.delete(linkId); + } + } +}, parseInt(process.env.TEMP_LINKS_CLEANUP_INTERVAL_MS, 10)); + +const server = app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); + +server.on('upgrade', (request, socket, head) => { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); +}); + +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); \ No newline at end of file diff --git a/start.json b/start.json new file mode 100644 index 0000000..766cb56 --- /dev/null +++ b/start.json @@ -0,0 +1,13 @@ +{ + "apps": [ + { + "name": "user-panel", + "script": "npm", + "args": "start", + "watch": false, + "env": { + "NODE_ENV": "production" + } + } + ] +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..6c81a2c --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,13 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './public/**/*.html', + './public/**/*.js', + './public/*.html', + './public/*.js' + ], + theme: { + extend: {}, + }, + plugins: [], + } \ No newline at end of file