diff --git a/app.js b/app.js index 112184d..05d6224 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,7 @@ import Hyperswarm from 'hyperswarm'; import b4a from 'b4a'; import { startTerminal, appendTerminalOutput } from './libs/terminal.js'; import { startDockerTerminal, cleanUpDockerTerminal } from './libs/dockerTerminal.js'; +import { fetchTemplates, displayTemplateList, openDeployModal } from './libs/templateDeploy.js'; // DOM Elements const containerList = document.getElementById('container-list'); @@ -33,6 +34,16 @@ function stopStatsInterval() { } } +function closeAllModals() { + // Find and hide all open modals + const modals = document.querySelectorAll('.modal.show'); // Adjust selector if necessary + modals.forEach(modal => { + const modalInstance = bootstrap.Modal.getInstance(modal); // Get Bootstrap modal instance + modalInstance.hide(); // Close the modal + }); +} + + document.addEventListener('DOMContentLoaded', () => { const dockerTerminalModal = document.getElementById('dockerTerminalModal'); @@ -397,18 +408,30 @@ function addConnection(topicHex) { connectionItem.className = 'list-group-item d-flex align-items-center justify-content-between'; connectionItem.dataset.topicId = topicId; connectionItem.innerHTML = ` - - ${topicId} - -
- - +
+
+ ${topicId} + +

+
+ + + +
`; + // Add event listener for "Deploy Template" button + connectionItem.querySelector('.deploy-template-btn').addEventListener('click', () => { + console.log(`[INFO] Opening template deploy modal for connection: ${topicId}`); + openTemplateDeployModal(topicId); + }); + // Add Docker Terminal button event listener connectionItem.querySelector('.docker-terminal-btn')?.addEventListener('click', (event) => { @@ -496,6 +519,20 @@ function addConnection(topicHex) { } +// Function to open the template deploy modal +function openTemplateDeployModal(topicId) { + // Pass the topic ID or other connection-specific info if needed + console.log(`[INFO] Preparing template deploy modal for topic: ${topicId}`); + + // Ensure the modal fetches templates + fetchTemplates(); // Refresh template list + + // Show the modal + const templateDeployModal = new bootstrap.Modal(document.getElementById('templateDeployModal')); + templateDeployModal.show(); +} + + // Initialize connections from cookies on page load document.addEventListener('DOMContentLoaded', () => { const savedConnections = loadConnections(); diff --git a/index.html b/index.html index dbab3f7..a84d51c 100644 --- a/index.html +++ b/index.html @@ -545,6 +545,73 @@
+ + + + + + + + + + +
diff --git a/libs/templateDeploy.js b/libs/templateDeploy.js new file mode 100644 index 0000000..fca8e6a --- /dev/null +++ b/libs/templateDeploy.js @@ -0,0 +1,200 @@ +// DOM Elements +const templateList = document.getElementById('template-list'); +const templateSearchInput = document.getElementById('template-search-input'); +const templateDeployModal = new bootstrap.Modal(document.getElementById('templateDeployModalUnique')); +const deployForm = document.getElementById('deploy-form'); + +// Function to close all modals +function closeAllModals() { + const modals = document.querySelectorAll('.modal.show'); + modals.forEach(modal => { + const modalInstance = bootstrap.Modal.getInstance(modal); + if (modalInstance) modalInstance.hide(); + }); +} + +// Show status indicator +function showStatusIndicator(message = 'Processing...') { + const statusIndicator = document.createElement('div'); + statusIndicator.id = 'status-indicator'; + statusIndicator.className = 'position-fixed top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center bg-dark bg-opacity-75'; + statusIndicator.innerHTML = ` +
+
+ Loading... +
+

${message}

