From 612ae128637e9f1e103d97390db11103c17452ef Mon Sep 17 00:00:00 2001 From: MCHost Date: Mon, 16 Jun 2025 10:11:55 -0400 Subject: [PATCH] first commit --- .gitignore | 3 + package.json | 29 + postcss.config.js | 6 + public/app.js | 1405 +++++++++++++++++++ public/css/styles.css | 222 +++ public/css/styles.min.css | 871 ++++++++++++ public/favicon/android-chrome-192x192.png | Bin 0 -> 7370 bytes public/favicon/android-chrome-512x512.png | Bin 0 -> 22545 bytes public/favicon/apple-touch-icon.png | Bin 0 -> 6635 bytes public/favicon/favicon-16x16.png | Bin 0 -> 409 bytes public/favicon/favicon-32x32.png | Bin 0 -> 859 bytes public/favicon/favicon.ico | Bin 0 -> 15406 bytes public/favicon/site.webmanifest | 1 + public/index.html | 221 +++ server.js | 1540 +++++++++++++++++++++ start.json | 13 + tailwind.config.js | 13 + 17 files changed, 4324 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/app.js create mode 100644 public/css/styles.css create mode 100644 public/css/styles.min.css create mode 100644 public/favicon/android-chrome-192x192.png create mode 100644 public/favicon/android-chrome-512x512.png create mode 100644 public/favicon/apple-touch-icon.png create mode 100644 public/favicon/favicon-16x16.png create mode 100644 public/favicon/favicon-32x32.png create mode 100644 public/favicon/favicon.ico create mode 100644 public/favicon/site.webmanifest create mode 100644 public/index.html create mode 100644 server.js create mode 100644 start.json create mode 100644 tailwind.config.js 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 0000000000000000000000000000000000000000..0f02c8c39dfd7458a4a6771f57ef0fca1a2912ec GIT binary patch literal 7370 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 ZMh4sH=+M~zeiA1E@-iyYWm2a9{Xe=Z&EWt5 literal 0 HcmV?d00001 diff --git a/public/favicon/android-chrome-512x512.png b/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..1be9760b962373af5cb80328e6bffc03e74eb83b GIT binary patch literal 22545 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 + + + + + + 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