Compare commits
37 Commits
1dc39decf7
...
main
Author | SHA1 | Date | |
---|---|---|---|
0cf82495e9 | |||
0bfb29c6ff | |||
faa225c077 | |||
ee9f65fef7 | |||
5e96160b7c | |||
526ef0ca84 | |||
35010022bc | |||
73bec336e3 | |||
605e30c368 | |||
3457eb0e46 | |||
5e8c085446 | |||
55d502c5f4 | |||
58335ead6d | |||
40db213f74 | |||
72e164fe34 | |||
ffb51d0d72 | |||
71992f004c | |||
2839fd7a7d | |||
77122a58b7 | |||
3e37359e61 | |||
9389d41364 | |||
b370169aaf | |||
c38f23d50e | |||
73adabe2c4 | |||
57209a8159 | |||
a3c5f18736 | |||
ad67461cf5 | |||
f995c44057 | |||
e4aeb9d53f | |||
f3695c274b | |||
925ae73cad | |||
40c1d7a5fb | |||
746dba4394 | |||
8699a265ae | |||
d9549454e1 | |||
3177976845 | |||
b8c6f30d32 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
server/.env
|
||||
|
181
README.md
181
README.md
@ -1,8 +1,8 @@
|
||||
# Peartainer
|
||||
# peardock
|
||||
|
||||
## Overview
|
||||
|
||||
Peartainer is a decentralized, peer-to-peer application designed to streamline Docker container management using Hyperswarm. The application connects multiple peers over a distributed hash table (DHT) network and provides full control over Docker containers, including starting, stopping, removing, duplicating, and monitoring real-time metrics. With its robust server key-based architecture, Peartainer ensures secure and persistent peer-to-peer communication.
|
||||
peardock is a decentralized, peer-to-peer application designed to streamline Docker container management using Hyperswarm. The application connects multiple peers over a distributed hash table (DHT) network and provides full control over Docker containers, including starting, stopping, removing, duplicating, viewing logs, deploying from templates, and monitoring real-time metrics. With its robust server key-based architecture, peardock ensures secure and persistent peer-to-peer communication.
|
||||
|
||||
The **server key** forms the foundation of the connection. It is automatically generated, saved, and reused unless explicitly refreshed, making it easy to maintain consistent access while allowing for manual key regeneration when needed.
|
||||
|
||||
@ -25,15 +25,25 @@ pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykc9b8cdnjicaqcgoefa4wo
|
||||
|
||||
- **Real-Time Docker Management**:
|
||||
- List all containers across peers with statuses.
|
||||
- Start, stop, and remove containers remotely.
|
||||
- Start, stop, restart, and remove containers remotely.
|
||||
|
||||
- **Dynamic Terminal Sessions**:
|
||||
- Open and manage multiple terminals for running containers.
|
||||
- Real-time shell sessions streamed to connected peers.
|
||||
|
||||
- **Docker CLI Terminal**:
|
||||
- Access a Docker CLI terminal to run Docker commands on the remote peer.
|
||||
|
||||
- **Container Duplication**:
|
||||
- Clone containers with custom configurations for CPUs, memory, network mode, and hostname.
|
||||
|
||||
- **Template Deployment**:
|
||||
- Deploy containers using templates fetched from a remote repository.
|
||||
- Customize deployment parameters such as ports, volumes, and environment variables.
|
||||
|
||||
- **Container Logs**:
|
||||
- View real-time and historical logs of containers.
|
||||
|
||||
- **Live Statistics Streaming**:
|
||||
- Broadcast CPU, memory, and network stats in real-time to connected peers.
|
||||
|
||||
@ -47,11 +57,13 @@ pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykc9b8cdnjicaqcgoefa4wo
|
||||
- Modern, responsive UI built with **Bootstrap**.
|
||||
- Integrated terminal viewer powered by **Xterm.js**.
|
||||
- Real-time container stats displayed for each container.
|
||||
- View container logs directly from the UI.
|
||||
- Deploy containers using templates with a user-friendly wizard.
|
||||
|
||||
- **Production Deployment**:
|
||||
- Ready-to-use client app available via Pear runtime:
|
||||
```bash
|
||||
pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykc9b8cdnjicaqcgoefa4wo
|
||||
pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykcgoefa4wo
|
||||
```
|
||||
|
||||
---
|
||||
@ -102,15 +114,15 @@ Peers connect to the server using the unique topic derived from the `SERVER_KEY`
|
||||
|
||||
The server interacts with Docker using **Dockerode**:
|
||||
|
||||
- List containers:
|
||||
- **List Containers**:
|
||||
```javascript
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
```
|
||||
- Start a container:
|
||||
- **Start a Container**:
|
||||
```javascript
|
||||
await docker.getContainer(containerId).start();
|
||||
```
|
||||
- Stream statistics:
|
||||
- **Stream Statistics**:
|
||||
```javascript
|
||||
container.stats({ stream: true }, (err, stream) => {
|
||||
stream.on('data', (data) => {
|
||||
@ -119,6 +131,8 @@ The server interacts with Docker using **Dockerode**:
|
||||
});
|
||||
});
|
||||
```
|
||||
- **Docker CLI Commands**:
|
||||
- Execute Docker commands received from the client within controlled parameters to ensure security.
|
||||
|
||||
---
|
||||
|
||||
@ -150,36 +164,37 @@ The server interacts with Docker using **Dockerode**:
|
||||
|
||||
### Server Setup
|
||||
|
||||
1. Clone the repository:
|
||||
1. **Clone the Repository**:
|
||||
```bash
|
||||
git clone https://git.ssh.surf/snxraven/peartainer.git
|
||||
cd peartainer
|
||||
git clone https://git.ssh.surf/snxraven/peardock.git
|
||||
cd peardock
|
||||
```
|
||||
|
||||
2. Change to server Dir:
|
||||
2. **Change to Server Directory**:
|
||||
```bash
|
||||
cd server
|
||||
```
|
||||
|
||||
3. npm install:
|
||||
3. **Install Dependencies**:
|
||||
```bash
|
||||
npm install hyperswarm dockerode hypercore-crypto stream dotenv
|
||||
```
|
||||
|
||||
4. Run Server:
|
||||
4. **Run the Server**:
|
||||
```bash
|
||||
node server.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Client Setup
|
||||
|
||||
1. For development, run:
|
||||
1. **For Development**, run:
|
||||
```bash
|
||||
pear run --dev .
|
||||
```
|
||||
|
||||
2. For production, use the pre-deployed Pear app:
|
||||
2. **For Production**, use the pre-deployed Pear app:
|
||||
```bash
|
||||
pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykc9b8cdnjicaqcgoefa4wo
|
||||
```
|
||||
@ -198,19 +213,85 @@ The server interacts with Docker using **Dockerode**:
|
||||
- **Listing Containers**:
|
||||
- View all containers (running and stopped) with their statuses.
|
||||
|
||||
- **Starting/Stopping Containers**:
|
||||
- Use the action buttons in the container list.
|
||||
- **Starting/Stopping/Restarting Containers**:
|
||||
- Use the action buttons (play, stop, restart icons) in the container list.
|
||||
|
||||
- **Removing Containers**:
|
||||
- Click the trash icon to delete a container.
|
||||
|
||||
- **Viewing Container Logs**:
|
||||
- Click the logs icon to view real-time and historical logs of a container.
|
||||
|
||||
- **Duplicating Containers**:
|
||||
- Click the clone icon and customize the duplication form.
|
||||
|
||||
### Terminal Access
|
||||
|
||||
- Open terminals for running containers.
|
||||
- Switch between sessions using the tray at the bottom.
|
||||
- **Container Terminal**:
|
||||
- Open terminals for running containers by clicking the terminal icon.
|
||||
- Switch between sessions using the tray at the bottom.
|
||||
|
||||
- **Docker CLI Terminal**:
|
||||
- Access a Docker CLI terminal to execute Docker commands on the remote peer.
|
||||
- Click the Docker terminal icon in the connection list.
|
||||
|
||||
### Template Deployment
|
||||
|
||||
- **Deploying from Templates**:
|
||||
- Open the template deployment modal by clicking the deploy template icon.
|
||||
- Search and select templates from the list.
|
||||
- Customize deployment parameters such as container name, image, ports, volumes, and environment variables.
|
||||
- Deploy the container with the specified settings.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Welcome Screen
|
||||
|
||||

|
||||
|
||||
*The initial welcome screen guiding users to add a connection.*
|
||||
|
||||
---
|
||||
|
||||
### Container List
|
||||
|
||||

|
||||
|
||||
*Displaying all Docker containers with real-time stats and action buttons.*
|
||||
|
||||
---
|
||||
|
||||
### Template Deployments
|
||||
|
||||

|
||||
|
||||
*Browsing and selecting templates for deployment from a remote repository.*
|
||||
|
||||
---
|
||||
|
||||
### Final Deploy Modal
|
||||
|
||||

|
||||
|
||||
*Customizing deployment parameters before launching a new container.*
|
||||
|
||||
---
|
||||
|
||||
### Duplicate Container Form
|
||||
|
||||

|
||||
|
||||
*Duplicating an existing container with options to modify configurations.*
|
||||
|
||||
---
|
||||
|
||||
### Container Logs
|
||||
|
||||

|
||||
|
||||
*Viewing real-time logs of a container directly from the UI.*
|
||||
|
||||
---
|
||||
|
||||
@ -232,7 +313,15 @@ The server interacts with Docker using **Dockerode**:
|
||||
|
||||
### Docker Commands
|
||||
|
||||
Add new commands in `server/server.js` under the `switch` statement for additional Docker functionalities.
|
||||
- Add new commands in `server/server.js` under the `switch` statement for additional Docker functionalities:
|
||||
```javascript
|
||||
switch (parsedData.command) {
|
||||
case 'newCommand':
|
||||
// Implement your command logic here
|
||||
break;
|
||||
// Existing cases...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -240,6 +329,8 @@ Add new commands in `server/server.js` under the `switch` statement for addition
|
||||
|
||||
- The `SERVER_KEY` is sensitive and should be stored securely.
|
||||
- Refresh the key periodically to enhance security, especially in untrusted environments.
|
||||
- peardock uses encrypted peer-to-peer connections, but it's recommended to run it within secure networks.
|
||||
- Limit access to the server by controlling who has the `SERVER_KEY`.
|
||||
|
||||
---
|
||||
|
||||
@ -250,16 +341,66 @@ Add new commands in `server/server.js` under the `switch` statement for addition
|
||||
1. **Unable to Connect**:
|
||||
- Verify the `SERVER_KEY` matches on both server and client.
|
||||
- Ensure the server is running and accessible.
|
||||
- Check network configurations and firewall settings.
|
||||
|
||||
2. **Docker Errors**:
|
||||
- Ensure Docker is running and properly configured.
|
||||
- Check permissions to manage Docker.
|
||||
- Verify that the user running the server has access to the Docker daemon.
|
||||
|
||||
3. **Terminal Issues**:
|
||||
- Verify the container has a valid shell (e.g., `/bin/bash`).
|
||||
- Ensure that the container is running before opening a terminal.
|
||||
- Check for network latency that might affect terminal responsiveness.
|
||||
|
||||
4. **Template Deployment Failures**:
|
||||
- Ensure the Docker image specified in the template is valid and accessible.
|
||||
- Check network connectivity if pulling images from remote repositories.
|
||||
- Validate all required parameters in the deployment form.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Fork the repository, make your changes, and submit a pull request.
|
||||
|
||||
1. **Fork the Repository**:
|
||||
- Click the "Fork" button at the top of the repository page.
|
||||
|
||||
2. **Clone Your Fork**:
|
||||
```bash
|
||||
git clone https://github.com/your-username/peardock.git
|
||||
```
|
||||
|
||||
3. **Create a Branch for Your Feature**:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
4. **Make Changes and Commit**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add your feature"
|
||||
```
|
||||
|
||||
5. **Push to Your Fork**:
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
6. **Submit a Pull Request**:
|
||||
- Go to your fork on GitHub and click the "New pull request" button.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **Portainer**: For inspiring the creation of a powerful Docker management tool.
|
||||
- **Hyperswarm**: Providing the peer-to-peer networking backbone.
|
||||
- **Dockerode**: Facilitating Docker API interactions in Node.js.
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
For questions, issues, or suggestions, please open an issue on the [GitHub repository](https://git.ssh.surf/snxraven/peardock).
|
387
app.js
387
app.js
@ -1,6 +1,8 @@
|
||||
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');
|
||||
@ -20,9 +22,10 @@ const connections = {};
|
||||
window.openTerminals = {};
|
||||
let activePeer = null;
|
||||
window.activePeer = null; // Expose to other modules
|
||||
hideStatusIndicator();
|
||||
let statsInterval = null; // Global variable to hold the interval
|
||||
|
||||
hideStatusIndicator();
|
||||
let statsInterval = null;
|
||||
let lastStatsUpdate = Date.now();
|
||||
function stopStatsInterval() {
|
||||
if (statsInterval) {
|
||||
clearInterval(statsInterval);
|
||||
@ -31,22 +34,65 @@ 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');
|
||||
|
||||
if (dockerTerminalModal) {
|
||||
dockerTerminalModal.addEventListener('hidden.bs.modal', () => {
|
||||
console.log('[INFO] Modal fully closed. Performing additional cleanup.');
|
||||
cleanUpDockerTerminal();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function startStatsInterval() {
|
||||
// Clear any existing interval
|
||||
if (statsInterval) {
|
||||
clearInterval(statsInterval);
|
||||
}
|
||||
|
||||
// Start a new interval to request stats every second
|
||||
statsInterval = setInterval(() => {
|
||||
if (window.activePeer) {
|
||||
console.log('[INFO] Requesting container stats...');
|
||||
sendCommand('stats', {}); // Adjust the command if specific arguments are needed
|
||||
const now = Date.now();
|
||||
if (now - lastStatsUpdate >= 500) { // Ensure at least 500ms between updates
|
||||
// sendCommand('allStats', {}); // Adjust command if necessary
|
||||
lastStatsUpdate = now;
|
||||
}
|
||||
} else {
|
||||
console.warn('[WARN] No active peer; skipping stats request.');
|
||||
}
|
||||
}, 1000); // 1 second interval
|
||||
}, 100); // Poll every 100ms for better reactivity
|
||||
}
|
||||
const smoothedStats = {}; // Container-specific smoothing storage
|
||||
|
||||
function smoothStats(containerId, newStats, smoothingFactor = 0.2) {
|
||||
if (!smoothedStats[containerId]) {
|
||||
smoothedStats[containerId] = { cpu: 0, memory: 0, ip: newStats.ip || 'No IP Assigned' };
|
||||
}
|
||||
|
||||
smoothedStats[containerId].cpu =
|
||||
smoothedStats[containerId].cpu * (1 - smoothingFactor) +
|
||||
newStats.cpu * smoothingFactor;
|
||||
|
||||
smoothedStats[containerId].memory =
|
||||
smoothedStats[containerId].memory * (1 - smoothingFactor) +
|
||||
newStats.memory * smoothingFactor;
|
||||
|
||||
// Preserve the latest IP address
|
||||
smoothedStats[containerId].ip = newStats.ip || smoothedStats[containerId].ip;
|
||||
|
||||
return smoothedStats[containerId];
|
||||
}
|
||||
|
||||
|
||||
function refreshContainerStats() {
|
||||
console.log('[INFO] Refreshing container stats...');
|
||||
@ -62,6 +108,7 @@ function waitForPeerResponse(expectedMessageFragment, timeout = 900000) {
|
||||
|
||||
window.handlePeerResponse = (response) => {
|
||||
console.log(`[DEBUG] Received response: ${JSON.stringify(response)}`);
|
||||
console.log(response.message)
|
||||
if (response && response.success && response.message.includes(expectedMessageFragment)) {
|
||||
console.log(`[DEBUG] Expected response received: ${response.message}`);
|
||||
resolve(response);
|
||||
@ -219,33 +266,26 @@ function hideStatusIndicator() {
|
||||
}
|
||||
}
|
||||
// Show Alert
|
||||
// Show alert message
|
||||
function showAlert(type, message) {
|
||||
const alertContainer = document.getElementById('alert-container');
|
||||
const alertBox = document.createElement('div');
|
||||
alertBox.className = `alert alert-${type}`;
|
||||
alertBox.textContent = message;
|
||||
|
||||
// Create alert element
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert ${type}`;
|
||||
alert.innerHTML = `
|
||||
<span>${message}</span>
|
||||
<button class="close-btn" aria-label="Close">×</button>
|
||||
`;
|
||||
const container = document.querySelector('#alert-container');
|
||||
if (container) {
|
||||
container.appendChild(alertBox);
|
||||
|
||||
// Add close button functionality
|
||||
const closeButton = alert.querySelector('.close-btn');
|
||||
closeButton.addEventListener('click', () => {
|
||||
alert.remove(); // Remove alert on close
|
||||
});
|
||||
|
||||
// Append alert to container
|
||||
alertContainer.appendChild(alert);
|
||||
|
||||
// Automatically remove alert after 5 seconds
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 5000);
|
||||
setTimeout(() => {
|
||||
container.removeChild(alertBox);
|
||||
}, 5000);
|
||||
} else {
|
||||
console.warn('[WARN] Alert container not found.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Collapse Sidebar Functionality
|
||||
const collapseSidebarBtn = document.getElementById('collapse-sidebar-btn');
|
||||
collapseSidebarBtn.addEventListener('click', () => {
|
||||
@ -261,43 +301,77 @@ collapseSidebarBtn.addEventListener('click', () => {
|
||||
|
||||
function handlePeerData(data, topicId, peer) {
|
||||
try {
|
||||
// Parse the incoming data
|
||||
const response = JSON.parse(data.toString());
|
||||
console.log(`[DEBUG] Received data from peer (topic: ${topicId}): ${JSON.stringify(response)}`);
|
||||
console.log(response.message)
|
||||
if (response.success && response.message.includes && response.message.includes('deployed successfully')) {
|
||||
console.log(`[INFO] Template deployed successfully: ${response.message}`);
|
||||
closeAllModals(); // Close all modals after successful deployment
|
||||
|
||||
// Ensure the data is for the active connection
|
||||
if (!connections[topicId] || peer !== window.activePeer) {
|
||||
console.warn(`[WARN] Ignoring data from inactive peer or topic: ${topicId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the response based on its type
|
||||
if (response.error) {
|
||||
console.error(`[ERROR] Server error: ${response.error}`);
|
||||
showAlert('danger', response.error);
|
||||
hideStatusIndicator();
|
||||
startStatsInterval(); // Restart stats polling
|
||||
showAlert('success', response.message);
|
||||
hideStatusIndicator();
|
||||
|
||||
}
|
||||
// Ensure the data is for the active connection
|
||||
if (!connections[topicId]) {
|
||||
console.warn(`[WARN] No connection found for topic: ${topicId}. Ignoring data.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.type === 'containers') {
|
||||
renderContainers(response.data, topicId); // Scope containers to this topic
|
||||
} else if (response.type === 'stats') {
|
||||
response.data.topicId = topicId; // Attach the topicId to the stats
|
||||
updateContainerStats(response.data); // Update stats for specific containers
|
||||
} else if (response.type === 'terminalOutput') {
|
||||
appendTerminalOutput(response.data, response.containerId, response.encoding);
|
||||
} else if (response.type === 'containerConfig') {
|
||||
if (window.inspectContainerCallback) {
|
||||
window.inspectContainerCallback(response.data);
|
||||
window.inspectContainerCallback = null; // Reset the callback
|
||||
}
|
||||
if (peer !== connections[topicId].peer) {
|
||||
console.warn(`[WARN] Ignoring data from a non-active peer for topic: ${topicId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate handling based on the response type
|
||||
switch (response.type) {
|
||||
case 'allStats':
|
||||
console.log('[INFO] Received aggregated stats for all containers.');
|
||||
response.data.forEach((stats) => updateContainerStats(stats));
|
||||
break;
|
||||
|
||||
case 'containers':
|
||||
console.log('[INFO] Processing container list...');
|
||||
renderContainers(response.data, topicId); // Render containers specific to this topic
|
||||
break;
|
||||
|
||||
case 'terminalOutput':
|
||||
console.log('[INFO] Appending terminal output...');
|
||||
appendTerminalOutput(response.data, response.containerId, response.encoding);
|
||||
break;
|
||||
|
||||
case 'containerConfig':
|
||||
console.log('[INFO] Handling container configuration...');
|
||||
if (window.inspectContainerCallback) {
|
||||
window.inspectContainerCallback(response.data);
|
||||
window.inspectContainerCallback = null; // Reset the callback
|
||||
}
|
||||
break;
|
||||
|
||||
case 'logs':
|
||||
console.log('[INFO] Handling logs output...');
|
||||
if (window.handleLogOutput) {
|
||||
window.handleLogOutput(response);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[WARN] Unhandled response type: ${response.type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle peer response callback if defined
|
||||
if (typeof window.handlePeerResponse === 'function') {
|
||||
window.handlePeerResponse(response);
|
||||
}
|
||||
} catch (err) {
|
||||
// Catch and log any parsing or processing errors
|
||||
console.error(`[ERROR] Failed to process peer data: ${err.message}`);
|
||||
showAlert('danger', 'Failed to process peer data.');
|
||||
console.error(`[DEBUG] Raw data received: ${data.toString()}`);
|
||||
showAlert('danger', 'Failed to process peer data. Check the console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -305,6 +379,8 @@ function handlePeerData(data, topicId, peer) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Add a new connection
|
||||
addConnectionForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
@ -333,19 +409,78 @@ 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>
|
||||
<button class="btn btn-sm btn-danger disconnect-btn">
|
||||
<i class="fas fa-plug"></i>
|
||||
</button>
|
||||
`;
|
||||
<div class="connection-item row align-items-center px-2 py-1 border-bottom bg-dark text-light">
|
||||
<!-- Connection Info -->
|
||||
<div class="col-8 connection-info text-truncate">
|
||||
<span>
|
||||
<span class="connection-status ${connections[topicId].peer ? 'status-connected' : 'status-disconnected'}"></span>${topicId}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="col-4 d-flex justify-content-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary docker-terminal-btn p-1" title="Open Terminal">
|
||||
<i class="fas fa-terminal"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary deploy-template-btn p-1" title="Deploy Template">
|
||||
<i class="fas fa-cubes"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger disconnect-btn p-1" title="Disconnect">
|
||||
<i class="fas fa-plug"></i>
|
||||
</button>
|
||||
</div>
|
||||
</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) => {
|
||||
event.stopPropagation();
|
||||
|
||||
console.log('[DEBUG] Docker terminal button clicked.');
|
||||
|
||||
if (!topicId) {
|
||||
console.error('[ERROR] Missing topicId. Cannot proceed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = connections[topicId];
|
||||
console.log(`[DEBUG] Retrieved connection for topicId: ${topicId}`, connection);
|
||||
|
||||
if (connection && connection.peer) {
|
||||
try {
|
||||
console.log(`[DEBUG] Starting Docker terminal for topicId: ${topicId}`);
|
||||
startDockerTerminal(topicId, connection.peer);
|
||||
|
||||
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
|
||||
if (dockerTerminalModal) {
|
||||
const modalInstance = new bootstrap.Modal(dockerTerminalModal);
|
||||
modalInstance.show();
|
||||
console.log('[DEBUG] Docker Terminal modal displayed.');
|
||||
} else {
|
||||
console.error('[ERROR] Docker Terminal modal not found in the DOM.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Failed to start Docker CLI terminal for topicId: ${topicId}`, error);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[WARNING] No active peer found for topicId: ${topicId}. Unable to start Docker CLI terminal.`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
connectionItem.querySelector('span').addEventListener('click', () => switchConnection(topicId));
|
||||
connectionItem.querySelector('.disconnect-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
disconnectConnection(topicId, connectionItem);
|
||||
});
|
||||
refreshContainerStats();
|
||||
|
||||
connectionList.appendChild(connectionItem);
|
||||
|
||||
@ -370,12 +505,13 @@ function addConnection(topicHex) {
|
||||
window.activePeer = null;
|
||||
dashboard.classList.add('hidden');
|
||||
containerList.innerHTML = '';
|
||||
stopStatsInterval(); // Stop stats polling
|
||||
}
|
||||
});
|
||||
|
||||
if (!window.activePeer) {
|
||||
switchConnection(topicId);
|
||||
}
|
||||
startStatsInterval();
|
||||
});
|
||||
|
||||
// Collapse the sidebar after adding a connection
|
||||
@ -389,6 +525,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();
|
||||
@ -398,6 +548,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const topicHex = savedConnections[topicId].topic;
|
||||
addConnection(topicHex);
|
||||
});
|
||||
|
||||
if (Object.keys(connections).length > 0) {
|
||||
hideWelcomePage();
|
||||
startStatsInterval(); // Start stats polling for active peers
|
||||
} else {
|
||||
showWelcomePage();
|
||||
}
|
||||
|
||||
assertVisibility();
|
||||
});
|
||||
|
||||
|
||||
@ -432,6 +591,9 @@ function disconnectConnection(topicId, connectionItem) {
|
||||
// Remove from global connections
|
||||
delete connections[topicId];
|
||||
|
||||
// Save the updated connections to cookies
|
||||
saveConnections();
|
||||
|
||||
// Remove the connection item from the UI
|
||||
if (connectionItem) {
|
||||
connectionList.removeChild(connectionItem);
|
||||
@ -481,7 +643,7 @@ function resetConnectionsView() {
|
||||
connectionItem.dataset.topicId = topicId;
|
||||
connectionItem.innerHTML = `
|
||||
<span>
|
||||
<span class="connection-status ${connections[topicId].peer ? 'status-connected' : 'status-disconnected'}"></span>${topicId}
|
||||
<span class="connection-status ${connections[topicId].peer ? 'status-connected' : 'status-disconnected'}"></span>
|
||||
</span>
|
||||
<button class="btn btn-sm btn-danger disconnect-btn">Disconnect</button>
|
||||
`;
|
||||
@ -568,37 +730,47 @@ function renderContainers(containers, topicId) {
|
||||
const image = container.Image || '-';
|
||||
const containerId = container.Id;
|
||||
const ipAddress = container.ipAddress || 'No IP Assigned';
|
||||
if (ipAddress === 'No IP Assigned') {
|
||||
console.warn(`[WARN] IP address missing for container ${container.Id}. Retrying...`);
|
||||
sendCommand('inspectContainer', { id: container.Id });
|
||||
}
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.containerId = containerId; // Store container ID for reference
|
||||
row.innerHTML = `
|
||||
<td>${name}</td>
|
||||
<td>${image}</td>
|
||||
<td>${container.State || 'Unknown'}</td>
|
||||
<td class="cpu">0</td>
|
||||
<td class="memory">0</td>
|
||||
<td class="ip-address">${ipAddress}</td>
|
||||
<td>
|
||||
<button class="btn btn-success btn-sm action-start" ${container.State === 'running' ? 'disabled' : ''}>
|
||||
<td>${name}</td>
|
||||
<td>${image}</td>
|
||||
<td>${container.State || 'Unknown'}</td>
|
||||
<td class="cpu">0</td>
|
||||
<td class="memory">0</td>
|
||||
<td class="ip-address">${ipAddress}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-success action-start p-1" title="Start" ${container.State === 'running' ? 'disabled' : ''}>
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
<button class="btn btn-info btn-sm action-restart" ${container.State !== 'running' ? 'disabled' : ''}>
|
||||
<button class="btn btn-outline-info action-restart p-1" title="Restart" ${container.State !== 'running' ? 'disabled' : ''}>
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm action-stop" ${container.State !== 'running' ? 'disabled' : ''}>
|
||||
<button class="btn btn-outline-warning action-stop p-1" title="Stop" ${container.State !== 'running' ? 'disabled' : ''}>
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm action-remove">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm action-terminal" ${container.State !== 'running' ? 'disabled' : ''}>
|
||||
<button class="btn btn-outline-primary action-logs p-1" title="Logs">
|
||||
<i class="fas fa-list-alt"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary action-terminal p-1" title="Terminal" ${container.State !== 'running' ? 'disabled' : ''}>
|
||||
<i class="fas fa-terminal"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm action-duplicate">
|
||||
<button class="btn btn-outline-secondary action-duplicate p-1" title="Duplicate">
|
||||
<i class="fas fa-clone"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
<button class="btn btn-outline-danger action-remove p-1" title="Remove">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
containerList.appendChild(row);
|
||||
// Add event listener for duplicate button
|
||||
const duplicateBtn = row.querySelector('.action-duplicate');
|
||||
@ -697,6 +869,35 @@ function addActionListeners(row, container) {
|
||||
}
|
||||
});
|
||||
|
||||
const logsBtn = row.querySelector('.action-logs');
|
||||
logsBtn.addEventListener('click', () => openLogModal(container.Id));
|
||||
|
||||
function openLogModal(containerId) {
|
||||
console.log(`[INFO] Opening logs modal for container: ${containerId}`);
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('logsModal'));
|
||||
const logContainer = document.getElementById('logs-container');
|
||||
|
||||
// Clear any existing logs
|
||||
logContainer.innerHTML = '';
|
||||
|
||||
// Request previous logs
|
||||
sendCommand('logs', { id: containerId });
|
||||
|
||||
// Listen for logs
|
||||
window.handleLogOutput = (logData) => {
|
||||
const logLine = atob(logData.data); // Decode base64 logs
|
||||
const logElement = document.createElement('pre');
|
||||
logElement.textContent = logLine;
|
||||
logContainer.appendChild(logElement);
|
||||
|
||||
// Scroll to the bottom
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
};
|
||||
|
||||
// Show the modal
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Remove Button
|
||||
removeBtn.addEventListener('click', async () => {
|
||||
@ -769,29 +970,35 @@ function addActionListeners(row, container) {
|
||||
}
|
||||
|
||||
|
||||
// Function to update container statistics
|
||||
function updateContainerStats(stats) {
|
||||
console.log(`[DEBUG] Updating stats for container ID: ${stats.id}, Topic ID: ${stats.topicId}`);
|
||||
|
||||
// Ensure stats belong to the active connection
|
||||
if (!window.activePeer || !connections[stats.topicId] || window.activePeer !== connections[stats.topicId].peer) {
|
||||
console.warn(`[WARN] Stats received for inactive or unknown connection. Skipping.`);
|
||||
if (!stats || !stats.id || typeof stats.cpu === 'undefined' || typeof stats.memory === 'undefined') {
|
||||
console.error('[ERROR] Invalid stats object:', stats);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the row for the container by its ID
|
||||
console.log(`[DEBUG] Updating stats for container ID: ${stats.id}`);
|
||||
|
||||
const row = containerList.querySelector(`tr[data-container-id="${stats.id}"]`);
|
||||
if (!row) {
|
||||
console.warn(`[WARN] No matching row for container ID: ${stats.id}. Skipping stats update.`);
|
||||
console.warn(`[WARN] No matching row for container ID: ${stats.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the container statistics in the UI
|
||||
row.querySelector('.cpu').textContent = stats.cpu.toFixed(2) || '0.00';
|
||||
row.querySelector('.memory').textContent = (stats.memory / (1024 * 1024)).toFixed(2) || '0.00';
|
||||
row.querySelector('.ip-address').textContent = stats.ip || 'No IP Assigned';
|
||||
// Ensure the IP address is added or retained from existing row
|
||||
const existingIpAddress = row.querySelector('.ip-address')?.textContent || 'No IP Assigned';
|
||||
stats.ip = stats.ip || existingIpAddress;
|
||||
|
||||
const smoothed = smoothStats(stats.id, stats);
|
||||
updateStatsUI(row, smoothed);
|
||||
}
|
||||
|
||||
function updateStatsUI(row, stats) {
|
||||
requestIdleCallback(() => {
|
||||
row.querySelector('.cpu').textContent = stats.cpu.toFixed(2) || '0.00';
|
||||
row.querySelector('.memory').textContent = (stats.memory / (1024 * 1024)).toFixed(2) || '0.00';
|
||||
row.querySelector('.ip-address').textContent = stats.ip;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Function to open the Duplicate Modal with container configurations
|
||||
@ -888,7 +1095,7 @@ function showWelcomePage() {
|
||||
}
|
||||
|
||||
if (connectionTitle) {
|
||||
connectionTitle.textContent = '';
|
||||
connectionTitle.textContent = '';
|
||||
} else {
|
||||
console.warn('[WARN] Connection title element not found!');
|
||||
}
|
||||
|
318
index.html
318
index.html
@ -86,7 +86,6 @@
|
||||
/* Leave space for the sidebar */
|
||||
flex: 1;
|
||||
/* Allow the content to grow */
|
||||
padding: 30px;
|
||||
overflow-y: auto;
|
||||
/* Allow scrolling if content overflows */
|
||||
position: relative;
|
||||
@ -239,7 +238,7 @@
|
||||
/* Ensure it uses all available space */
|
||||
width: 100%;
|
||||
/* Take up the full width of the content area */
|
||||
padding: 0;
|
||||
margin-top: 30px;
|
||||
/* Remove extra padding */
|
||||
overflow-y: auto;
|
||||
/* Allow vertical scrolling if needed */
|
||||
@ -251,22 +250,40 @@
|
||||
display: none !important;
|
||||
/* Hide the dashboard completely when not needed */
|
||||
}
|
||||
|
||||
#alert-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1055; /* Ensure it overlays important elements only */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1055;
|
||||
/* Ensure it overlays important elements only */
|
||||
display: flex;
|
||||
flex-direction: column-reverse; /* Stack alerts upwards */
|
||||
gap: 10px; /* Add space between alerts */
|
||||
pointer-events: none; /* Prevent container from blocking clicks */
|
||||
flex-direction: column-reverse;
|
||||
/* Stack alerts upwards */
|
||||
gap: 10px;
|
||||
/* Add space between alerts */
|
||||
pointer-events: none;
|
||||
/* Prevent container from blocking clicks */
|
||||
}
|
||||
|
||||
#alert-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1055;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
/* Prevent container from blocking clicks */
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 80%;
|
||||
padding: 12px 20px;
|
||||
background-color: #2b2b2b;
|
||||
color: #e0e0e0;
|
||||
@ -278,50 +295,151 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: auto; /* Allow alerts to be interactive */
|
||||
pointer-events: auto;
|
||||
/* Allow alerts to be interactive */
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
border-left: 6px solid #28a745; /* Green border for success */
|
||||
.alert.success {
|
||||
border-left: 6px solid #28a745;
|
||||
/* Green border for success */
|
||||
}
|
||||
|
||||
.alert.danger {
|
||||
border-left: 6px solid #dc3545;
|
||||
/* Red border for danger */
|
||||
}
|
||||
|
||||
.alert .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
/* Align to the far right */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#alert-container {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
max-width: 90%;
|
||||
font-size: 12px;
|
||||
padding: 10px 15px;
|
||||
white-space: normal;
|
||||
/* Allow wrapping on small screens */
|
||||
text-overflow: clip;
|
||||
/* Disable ellipsis when wrapping */
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.alert {
|
||||
white-space: nowrap;
|
||||
/* Re-enable nowrap for larger screens */
|
||||
text-overflow: ellipsis;
|
||||
/* Add ellipsis for overflowed text */
|
||||
}
|
||||
}
|
||||
|
||||
#sidebar.collapsed .btn-danger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* General scrollbar styles for dark and skinny scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
/* Firefox */
|
||||
scrollbar-color: #555 #1a1a1a;
|
||||
/* Firefox: thumb and track color */
|
||||
}
|
||||
|
||||
/* WebKit-based browsers (Chrome, Edge, Safari) */
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
/* Width of vertical scrollbar */
|
||||
height: 8px;
|
||||
/* Height of horizontal scrollbar */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
/* Track color */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #555;
|
||||
/* Thumb color */
|
||||
border-radius: 10px;
|
||||
/* Round the thumb */
|
||||
border: 2px solid #1a1a1a;
|
||||
/* Space between thumb and track */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #777;
|
||||
/* Lighter color on hover */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background-color: #999;
|
||||
/* Even lighter color when active */
|
||||
}
|
||||
|
||||
|
||||
.list-group-item {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);
|
||||
color: var(--bs-list-group-color);
|
||||
text-decoration: none;
|
||||
background-color: #2c2c2c
|
||||
|
||||
;
|
||||
}
|
||||
|
||||
.alert.danger {
|
||||
border-left: 6px solid #dc3545; /* Red border for danger */
|
||||
.list-group-item {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
|
||||
.alert .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-left: auto; /* Align to the far right */
|
||||
.text-primary {
|
||||
--bs-text-opacity: 1;
|
||||
color: rgb(254 254 254) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#alert-container {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
max-width: 90%;
|
||||
font-size: 12px;
|
||||
padding: 10px 15px;
|
||||
white-space: normal; /* Allow wrapping on small screens */
|
||||
text-overflow: clip; /* Disable ellipsis when wrapping */
|
||||
}
|
||||
.list-group {
|
||||
--bs-list-group-color: var(--bs-body-color);
|
||||
--bs-list-group-bg: var(--bs-body-bg);
|
||||
--bs-list-group-border-color: transparent;
|
||||
--bs-list-group-border-width: var(--bs-border-width);
|
||||
--bs-list-group-border-radius: var(--bs-border-radius);
|
||||
--bs-list-group-item-padding-x: 1rem;
|
||||
--bs-list-group-item-padding-y: 0.5rem;
|
||||
--bs-list-group-action-color: var(--bs-secondary-color);
|
||||
--bs-list-group-action-hover-color: var(--bs-emphasis-color);
|
||||
--bs-list-group-action-hover-bg: var(--bs-tertiary-bg);
|
||||
--bs-list-group-action-active-color: var(--bs-body-color);
|
||||
--bs-list-group-action-active-bg: var(--bs-secondary-bg);
|
||||
--bs-list-group-disabled-color: var(--bs-secondary-color);
|
||||
--bs-list-group-disabled-bg: var(--bs-body-bg);
|
||||
--bs-list-group-active-color: #fff;
|
||||
--bs-list-group-active-bg: #0d6efd;
|
||||
--bs-list-group-active-border-color: #0d6efd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
border-radius: var(--bs-list-group-border-radius);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.alert {
|
||||
white-space: nowrap; /* Re-enable nowrap for larger screens */
|
||||
text-overflow: ellipsis; /* Add ellipsis for overflowed text */
|
||||
}
|
||||
}
|
||||
#sidebar.collapsed .btn-danger {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
@ -336,23 +454,24 @@
|
||||
<div class="content">
|
||||
<h4 class="text-center mt-3">Connections</h4>
|
||||
<ul id="connection-list" class="list-group mb-3"></ul>
|
||||
<form id="add-connection-form" class="px-3">
|
||||
<input type="text" id="new-connection-topic" class="form-control mb-2" placeholder="Enter server topic"
|
||||
required>
|
||||
<button type="submit" class="btn btn-primary w-100">Add Connection</button>
|
||||
<form id="add-connection-form" class="px-3 d-flex align-items-center">
|
||||
<input type="text" id="new-connection-topic" class="form-control me-2" placeholder="Enter server topic" required>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-plug"></i> Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="content">
|
||||
<div id="welcome-page">
|
||||
<h1>Welcome to Peartainer</h1>
|
||||
<h1>Welcome to peardock</h1>
|
||||
<p class="mt-3">Easily manage your Docker containers across peer-to-peer connections.</p>
|
||||
<p>To get started, add a connection using the form in the sidebar.</p>
|
||||
<!-- <img src="https://via.placeholder.com/500x300" alt="Welcome Graphic" class="img-fluid mt-4"> -->
|
||||
</div>
|
||||
<div id="dashboard" class="hidden">
|
||||
<h2>Containers</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped">
|
||||
<thead>
|
||||
@ -463,10 +582,111 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker Terminal Modal -->
|
||||
<div class="modal fade" id="dockerTerminalModal" tabindex="-1" aria-labelledby="docker-terminal-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 id="docker-terminal-title" class="modal-title">Docker CLI Terminal</h5>
|
||||
<button id="docker-kill-terminal-btn" type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="docker-terminal-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="logsModal" tabindex="-1" aria-labelledby="logsModalLabel" 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="logsModalLabel">Container Logs</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="logs-container" style="max-height: 70vh; overflow-y: auto; font-family: monospace; background: black; padding: 10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</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="form-group mb-3">
|
||||
<label for="deploy-container-name">Container Name</label>
|
||||
<input type="text" id="deploy-container-name" class="form-control" placeholder="Enter container name">
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
||||
<!-- xterm.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
|
||||
|
||||
|
212
libs/dockerTerminal.js
Normal file
212
libs/dockerTerminal.js
Normal file
@ -0,0 +1,212 @@
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
|
||||
// DOM Elements
|
||||
const dockerTerminalModal = document.getElementById('docker-terminal-modal');
|
||||
const dockerTerminalTitle = document.getElementById('docker-terminal-title');
|
||||
const dockerTerminalContainer = document.getElementById('docker-terminal-container');
|
||||
const dockerKillTerminalBtn = document.getElementById('docker-kill-terminal-btn');
|
||||
|
||||
// Terminal variables
|
||||
let dockerTerminalSession = null;
|
||||
|
||||
/**
|
||||
* Initialize and start the Docker CLI terminal.
|
||||
* @param {string} connectionId - Unique ID for the connection.
|
||||
* @param {Object} peer - Active peer object for communication.
|
||||
*/
|
||||
function startDockerTerminal(connectionId, peer) {
|
||||
if (!peer) {
|
||||
console.error('[ERROR] No active peer for Docker CLI terminal.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dockerTerminalSession) {
|
||||
console.log('[INFO] Docker CLI terminal session already exists.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify DOM elements
|
||||
const dockerTerminalContainer = document.getElementById('docker-terminal-container');
|
||||
const dockerTerminalTitle = document.getElementById('docker-terminal-title');
|
||||
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
|
||||
const dockerKillTerminalBtn = document.getElementById('docker-kill-terminal-btn');
|
||||
|
||||
if (!dockerTerminalContainer || !dockerTerminalTitle || !dockerTerminalModal || !dockerKillTerminalBtn) {
|
||||
console.error('[ERROR] Missing required DOM elements for Docker CLI terminal.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize the xterm.js terminal
|
||||
const xterm = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: { background: '#000000', foreground: '#ffffff' },
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
xterm.loadAddon(fitAddon);
|
||||
|
||||
// Prepare the terminal container
|
||||
dockerTerminalContainer.innerHTML = ''; // Clear previous content
|
||||
xterm.open(dockerTerminalContainer);
|
||||
fitAddon.fit();
|
||||
|
||||
dockerTerminalSession = { xterm, fitAddon, connectionId, peer };
|
||||
|
||||
// Buffer to accumulate user input
|
||||
let inputBuffer = '';
|
||||
|
||||
// Handle terminal input
|
||||
xterm.onData((input) => {
|
||||
if (input === '\r') {
|
||||
// User pressed Enter
|
||||
const fullCommand = prependDockerCommand(inputBuffer.trim());
|
||||
if (fullCommand) {
|
||||
console.log(`[DEBUG] Sending Docker CLI command: ${fullCommand}`);
|
||||
peer.write(
|
||||
JSON.stringify({
|
||||
command: 'dockerCommand',
|
||||
connectionId,
|
||||
data: fullCommand,
|
||||
})
|
||||
);
|
||||
xterm.write('\r\n'); // Move to the next line
|
||||
} else {
|
||||
xterm.write('\r\n[ERROR] Invalid command. Please check your input.\r\n');
|
||||
}
|
||||
inputBuffer = ''; // Clear the buffer after processing
|
||||
} else if (input === '\u007F') {
|
||||
// Handle backspace
|
||||
if (inputBuffer.length > 0) {
|
||||
inputBuffer = inputBuffer.slice(0, -1); // Remove last character
|
||||
xterm.write('\b \b'); // Erase character from display
|
||||
}
|
||||
} else {
|
||||
// Append input to buffer and display it
|
||||
inputBuffer += input;
|
||||
xterm.write(input);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle peer data
|
||||
peer.on('data', (data) => {
|
||||
console.log('[DEBUG] Received data event');
|
||||
try {
|
||||
const response = JSON.parse(data.toString());
|
||||
if (response.connectionId === connectionId) {
|
||||
const decodedData = decodeResponseData(response.data, response.encoding);
|
||||
|
||||
if (response.type === 'dockerOutput') {
|
||||
xterm.write(`${decodedData.trim()}\r\n`);
|
||||
} else if (response.type === 'terminalErrorOutput') {
|
||||
xterm.write(`\r\n[ERROR] ${decodedData.trim()}\r\n`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Failed to parse response from peer: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the terminal modal and title
|
||||
dockerTerminalTitle.textContent = `Docker CLI Terminal: ${connectionId}`;
|
||||
const modalInstance = new bootstrap.Modal(dockerTerminalModal);
|
||||
modalInstance.show();
|
||||
|
||||
// Attach event listener for Kill Terminal button
|
||||
dockerKillTerminalBtn.onclick = () => {
|
||||
cleanUpDockerTerminal();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend 'docker' to the command if it's missing.
|
||||
* @param {string} command - Command string entered by the user.
|
||||
* @returns {string|null} - Full Docker command or null if invalid.
|
||||
*/
|
||||
function prependDockerCommand(command) {
|
||||
// Disallow dangerous operators
|
||||
if (/[\|;&`]/.test(command)) {
|
||||
console.warn('[WARN] Invalid characters detected in command.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prepend 'docker' if not already present
|
||||
if (!command.startsWith('docker ')) {
|
||||
return `docker ${command}`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode response data from Base64 or return as-is if not encoded.
|
||||
* @param {string} data - Response data from the server.
|
||||
* @param {string} encoding - Encoding type (e.g., 'base64').
|
||||
* @returns {string} - Decoded or plain data.
|
||||
*/
|
||||
function decodeResponseData(data, encoding) {
|
||||
if (encoding === 'base64') {
|
||||
try {
|
||||
return atob(data); // Decode Base64 data
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Failed to decode Base64 data: ${error.message}`);
|
||||
return '[ERROR] Command failed.';
|
||||
}
|
||||
}
|
||||
return data; // Return plain data if not encoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the Docker CLI terminal session.
|
||||
*/
|
||||
function cleanUpDockerTerminal() {
|
||||
console.log('[INFO] Cleaning up Docker Terminal...');
|
||||
|
||||
// Retrieve the required DOM elements
|
||||
const dockerTerminalContainer = document.getElementById('docker-terminal-container');
|
||||
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
|
||||
|
||||
if (!dockerTerminalContainer || !dockerTerminalModal) {
|
||||
console.error('[ERROR] Required DOM elements not found for cleaning up the Docker Terminal.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispose of the terminal session if it exists
|
||||
if (dockerTerminalSession) {
|
||||
if (dockerTerminalSession.xterm) {
|
||||
dockerTerminalSession.xterm.dispose();
|
||||
}
|
||||
dockerTerminalSession = null; // Reset the session object
|
||||
}
|
||||
|
||||
// Clear the terminal content
|
||||
dockerTerminalContainer.innerHTML = '';
|
||||
|
||||
// Use Bootstrap API to hide the modal
|
||||
const modalInstance = bootstrap.Modal.getInstance(dockerTerminalModal);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
} else {
|
||||
console.warn('[WARNING] Modal instance not found. Falling back to manual close.');
|
||||
dockerTerminalModal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Ensure lingering backdrops are removed
|
||||
const backdrop = document.querySelector('.modal-backdrop');
|
||||
if (backdrop) {
|
||||
backdrop.remove();
|
||||
}
|
||||
|
||||
// Restore the body's scroll behavior
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
|
||||
console.log('[INFO] Docker CLI terminal session cleanup completed.');
|
||||
}
|
||||
|
||||
|
||||
// Attach event listener for Kill Terminal button (redundant safety check)
|
||||
dockerKillTerminalBtn.onclick = () => {
|
||||
cleanUpDockerTerminal();
|
||||
};
|
||||
|
||||
// Export functions
|
||||
export { startDockerTerminal, cleanUpDockerTerminal, dockerTerminalSession };
|
246
libs/templateDeploy.js
Normal file
246
libs/templateDeploy.js
Normal file
@ -0,0 +1,246 @@
|
||||
// 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');
|
||||
let templates = [];
|
||||
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
|
||||
});
|
||||
}
|
||||
// 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/Lissy93/portainer-templates/main/templates.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
templates = data.templates || []; // Update global templates
|
||||
displayTemplateList(templates);
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to fetch templates:', error.message);
|
||||
showAlert('danger', 'Failed to load templates.');
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// Set the modal title
|
||||
const deployTitle = document.getElementById('deploy-title');
|
||||
deployTitle.textContent = `Deploy ${template.title}`;
|
||||
|
||||
// Populate the image name
|
||||
const deployImage = document.getElementById('deploy-image');
|
||||
deployImage.value = template.image || '';
|
||||
|
||||
// Populate ports
|
||||
const deployPorts = document.getElementById('deploy-ports');
|
||||
deployPorts.value = (template.ports || []).join(', ');
|
||||
|
||||
// Populate volumes
|
||||
const deployVolumes = document.getElementById('deploy-volumes');
|
||||
deployVolumes.value = (template.volumes || [])
|
||||
.map(volume => `${volume.bind}:${volume.container}`)
|
||||
.join(', ');
|
||||
|
||||
// Add environment variables
|
||||
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);
|
||||
});
|
||||
|
||||
// Add Container Name field
|
||||
const containerNameField = document.getElementById('deploy-container-name');
|
||||
containerNameField.value = ''; // Clear previous value, if any
|
||||
|
||||
// Show the modal
|
||||
templateDeployModal.show();
|
||||
}
|
||||
|
||||
// Deploy Docker container
|
||||
async function deployDockerContainer(payload) {
|
||||
const { containerName, 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...');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
window.handlePeerResponse = (response) => {
|
||||
if (response.success && response.message.includes('deployed successfully')) {
|
||||
console.log('[INFO] Deployment response received:', response.message);
|
||||
resolve(response); // Resolve with success
|
||||
} else if (response.error) {
|
||||
reject(new Error(response.error)); // Reject on error
|
||||
}
|
||||
};
|
||||
|
||||
// Send the deployment command
|
||||
sendCommand('deployContainer', {
|
||||
containerName,
|
||||
image: imageName,
|
||||
ports: validPorts,
|
||||
volumes: validVolumes,
|
||||
env: envVars.map(({ name, value }) => ({ name, value })),
|
||||
});
|
||||
|
||||
// Fallback timeout to avoid hanging indefinitely
|
||||
setTimeout(() => {
|
||||
reject(new Error('Deployment timed out. No response from server.'));
|
||||
}, 30000); // Adjust timeout duration as needed
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Handle form submission for deployment
|
||||
deployForm.addEventListener('submit', async (e) => {
|
||||
closeAllModals();
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const containerName = document.getElementById('deploy-container-name').value.trim();
|
||||
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 = { containerName, imageName, ports, volumes, envVars };
|
||||
|
||||
console.log('[DEBUG] Deploy payload:', deployPayload);
|
||||
|
||||
try {
|
||||
showStatusIndicator('Deploying container...');
|
||||
// Wait for deployment to complete
|
||||
const successResponse = await deployDockerContainer(deployPayload);
|
||||
hideStatusIndicator();
|
||||
// showAlert('success', successResponse.message);
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to deploy container:', error.message);
|
||||
hideStatusIndicator();
|
||||
closeAllModals();
|
||||
showAlert('danger', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize templates on load
|
||||
document.addEventListener('DOMContentLoaded', fetchTemplates);
|
||||
|
||||
// Export required functions
|
||||
export { fetchTemplates, displayTemplateList, openDeployModal };
|
21
package.json
21
package.json
@ -1,22 +1,20 @@
|
||||
{
|
||||
"name": "peartainer",
|
||||
"name": "peardock",
|
||||
"main": "index.html",
|
||||
"pear": {
|
||||
"name": "peartainer",
|
||||
"name": "peardock",
|
||||
"type": "desktop",
|
||||
"gui": {
|
||||
"backgroundColor": "#1F2430",
|
||||
"height": "400",
|
||||
"width": "950"
|
||||
},
|
||||
"links": [
|
||||
"http://127.0.0.1",
|
||||
"http://localhost",
|
||||
"https://ka-f.fontawesome.com",
|
||||
"https://cdn.jsdelivr.net",
|
||||
"https://cdnjs.cloudflare.com",
|
||||
"ws://localhost:8080"
|
||||
]
|
||||
"links": [
|
||||
"http://*",
|
||||
"https://*",
|
||||
"ws://*",
|
||||
"wss://*"
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
@ -29,10 +27,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"
|
||||
}
|
||||
|
BIN
screenshots/screenshot-0.png
Normal file
BIN
screenshots/screenshot-0.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 76 KiB |
BIN
screenshots/screenshot-1.png
Normal file
BIN
screenshots/screenshot-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 120 KiB |
BIN
screenshots/screenshot-2.png
Normal file
BIN
screenshots/screenshot-2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
BIN
screenshots/screenshot-3.png
Normal file
BIN
screenshots/screenshot-3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 158 KiB |
BIN
screenshots/screenshot-4.png
Normal file
BIN
screenshots/screenshot-4.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 161 KiB |
BIN
screenshots/screenshot-5.png
Normal file
BIN
screenshots/screenshot-5.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 148 KiB |
@ -1 +0,0 @@
|
||||
SERVER_KEY=1288060fad7d4f88928a704fa03e48efc04c1ebb4614093ea720b934f184361b
|
387
server/server.js
387
server/server.js
@ -7,6 +7,7 @@ import { PassThrough } from 'stream';
|
||||
import os from "os";
|
||||
import fs from 'fs';
|
||||
import dotenv from 'dotenv';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
@ -52,7 +53,9 @@ swarm.on('connection', (peer) => {
|
||||
peer.on('data', async (data) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(data.toString());
|
||||
console.log(`[DEBUG] Received data from peer: ${JSON.stringify(parsedData)}`);
|
||||
if (!(parsedData.command === 'stats' && Object.keys(parsedData.args).length === 0)) {
|
||||
console.log(`[DEBUG] Received data from peer: ${JSON.stringify(parsedData)}`);
|
||||
}
|
||||
let response;
|
||||
|
||||
switch (parsedData.command) {
|
||||
@ -96,6 +99,99 @@ swarm.on('connection', (peer) => {
|
||||
const config = await container.inspect();
|
||||
response = { type: 'containerConfig', data: config };
|
||||
break;
|
||||
case 'dockerCommand':
|
||||
console.log(`[INFO] Handling 'dockerCommand' with data: ${parsedData.data}`);
|
||||
|
||||
try {
|
||||
const command = parsedData.data.split(' '); // Split the command into executable and args
|
||||
const executable = command[0];
|
||||
const args = command.slice(1);
|
||||
|
||||
const childProcess = spawn(executable, args);
|
||||
|
||||
let response = {
|
||||
type: 'dockerOutput',
|
||||
connectionId: parsedData.connectionId,
|
||||
data: '',
|
||||
};
|
||||
|
||||
// Stream stdout to the peer
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
console.log(`[DEBUG] Command stdout: ${data.toString()}`);
|
||||
peer.write(
|
||||
JSON.stringify({
|
||||
...response,
|
||||
data: data.toString('base64'),
|
||||
encoding: 'base64',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Stream stderr to the peer
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
console.error(`[ERROR] Command stderr: ${data.toString()}`);
|
||||
peer.write(
|
||||
JSON.stringify({
|
||||
...response,
|
||||
data: `[ERROR] ${data.toString('base64')}`,
|
||||
encoding: 'base64',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle command exit
|
||||
childProcess.on('close', (code) => {
|
||||
const exitMessage = `[INFO] Command exited with code ${code}`;
|
||||
console.log(exitMessage);
|
||||
peer.write(
|
||||
JSON.stringify({
|
||||
...response,
|
||||
data: exitMessage,
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Command execution failed: ${error.message}`);
|
||||
peer.write(
|
||||
JSON.stringify({
|
||||
type: 'dockerOutput',
|
||||
connectionId: parsedData.connectionId,
|
||||
data: `[ERROR] Failed to execute command: ${error.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');
|
||||
@ -105,13 +201,14 @@ swarm.on('connection', (peer) => {
|
||||
|
||||
await duplicateContainer(name, image, hostname, netmode, cpu, memoryInMB, dupConfig, peer);
|
||||
return; // Response is handled within the duplicateContainer function
|
||||
|
||||
case 'startContainer':
|
||||
console.log(`[INFO] Handling 'startContainer' command for container: ${parsedData.args.id}`);
|
||||
await docker.getContainer(parsedData.args.id).start();
|
||||
response = { success: true, message: `Container ${parsedData.args.id} started` };
|
||||
break;
|
||||
|
||||
// case 'allStats':
|
||||
// await handleallStatsRequest(peer);
|
||||
// return; // No further response needed
|
||||
case 'stopContainer':
|
||||
console.log(`[INFO] Handling 'stopContainer' command for container: ${parsedData.args.id}`);
|
||||
await docker.getContainer(parsedData.args.id).stop();
|
||||
@ -130,6 +227,120 @@ swarm.on('connection', (peer) => {
|
||||
response = { success: true, message: `Container ${parsedData.args.id} removed` };
|
||||
break;
|
||||
|
||||
case 'deployContainer':
|
||||
console.log('[INFO] Handling "deployContainer" command');
|
||||
const { containerName, image: imageToDeploy, ports = [], volumes = [], env = [] } = parsedData.args;
|
||||
|
||||
try {
|
||||
// Validate and sanitize container name
|
||||
if (!containerName || typeof containerName !== 'string') {
|
||||
throw new Error('Invalid or missing container name.');
|
||||
}
|
||||
|
||||
// Ensure the name is alphanumeric with optional dashes/underscores
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(containerName)) {
|
||||
throw new Error('Container name must be alphanumeric and may include dashes or underscores.');
|
||||
}
|
||||
|
||||
// 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 with a custom name
|
||||
console.log('[INFO] Creating the container...');
|
||||
const container = await docker.createContainer({
|
||||
name: containerName, // Include the container name
|
||||
Image: imageToDeploy,
|
||||
Env: validEnv,
|
||||
HostConfig: hostConfig,
|
||||
});
|
||||
|
||||
console.log('[INFO] Starting the container...');
|
||||
await container.start();
|
||||
|
||||
console.log(`[INFO] Container "${containerName}" deployed successfully from image "${imageToDeploy}"`);
|
||||
|
||||
// Respond with success message
|
||||
peer.write(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: `Container "${containerName}" 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);
|
||||
@ -319,44 +530,6 @@ docker.listContainers({ all: true }, async (err, containers) => {
|
||||
ipAddress = networks[0].IPAddress; // Use the first network's IP
|
||||
}
|
||||
}
|
||||
|
||||
// Start streaming container stats
|
||||
container.stats({ stream: true }, (statsErr, stream) => {
|
||||
if (statsErr) {
|
||||
console.error(`[ERROR] Failed to get stats for container ${containerInfo.Id}: ${statsErr.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('data', (data) => {
|
||||
try {
|
||||
const stats = JSON.parse(data.toString());
|
||||
const cpuUsage = calculateCPUPercent(stats);
|
||||
const memoryUsage = stats.memory_stats.usage || 0; // Default to 0 if undefined
|
||||
|
||||
const statsData = {
|
||||
id: containerInfo.Id,
|
||||
cpu: cpuUsage,
|
||||
memory: memoryUsage,
|
||||
ip: ipAddress, // Use the pre-inspected IP address
|
||||
};
|
||||
|
||||
// Broadcast stats to all connected peers
|
||||
for (const peer of connectedPeers) {
|
||||
peer.write(JSON.stringify({ type: 'stats', data: statsData }));
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.error(`[ERROR] Failed to parse stats for container ${containerInfo.Id}: ${parseErr.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
console.error(`[ERROR] Stats stream error for container ${containerInfo.Id}: ${streamErr.message}`);
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
console.log(`[INFO] Stats stream closed for container ${containerInfo.Id}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -463,63 +636,95 @@ function handleKillTerminal(containerId, peer) {
|
||||
}
|
||||
}
|
||||
|
||||
function streamContainerStats(containerInfo) {
|
||||
async function collectContainerStats(containerStats) {
|
||||
const currentContainers = await docker.listContainers({ all: true });
|
||||
const currentIds = currentContainers.map((c) => c.Id);
|
||||
|
||||
// Collect stats for all containers, including newly added ones
|
||||
for (const containerInfo of currentContainers) {
|
||||
if (!containerStats[containerInfo.Id]) {
|
||||
console.log(`[INFO] Found new container: ${containerInfo.Names[0]?.replace(/^\//, '')}`);
|
||||
containerStats[containerInfo.Id] = await initializeContainerStats(containerInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove containers that no longer exist
|
||||
Object.keys(containerStats).forEach((id) => {
|
||||
if (!currentIds.includes(id)) {
|
||||
console.log(`[INFO] Removing stats tracking for container: ${id}`);
|
||||
delete containerStats[id];
|
||||
}
|
||||
});
|
||||
|
||||
return containerStats;
|
||||
}
|
||||
|
||||
async function initializeContainerStats(containerInfo) {
|
||||
const container = docker.getContainer(containerInfo.Id);
|
||||
|
||||
// Use the same logic as listContainers to get the IP address
|
||||
container.inspect((inspectErr, details) => {
|
||||
let ipAddress = 'No IP Assigned'; // Default IP address fallback
|
||||
// Inspect container for IP address
|
||||
let ipAddress = 'No IP Assigned';
|
||||
try {
|
||||
const details = await container.inspect();
|
||||
const networks = details.NetworkSettings?.Networks || {};
|
||||
ipAddress = Object.values(networks)[0]?.IPAddress || 'No IP Assigned';
|
||||
} catch (err) {
|
||||
console.error(`[ERROR] Failed to inspect container ${containerInfo.Id}: ${err.message}`);
|
||||
}
|
||||
|
||||
if (inspectErr) {
|
||||
console.error(`[ERROR] Failed to inspect container ${containerInfo.Id}: ${inspectErr.message}`);
|
||||
} else if (details.NetworkSettings && details.NetworkSettings.Networks) {
|
||||
const networks = Object.values(details.NetworkSettings.Networks);
|
||||
if (networks.length > 0 && networks[0].IPAddress) {
|
||||
ipAddress = networks[0].IPAddress; // Retrieve the first network's IP address
|
||||
const statsData = {
|
||||
id: containerInfo.Id,
|
||||
name: containerInfo.Names[0]?.replace(/^\//, '') || 'Unknown',
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
ip: ipAddress,
|
||||
};
|
||||
|
||||
// Start streaming stats for the container
|
||||
try {
|
||||
const statsStream = await container.stats({ stream: true });
|
||||
statsStream.on('data', (data) => {
|
||||
try {
|
||||
const stats = JSON.parse(data.toString());
|
||||
statsData.cpu = calculateCPUPercent(stats);
|
||||
statsData.memory = stats.memory_stats.usage || 0;
|
||||
} catch (err) {
|
||||
console.error(`[ERROR] Failed to parse stats for container ${containerInfo.Id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start streaming container stats
|
||||
container.stats({ stream: true }, (statsErr, stream) => {
|
||||
if (statsErr) {
|
||||
console.error(`[ERROR] Failed to get stats for container ${containerInfo.Id}: ${statsErr.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('data', (data) => {
|
||||
try {
|
||||
const stats = JSON.parse(data.toString());
|
||||
const cpuUsage = calculateCPUPercent(stats);
|
||||
const memoryUsage = stats.memory_stats.usage || 0; // Default to 0 if undefined
|
||||
|
||||
// Use the extracted IP address in the stats data
|
||||
const statsData = {
|
||||
id: containerInfo.Id,
|
||||
cpu: cpuUsage,
|
||||
memory: memoryUsage,
|
||||
ip: ipAddress, // Use the IP address retrieved during inspection
|
||||
};
|
||||
|
||||
// Broadcast stats to all connected peers
|
||||
for (const peer of connectedPeers) {
|
||||
peer.write(JSON.stringify({ type: 'stats', data: statsData }));
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.error(`[ERROR] Failed to parse stats for container ${containerInfo.Id}: ${parseErr.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
console.error(`[ERROR] Stats stream error for container ${containerInfo.Id}: ${streamErr.message}`);
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
console.log(`[INFO] Stats stream closed for container ${containerInfo.Id}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
statsStream.on('error', (err) => {
|
||||
console.error(`[ERROR] Stats stream error for container ${containerInfo.Id}: ${err.message}`);
|
||||
});
|
||||
|
||||
statsStream.on('close', () => {
|
||||
console.log(`[INFO] Stats stream closed for container ${containerInfo.Id}`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[ERROR] Failed to start stats stream for container ${containerInfo.Id}: ${err.message}`);
|
||||
}
|
||||
|
||||
return statsData;
|
||||
}
|
||||
|
||||
async function handleStatsBroadcast() {
|
||||
const containerStats = {};
|
||||
|
||||
// Periodically update stats and broadcast
|
||||
setInterval(async () => {
|
||||
await collectContainerStats(containerStats);
|
||||
const aggregatedStats = Object.values(containerStats);
|
||||
const response = { type: 'allStats', data: aggregatedStats };
|
||||
|
||||
for (const peer of connectedPeers) {
|
||||
peer.write(JSON.stringify(response));
|
||||
}
|
||||
}, 1000); // Send stats every 500ms
|
||||
}
|
||||
|
||||
// Start the stats broadcast
|
||||
handleStatsBroadcast();
|
||||
|
||||
|
||||
|
||||
// Handle process termination
|
||||
|
Reference in New Issue
Block a user