+
+ `; + document.body.appendChild(statusIndicator); +} + +// Hide status indicator +function hideStatusIndicator() { + const statusIndicator = document.getElementById('status-indicator'); + if (statusIndicator) { + console.log('[DEBUG] Hiding status indicator'); + statusIndicator.remove(); + } else { + console.error('[ERROR] Status indicator element not found!'); + } +} + +// Show alert message +function showAlert(type, message) { + const alertBox = document.createElement('div'); + alertBox.className = `alert alert-${type}`; + alertBox.textContent = message; + + const container = document.querySelector('#alert-container'); + if (container) { + container.appendChild(alertBox); + + setTimeout(() => { + container.removeChild(alertBox); + }, 5000); + } else { + console.warn('[WARN] Alert container not found.'); + } +} + +// Fetch templates from the URL +async function fetchTemplates() { + try { + const response = await fetch('https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates-2.0.json'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + const templates = data.templates || []; + displayTemplateList(templates); + } catch (error) { + console.error('[ERROR] Failed to fetch templates:', error.message); + showAlert('danger', 'Failed to load templates.'); + } +} + +// Display templates in the list +function displayTemplateList(templates) { + templateList.innerHTML = ''; + templates.forEach(template => { + const listItem = document.createElement('li'); + listItem.className = 'list-group-item d-flex justify-content-between align-items-center'; + listItem.innerHTML = ` +
+ Logo + ${template.title} +
+ + `; + listItem.querySelector('.deploy-btn').addEventListener('click', () => openDeployModal(template)); + templateList.appendChild(listItem); + }); +} + +// Filter templates by search input +templateSearchInput.addEventListener('input', () => { + const searchQuery = templateSearchInput.value.toLowerCase(); + const filteredTemplates = templates.filter(template => + template.title.toLowerCase().includes(searchQuery) || + template.description.toLowerCase().includes(searchQuery) + ); + displayTemplateList(filteredTemplates); +}); + +// Open deploy modal and populate the form dynamically +function openDeployModal(template) { + console.log('[DEBUG] Opening deploy modal for:', template); + + const deployTitle = document.getElementById('deploy-title'); + deployTitle.textContent = `Deploy ${template.title}`; + + const deployImage = document.getElementById('deploy-image'); + deployImage.value = template.image || ''; + + const deployPorts = document.getElementById('deploy-ports'); + deployPorts.value = (template.ports || []).join(', '); + + const deployVolumes = document.getElementById('deploy-volumes'); + deployVolumes.value = (template.volumes || []) + .map(volume => `${volume.bind}:${volume.container}`) + .join(', '); + + const deployEnv = document.getElementById('deploy-env'); + deployEnv.innerHTML = ''; + (template.env || []).forEach(env => { + const envRow = document.createElement('div'); + envRow.className = 'mb-3'; + envRow.innerHTML = ` + + + `; + deployEnv.appendChild(envRow); + }); + + templateDeployModal.show(); +} + +// Deploy Docker container +async function deployDockerContainer(payload) { + const { imageName, ports = [], volumes = [], envVars = [] } = payload; + + const validPorts = ports.filter(port => { + if (!port || !port.includes('/')) { + console.warn(`[WARN] Invalid port entry skipped: ${port}`); + return false; + } + return true; + }); + + const validVolumes = volumes.filter(volume => { + if (!volume || !volume.includes(':')) { + console.warn(`[WARN] Invalid volume entry skipped: ${volume}`); + return false; + } + return true; + }); + + console.log('[INFO] Sending deployment command to the server...'); + sendCommand('deployContainer', { + image: imageName, + ports: validPorts, + volumes: validVolumes, + env: envVars.map(({ name, value }) => ({ name, value })), + }); +} + +// Handle form submission for deployment +deployForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const imageName = document.getElementById('deploy-image').value.trim(); + const ports = document.getElementById('deploy-ports').value.split(',').map(port => port.trim()); + const volumes = document.getElementById('deploy-volumes').value.split(',').map(volume => volume.trim()); + const envInputs = document.querySelectorAll('#deploy-env input'); + const envVars = Array.from(envInputs).map(input => ({ + name: input.getAttribute('data-env-name'), + value: input.value.trim(), + })); + + const deployPayload = { imageName, ports, volumes, envVars }; + + console.log('[DEBUG] Deploy payload:', deployPayload); + try { + showStatusIndicator('Deploying container...'); + await deployDockerContainer(deployPayload); + hideStatusIndicator(); + closeAllModals(); + showAlert('success', `Container deployed successfully from image ${imageName}.`); + } catch (error) { + console.error('[ERROR] Failed to deploy container:', error.message); + hideStatusIndicator(); + showAlert('danger', 'Failed to deploy container.'); + } +}); + +// Initialize templates on load +document.addEventListener('DOMContentLoaded', fetchTemplates); + +// Export required functions +export { fetchTemplates, displayTemplateList, openDeployModal }; diff --git a/package.json b/package.json index e5267ae..d3f5ecf 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "https://ka-f.fontawesome.com", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", - "ws://localhost:8080" + "ws://localhost:8080", + "https://raw.githubusercontent.com", + "https://portainer-io-assets.sfo2.digitaloceanspaces.com" ] }, "type": "module", @@ -29,10 +31,13 @@ "pear-interface": "^1.0.0" }, "dependencies": { + "axios": "^1.7.8", "dockernode": "^0.1.0", "dockerode": "^4.0.2", "dotenv": "^16.4.5", "hyperswarm": "^4.8.4", + "stream": "^0.0.3", + "util": "^0.12.5", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0" } diff --git a/server/server.js b/server/server.js index 335d3ec..1f14cbb 100644 --- a/server/server.js +++ b/server/server.js @@ -163,35 +163,35 @@ swarm.on('connection', (peer) => { break; - case 'logs': - console.log(`[INFO] Handling 'logs' command for container: ${parsedData.args.id}`); - const logsContainer = docker.getContainer(parsedData.args.id); - const logsStream = await logsContainer.logs({ - stdout: true, - stderr: true, - tail: 100, // Fetch the last 100 log lines - follow: true, // Stream live logs - }); - - logsStream.on('data', (chunk) => { - peer.write( - JSON.stringify({ - type: 'logs', - data: chunk.toString('base64'), // Send base64 encoded logs - }) - ); - }); - - logsStream.on('end', () => { - console.log(`[INFO] Log stream ended for container: ${parsedData.args.id}`); - }); - - logsStream.on('error', (err) => { - console.error(`[ERROR] Log stream error for container ${parsedData.args.id}: ${err.message}`); - peer.write(JSON.stringify({ error: `Log stream error: ${err.message}` })); - }); - - break; + case 'logs': + console.log(`[INFO] Handling 'logs' command for container: ${parsedData.args.id}`); + const logsContainer = docker.getContainer(parsedData.args.id); + const logsStream = await logsContainer.logs({ + stdout: true, + stderr: true, + tail: 100, // Fetch the last 100 log lines + follow: true, // Stream live logs + }); + + logsStream.on('data', (chunk) => { + peer.write( + JSON.stringify({ + type: 'logs', + data: chunk.toString('base64'), // Send base64 encoded logs + }) + ); + }); + + logsStream.on('end', () => { + console.log(`[INFO] Log stream ended for container: ${parsedData.args.id}`); + }); + + logsStream.on('error', (err) => { + console.error(`[ERROR] Log stream error for container ${parsedData.args.id}: ${err.message}`); + peer.write(JSON.stringify({ error: `Log stream error: ${err.message}` })); + }); + + break; case 'duplicateContainer': console.log('[INFO] Handling \'duplicateContainer\' command'); @@ -225,6 +225,106 @@ swarm.on('connection', (peer) => { response = { success: true, message: `Container ${parsedData.args.id} removed` }; break; + case 'deployContainer': + console.log('[INFO] Handling "deployContainer" command'); + const { image: imageToDeploy, ports = [], volumes = [], env = [] } = parsedData.args; + + try { + // Validate and sanitize image + if (!imageToDeploy || typeof imageToDeploy !== 'string') { + throw new Error('Invalid or missing Docker image.'); + } + + // Validate and sanitize ports + const validPorts = ports.filter((port) => { + if (typeof port === 'string' && /^\d+\/(tcp|udp)$/.test(port)) { + return true; + } else { + console.warn(`[WARN] Invalid port entry skipped: ${port}`); + return false; + } + }); + + // Validate and sanitize volumes + const validVolumes = volumes.filter((volume) => { + if (typeof volume === 'string' && volume.includes(':')) { + return true; + } else { + console.warn(`[WARN] Invalid volume entry skipped: ${volume}`); + return false; + } + }); + + // Validate and sanitize environment variables + const validEnv = env.map(({ name, value }) => { + if (name && value) { + return `${name}=${value}`; + } else { + console.warn(`[WARN] Invalid environment variable skipped: name=${name}, value=${value}`); + return null; + } + }).filter(Boolean); + + console.log(`[INFO] Pulling Docker image "${imageToDeploy}"`); + + // Pull the Docker image + const pullStream = await docker.pull(imageToDeploy); + await new Promise((resolve, reject) => { + docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve())); + }); + + console.log(`[INFO] Image "${imageToDeploy}" pulled successfully`); + + // Configure container creation settings + const hostConfig = { + PortBindings: {}, + Binds: validVolumes, // Use validated volumes in Docker's expected format + NetworkMode: 'bridge', // Set the network mode to bridge + }; + validPorts.forEach((port) => { + const [containerPort, protocol] = port.split('/'); + hostConfig.PortBindings[`${containerPort}/${protocol}`] = [{ HostPort: containerPort }]; + }); + + // Create and start the container + console.log('[INFO] Creating the container...'); + const container = await docker.createContainer({ + Image: imageToDeploy, + Env: validEnv, + HostConfig: hostConfig, + }); + + console.log('[INFO] Starting the container...'); + await container.start(); + + console.log(`[INFO] Container deployed successfully from image "${imageToDeploy}"`); + + // Respond with success message + peer.write( + JSON.stringify({ + success: true, + message: `Container deployed successfully from image "${imageToDeploy}"`, + }) + ); + + // Update all peers with the latest container list + const containers = await docker.listContainers({ all: true }); + const update = { type: 'containers', data: containers }; + + for (const connectedPeer of connectedPeers) { + connectedPeer.write(JSON.stringify(update)); + } + } catch (err) { + console.error(`[ERROR] Failed to deploy container: ${err.message}`); + peer.write( + JSON.stringify({ + error: `Failed to deploy container: ${err.message}`, + }) + ); + } + break; + + case 'startTerminal': console.log(`[INFO] Starting terminal for container: ${parsedData.args.containerId}`); handleTerminal(parsedData.args.containerId, peer);