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 = `
+
+
+
${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);