first attempt at tempate deployments
This commit is contained in:
parent
77122a58b7
commit
2839fd7a7d
57
app.js
57
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 = `
|
||||
<span>
|
||||
<span class="connection-status status-disconnected"></span>${topicId}
|
||||
</span>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-primary docker-terminal-btn">
|
||||
<i class="fas fa-terminal"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger disconnect-btn">
|
||||
<i class="fas fa-plug"></i>
|
||||
</button>
|
||||
<div class="connection-item d-flex align-items-center justify-content-between p-2">
|
||||
<div class="connection-info">
|
||||
<span class="topic-id">${topicId}</span>
|
||||
<span class="connection-status status-disconnected ms-2"></span>
|
||||
</div><BR>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-primary docker-terminal-btn" title="Open Terminal">
|
||||
<i class="fas fa-terminal"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary deploy-template-btn" title="Deploy Template">
|
||||
<i class="fas fa-cubes"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger disconnect-btn" title="Disconnect">
|
||||
<i class="fas fa-plug"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
// 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();
|
||||
|
67
index.html
67
index.html
@ -545,6 +545,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Input -->
|
||||
|
||||
<!-- Deploy Modal -->
|
||||
<div class="modal fade" id="templateDeployModal" tabindex="-1" aria-labelledby="deployModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deploy-title"></h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" id="template-search-input" class="form-control my-3" placeholder="Search templates...">
|
||||
<ul id="template-list" class="list-group"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Deploy Modal -->
|
||||
<div class="modal fade" id="templateDeployModal" tabindex="-1" aria-labelledby="templateDeployModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="templateDeployModalLabel">Deploy Template</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" id="template-search-input" class="form-control mb-3" placeholder="Search templates...">
|
||||
<ul id="template-list" class="list-group"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy Template Modal -->
|
||||
<div class="modal fade" id="templateDeployModalUnique" tabindex="-1" aria-labelledby="templateDeployModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deploy-title">Deploy Template</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="deploy-form">
|
||||
<div class="mb-3">
|
||||
<label for="deploy-image" class="form-label">Image</label>
|
||||
<input type="text" id="deploy-image" class="form-control" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="deploy-ports" class="form-label">Ports</label>
|
||||
<input type="text" id="deploy-ports" class="form-control" placeholder="e.g., 80/tcp, 443/tcp" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="deploy-volumes" class="form-label">Volumes</label>
|
||||
<input type="text" id="deploy-volumes" class="form-control" placeholder="e.g., /host/path:/container/path" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="deploy-env" class="form-label">Environment Variables</label>
|
||||
<div id="deploy-env"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Deploy</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Container -->
|
||||
<div id="alert-container" class="position-fixed top-0 start-50 translate-middle-x mt-3"
|
||||
style="z-index: 1051; max-width: 90%;"></div>
|
||||
|
200
libs/templateDeploy.js
Normal file
200
libs/templateDeploy.js
Normal file
@ -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 = `
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-light" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3 text-light">${message}</p>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div>
|
||||
<img src="${template.logo || ''}" alt="Logo" class="me-2" style="width: 24px; height: 24px;">
|
||||
<span>${template.title}</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm deploy-btn">Deploy</button>
|
||||
`;
|
||||
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 = `
|
||||
<label>${env.label || env.name}</label>
|
||||
<input type="text" class="form-control" data-env-name="${env.name}" value="${env.default || ''}">
|
||||
`;
|
||||
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 };
|
@ -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"
|
||||
}
|
||||
|
150
server/server.js
150
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
|
||||
});
|
||||
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('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('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}` }));
|
||||
});
|
||||
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;
|
||||
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);
|
||||
|
Loading…
Reference in New Issue
Block a user