Compare commits

...

67 Commits
main ... main

Author SHA1 Message Date
Raven Scott
0cf82495e9 update ss 2024-12-03 01:00:56 -05:00
Raven Scott
0bfb29c6ff update name 2024-12-03 00:49:32 -05:00
Raven Scott
faa225c077 update readme 2024-12-02 20:52:12 -05:00
Raven Scott
ee9f65fef7 update 2024-12-02 20:39:17 -05:00
Raven Scott
5e96160b7c update readme 2024-12-02 20:37:39 -05:00
Raven Scott
526ef0ca84 add screenshots 2024-12-02 20:33:15 -05:00
Raven Scott
35010022bc update 2024-12-02 06:17:23 -05:00
Raven Scott
73bec336e3 update 2024-12-02 06:05:38 -05:00
Raven Scott
605e30c368 update 2024-12-02 05:58:22 -05:00
Raven Scott
3457eb0e46 fix up status indicator on deploy 2024-12-02 05:18:16 -05:00
Raven Scott
5e8c085446 streamline 2024-12-02 05:05:43 -05:00
Raven Scott
55d502c5f4 streamline stats 2024-12-02 05:05:42 -05:00
Raven Scott
58335ead6d fix 2024-12-02 04:00:48 -05:00
Raven Scott
40db213f74 update 2024-12-02 03:56:34 -05:00
Raven Scott
72e164fe34 better 2024-12-02 03:51:17 -05:00
Raven Scott
ffb51d0d72 update 2024-12-02 03:30:55 -05:00
Raven Scott
71992f004c more progress 2024-12-02 03:29:59 -05:00
Raven Scott
2839fd7a7d first attempt at tempate deployments 2024-12-02 02:16:00 -05:00
Raven Scott
77122a58b7 add container log view 2024-12-02 00:02:36 -05:00
Raven Scott
3e37359e61 add docker cli access per connection 2024-12-01 23:48:26 -05:00
Raven Scott
9389d41364 test 2024-12-01 22:17:38 -05:00
Raven Scott
b370169aaf test 2024-12-01 22:16:05 -05:00
Raven Scott
c38f23d50e test again 2024-12-01 22:14:26 -05:00
Raven Scott
73adabe2c4 test 2024-12-01 22:01:07 -05:00
Raven Scott
57209a8159 style scrollbars 2024-11-30 15:43:02 -05:00
a3c5f18736 revert ad67461cf5
revert Add Name Modal On Add
2024-11-30 07:12:27 -05:00
Raven Scott
ad67461cf5 Add Name Modal On Add 2024-11-30 06:53:07 -05:00
Raven Scott
f995c44057 remove rdp it was stupid 2024-11-30 05:04:44 -05:00
Raven Scott
e4aeb9d53f optimize screen 2024-11-30 05:03:52 -05:00
Raven Scott
f3695c274b stop stat spam 2024-11-30 04:09:21 -05:00
Raven Scott
925ae73cad update server 2024-11-30 04:06:28 -05:00
40c1d7a5fb remove dumb .env file 2024-11-30 04:04:14 -05:00
Raven Scott
746dba4394 remove stupid console log and fix removal of server from cookie 2024-11-30 03:57:22 -05:00
Raven Scott
8699a265ae actually fix 2024-11-30 03:46:49 -05:00
Raven Scott
d9549454e1 fix 2024-11-30 03:26:40 -05:00
Raven Scott
3177976845 update 2024-11-30 03:17:21 -05:00
Raven Scott
b8c6f30d32 better stat tracking 2024-11-30 03:08:13 -05:00
Raven Scott
1dc39decf7 fix 2024-11-30 02:52:38 -05:00
Raven Scott
0e94f047c0 handle multiple topics correctly 2024-11-30 02:42:07 -05:00
Raven Scott
c078751561 small fix 2024-11-30 02:26:30 -05:00
Raven Scott
730f356e14 Save connection settings as cookies, add a reset connection button to sidebar 2024-11-30 00:40:10 -05:00
Raven Scott
7728e5e5b7 fix 2024-11-30 00:12:00 -05:00
Raven Scott
0a31b03601 better table 2024-11-29 23:37:39 -05:00
Raven Scott
830d72608b update 2024-11-29 23:33:04 -05:00
Raven Scott
4f13281873 Add restart to actions 2024-11-29 23:30:18 -05:00
Raven Scott
964c6c60f3 fix 2024-11-29 22:46:32 -05:00
Raven Scott
93c40af76a Add welcome page if no connections 2024-11-29 22:40:26 -05:00
Raven Scott
e33023e250 add indicators for tasks 2024-11-29 21:57:53 -05:00
Raven Scott
ebe25cbe1c If a container is deleted, auto close and delete its terminal session and modal 2024-11-29 20:29:45 -05:00
Raven Scott
37aa448011 Feat: auto collapse when connection is made 2024-11-29 20:27:44 -05:00
Raven Scott
956b7ee056 edit: add modal asking user if they really want to delete a container 2024-11-29 20:17:53 -05:00
Raven Scott
b2302f8067 fix duplicate modal 2024-11-29 20:12:02 -05:00
Raven Scott
55eefce740 FA Button 2024-11-29 20:06:46 -05:00
Raven Scott
b0a1446ce0 make the terminal resize by drag 2024-11-29 20:00:29 -05:00
Raven Scott
4f31d0657c Always show IP Address in list 2024-11-29 19:10:48 -05:00
Raven Scott
d54328c001 update 2024-11-29 18:27:37 -05:00
Raven Scott
7c19fe58a6 update 2024-11-29 18:23:27 -05:00
Raven Scott
b9e8c6da2f test network fix 2024-11-29 18:19:26 -05:00
Raven Scott
0e6f464d16 Add readme 2024-11-29 18:13:45 -05:00
Raven Scott
182460f091 update server to save static key to .env 2024-11-29 18:00:45 -05:00
01e5973ebc revert e8ff03d471
revert adding pear server init
2024-11-29 17:50:12 -05:00
fe4fbf3b3c revert 6c4e0593b7
revert move to browserfs
2024-11-29 17:49:59 -05:00
7d36533477 revert 6c4e0593b7
revert move to browserfs
2024-11-29 17:49:43 -05:00
Raven Scott
6c4e0593b7 move to browserfs 2024-11-29 17:46:37 -05:00
Raven Scott
e8ff03d471 adding pear server init 2024-11-29 17:40:49 -05:00
Raven Scott
76825acb15 Add in Font Awesome Icons for Actions 2024-11-29 16:40:37 -05:00
4753207c02 Merge pull request 'fix: running on windows' (#1) from Cyber/peartainer:main into main
Reviewed-on: snxraven/peartainer#1
2024-11-28 12:43:50 -05:00
17 changed files with 2791 additions and 443 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

1
.env Normal file
View File

@ -0,0 +1 @@
SERVER_KEY=0708bb56dd447a1b6951cc5b92522ab42994bd07d616375f4f0776df22b0b629

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules
package-lock.json
server/.env

406
README.md Normal file
View File

@ -0,0 +1,406 @@
# peardock
## Overview
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.
In addition to a development environment, the client app can be run in **production mode** directly via Pear with the following command:
```bash
pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykc9b8cdnjicaqcgoefa4wo
```
---
## Key Features
### Server-Side
- **Persistent Server Key**:
- Generates a `SERVER_KEY` for each instance.
- The key is saved to `.env` for consistent re-use.
- Supports manual key regeneration by deleting `.env`.
- **Real-Time Docker Management**:
- List all containers across peers with statuses.
- 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.
### Client-Side
- **Peer-to-Peer Networking**:
- Connects to servers using unique server keys via Hyperswarm.
- Fully decentralized; no central server is required.
- **Interactive User Interface**:
- 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://7to8bzrk53ab5ufwauqcw57s1kxmuykcgoefa4wo
```
---
## How It Works
### Server Key Architecture
The server is initialized with a `SERVER_KEY` that uniquely identifies the network. This key is essential for peers to connect and interact with the server.
- **Key Generation**:
- On the first run, the server checks for an existing `SERVER_KEY` in the `.env` file. If absent, a new key is generated:
```javascript
function generateNewKey() {
const newKey = crypto.randomBytes(32);
fs.appendFileSync('.env', `SERVER_KEY=${newKey.toString('hex')}\n`, { flag: 'a' });
return newKey;
}
```
- The key is saved to `.env` for persistence.
- **Key Usage**:
- The server uses the key to generate a topic buffer for Hyperswarm:
```javascript
const topic = Buffer.from(keyHex, 'hex');
swarm.join(topic, { server: true, client: false });
```
- **Key Refresh**:
- To regenerate the key, delete the `.env` file and restart the server.
### Peer Connections
Peers connect to the server using the unique topic derived from the `SERVER_KEY`. The Hyperswarm network ensures secure, low-latency connections.
- **Connecting**:
- Each client app connects to the server by joining the topic buffer:
```javascript
const topicBuffer = b4a.from(topicHex, 'hex');
swarm.join(topicBuffer, { client: true, server: true });
```
- **Communication**:
- Commands (e.g., `listContainers`, `startContainer`) are sent as JSON over the connection.
- Responses and real-time updates are broadcast back to peers.
### Docker Integration
The server interacts with Docker using **Dockerode**:
- **List Containers**:
```javascript
const containers = await docker.listContainers({ all: true });
```
- **Start a Container**:
```javascript
await docker.getContainer(containerId).start();
```
- **Stream Statistics**:
```javascript
container.stats({ stream: true }, (err, stream) => {
stream.on('data', (data) => {
const stats = JSON.parse(data.toString());
broadcastToPeers({ type: 'stats', data: stats });
});
});
```
- **Docker CLI Commands**:
- Execute Docker commands received from the client within controlled parameters to ensure security.
---
## Installation
### Prerequisites
1. **Docker**:
- Install Docker and ensure it is running.
- For Linux, add your user to the Docker group:
```bash
sudo usermod -aG docker $USER
```
Log out and back in for changes to take effect.
2. **Node.js**:
- Install Node.js v16 or higher:
```bash
sudo apt install nodejs npm
```
3. **Pear**:
- Install the Pear runtime for running the client and server:
```bash
npm install -g pear
```
---
### Server Setup
1. **Clone the Repository**:
```bash
git clone https://git.ssh.surf/snxraven/peardock.git
cd peardock
```
2. **Change to Server Directory**:
```bash
cd server
```
3. **Install Dependencies**:
```bash
npm install hyperswarm dockerode hypercore-crypto stream dotenv
```
4. **Run the Server**:
```bash
node server.js
```
---
### Client Setup
1. **For Development**, run:
```bash
pear run --dev .
```
2. **For Production**, use the pre-deployed Pear app:
```bash
pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykc9b8cdnjicaqcgoefa4wo
```
---
## Usage
### Connecting to a Server
1. Launch the client app.
2. Enter the server's `SERVER_KEY` in the connection form to join its topic.
### Managing Containers
- **Listing Containers**:
- View all containers (running and stopped) with their statuses.
- **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
- **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
![Welcome Screen](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-0.png)
*The initial welcome screen guiding users to add a connection.*
---
### Container List
![Container List](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-1.png)
*Displaying all Docker containers with real-time stats and action buttons.*
---
### Template Deployments
![Template Deployments](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-2.png)
*Browsing and selecting templates for deployment from a remote repository.*
---
### Final Deploy Modal
![Final Deploy Modal](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-3.png)
*Customizing deployment parameters before launching a new container.*
---
### Duplicate Container Form
![Duplicate Container Form](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-4.png)
*Duplicating an existing container with options to modify configurations.*
---
### Container Logs
![Container Logs](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-5.png)
*Viewing real-time logs of a container directly from the UI.*
---
## Customization
### UI Customization
- Modify the layout and styling in `index.html` and the embedded CSS.
### Terminal Behavior
- Adjust terminal settings in `libs/terminal.js`:
```javascript
const xterm = new Terminal({
cursorBlink: true,
theme: { background: '#1a1a1a', foreground: '#ffffff' },
});
```
### Docker Commands
- 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...
}
```
---
## Security
- 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`.
---
## Troubleshooting
### Common Issues
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).

1297
app.js

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -21,6 +22,11 @@
color: white;
overflow: hidden;
}
.hidden {
display: none !important;
}
#titlebar {
-webkit-app-region: drag;
height: 30px;
@ -30,11 +36,13 @@
background-color: #2c2c2c;
z-index: 1000;
}
pear-ctrl[data-platform="darwin"] {
float: left;
margin-top: 5px;
margin-left: 10px;
}
#sidebar {
position: fixed;
top: 30px;
@ -45,12 +53,15 @@
overflow-y: auto;
transition: width 0.3s ease-in-out;
}
#sidebar.collapsed {
width: 50px;
}
#sidebar.collapsed .content {
display: none;
}
#collapse-sidebar-btn {
position: absolute;
top: 10px;
@ -66,16 +77,24 @@
text-align: center;
cursor: pointer;
}
#content {
display: flex;
flex-direction: column;
/* Keep vertical stacking for child elements */
margin-left: 250px;
/* Leave space for the sidebar */
flex: 1;
padding: 30px;
/* Allow the content to grow */
overflow-y: auto;
transition: margin-left 0.3s ease-in-out;
/* Allow scrolling if content overflows */
position: relative;
}
#sidebar.collapsed ~ #content {
#sidebar.collapsed~#content {
margin-left: 50px;
}
.connection-status {
border-radius: 50%;
width: 10px;
@ -83,38 +102,56 @@
display: inline-block;
margin-right: 8px;
}
.status-connected {
background-color: green;
}
.status-disconnected {
background-color: red;
}
#terminal-modal {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
max-height: 400px;
max-height: 90vh;
height: 300px;
background-color: #1a1a1a;
border-top: 2px solid #444;
display: none;
flex-direction: column;
z-index: 1000;
overflow: hidden;
}
#terminal-modal .header {
background-color: #444;
cursor: move;
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
#terminal-resize-handle {
width: 100%;
height: 10px;
cursor: ns-resize;
background-color: #444;
position: absolute;
bottom: 0;
left: 0;
}
#terminal-container {
flex: 1;
overflow: hidden;
background-color: black;
color: white;
}
#tray {
position: fixed;
bottom: 0;
@ -128,6 +165,7 @@
white-space: nowrap;
z-index: 999;
}
#tray .tray-item {
background-color: #555;
color: white;
@ -135,9 +173,277 @@
border-radius: 5px;
cursor: pointer;
}
#status-indicator {
display: none;
/* Ensure it's hidden by default */
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.75);
z-index: 1050;
}
#status-indicator .spinner-border {
width: 3rem;
height: 3rem;
}
#status-indicator p {
margin-top: 1rem;
color: #fff;
font-size: 1.25rem;
}
#welcome-page {
display: flex;
flex-direction: column;
/* Stack child elements vertically */
justify-content: center;
/* Center content vertically */
align-items: center;
/* Center content horizontally */
text-align: center;
/* Center-align text */
position: absolute;
/* Overlay it over the content area */
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* Center it perfectly in the content */
max-width: 800px;
/* Restrict the width of the welcome page */
width: 100%;
/* Allow it to scale */
padding: 20px;
background-color: transparent;
/* Match the theme */
}
#welcome-page.hidden {
display: none !important;
/* Completely hide when not needed */
}
#dashboard {
display: flex;
/* Use flex layout for content within the dashboard */
flex-direction: column;
/* Stack elements vertically */
flex: 1;
/* Ensure it uses all available space */
width: 100%;
/* Take up the full width of the content area */
margin-top: 30px;
/* Remove extra padding */
overflow-y: auto;
/* Allow vertical scrolling if needed */
position: relative;
/* Prevent overlap with other elements */
}
#dashboard.hidden {
display: none !important;
/* Hide the dashboard completely when not needed */
}
#alert-container {
position: fixed;
bottom: 20px;
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 */
}
#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: inline-flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background-color: #2b2b2b;
color: #e0e0e0;
border-radius: 6px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
font-family: Arial, sans-serif;
font-size: 14px;
animation: fadeIn 0.3s ease-out, fadeOut 4.5s ease-in forwards;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: auto;
/* Allow alerts to be interactive */
}
.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
;
}
.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;
}
.text-primary {
--bs-text-opacity: 1;
color: rgb(254 254 254) !important;
}
.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);
}
</style>
</head>
<body>
<div id="titlebar">
<pear-ctrl></pear-ctrl>
@ -148,34 +454,44 @@
<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">
<h1 id="connection-title">Add a Connection</h1>
<div id="welcome-page">
<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>
<table class="table table-dark table-striped">
<thead>
<tr>
<th>Name</th>
<th>Image</th>
<th>Status</th>
<th>CPU (%)</th>
<th>Memory (MB)</th>
<th>IP Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="container-list"></tbody>
</table>
<div class="table-responsive">
<table class="table table-dark table-striped">
<thead>
<tr>
<th>Name</th>
<th>Image</th>
<th>Status</th>
<th>CPU (%)</th>
<th>Memory (MB)</th>
<th>IP Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="container-list"></tbody>
</table>
</div>
</div>
</div>
<!-- Duplicate Container Modal -->
<div class="modal fade" id="duplicateModal" tabindex="-1" aria-labelledby="duplicateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
@ -191,7 +507,7 @@
<input type="text" class="form-control" id="container-name" required>
</div>
<div class="mb-3">
<label for="container-image" class="form-label">Hostname</label>
<label for="container-hostname" class="form-label">Hostname</label>
<input type="text" class="form-control" id="container-hostname" required>
</div>
<div class="mb-3">
@ -199,7 +515,7 @@
<input type="text" class="form-control" id="container-image" required>
</div>
<div class="mb-3">
<label for="container-image" class="form-label">Net Mode</label>
<label for="container-netmode" class="form-label">Net Mode</label>
<input type="text" class="form-control" id="container-netmode" required>
</div>
<div class="mb-3">
@ -210,7 +526,6 @@
<label for="container-memory" class="form-label">Memory (MB)</label>
<input type="number" class="form-control" id="container-memory" required>
</div>
<!-- Container Configuration as JSON -->
<div class="mb-3">
<label for="container-config" class="form-label">Container Configuration (JSON)</label>
<textarea class="form-control" id="container-config" rows="10" required></textarea>
@ -227,22 +542,159 @@
<div class="header">
<span id="terminal-title"></span>
<div>
<button id="kill-terminal-btn" class="btn btn-sm btn-danger">Kill Terminal</button>
<button id="kill-terminal-btn" class="btn btn-sm btn-danger">
<i class="fas fa-times-circle"></i>
</button>
</div>
</div>
<div id="terminal-container"></div>
<div id="terminal-resize-handle"></div>
</div>
<div id="tray"></div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Confirm Deletion</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this container?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="confirm-delete-btn" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
</div>
<!-- Status Indicator Overlay -->
<div id="status-indicator"
class="position-fixed top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center bg-dark bg-opacity-75"
style="display: none; z-index: 1050;">
<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">Processing...</p>
</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>
<!-- Bootstrap JS for Modal Functionality -->
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- Your App JS -->
<script type="module" src="app.js"></script>
</body>
</html>

212
libs/dockerTerminal.js Normal file
View 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
View 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 };

View File

@ -6,11 +6,17 @@ const terminalModal = document.getElementById('terminal-modal');
const terminalTitle = document.getElementById('terminal-title');
const terminalContainer = document.getElementById('terminal-container');
const tray = document.getElementById('tray');
const terminalHeader = document.querySelector('#terminal-modal .header');
// Terminal variables
let terminalSessions = {}; // Track terminal sessions per containerId
let activeContainerId = null; // Currently displayed containerId
// Variables for resizing
let isResizing = false;
let startY = 0;
let startHeight = 0;
// Kill Terminal button functionality
document.getElementById('kill-terminal-btn').onclick = () => {
killActiveTerminal();
@ -39,6 +45,42 @@ function killActiveTerminal() {
}
}
// Start resizing when the mouse is down on the header
terminalHeader.addEventListener('mousedown', (e) => {
isResizing = true;
startY = e.clientY; // Track the initial Y position
startHeight = terminalModal.offsetHeight; // Track the initial height
document.body.style.cursor = 'ns-resize'; // Change cursor to indicate resizing
e.preventDefault(); // Prevent text selection
});
// Resize the modal while dragging
document.addEventListener('mousemove', (e) => {
if (isResizing) {
const deltaY = startY - e.clientY; // Calculate how much the mouse moved
const newHeight = Math.min(
Math.max(startHeight + deltaY, 150), // Minimum height: 150px
window.innerHeight * 0.9 // Maximum height: 90% of viewport height
);
terminalModal.style.height = `${newHeight}px`; // Set new height
terminalContainer.style.height = `${newHeight - 40}px`; // Adjust terminal container height
const activeSession = terminalSessions[activeContainerId];
if (activeSession) {
setTimeout(() => activeSession.fitAddon.fit(), 10); // Adjust terminal content
}
}
});
// Stop resizing when the mouse is released
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = 'default'; // Reset cursor
}
});
// Start terminal session
function startTerminal(containerId, containerName) {
if (!window.activePeer) {

View File

@ -1,22 +1,20 @@
{
"name": "peartainer",
"name": "peardock",
"main": "index.html",
"pear": {
"name": "peartainer",
"name": "peardock",
"type": "desktop",
"gui": {
"backgroundColor": "#1F2430",
"height": "540",
"width": "720"
"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,8 +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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View File

@ -5,16 +5,43 @@ import Docker from 'dockerode';
import crypto from 'hypercore-crypto';
import { PassThrough } from 'stream';
import os from "os";
import fs from 'fs';
import dotenv from 'dotenv';
import { spawn } from 'child_process';
const docker = new Docker({ socketPath: os.platform() === "win32" ? '//./pipe/dockerDesktopLinuxEngine' : '/var/run/docker.sock' });
// Load environment variables from .env file
dotenv.config();
const docker = new Docker({
socketPath: os.platform() === "win32" ? '//./pipe/dockerDesktopLinuxEngine' : '/var/run/docker.sock',
});
const swarm = new Hyperswarm();
const connectedPeers = new Set();
const terminalSessions = new Map(); // Map to track terminal sessions per peer
// Generate a topic for the server
const topic = crypto.randomBytes(32);
// Function to generate a new key
function generateNewKey() {
const newKey = crypto.randomBytes(32);
fs.appendFileSync('.env', `SERVER_KEY=${newKey.toString('hex')}\n`, { flag: 'a' });
return newKey;
}
// Load or generate the topic key
let keyHex = process.env.SERVER_KEY;
if (!keyHex) {
console.log('[INFO] No SERVER_KEY found in .env. Generating a new one...');
const newKey = generateNewKey();
keyHex = newKey.toString('hex');
} else {
console.log('[INFO] SERVER_KEY loaded from .env.');
}
// Convert the keyHex to a Buffer
const topic = Buffer.from(keyHex, 'hex');
console.log(`[INFO] Server started with topic: ${topic.toString('hex')}`);
// Start listening or further implementation logic here
// Join the swarm with the generated topic
swarm.join(topic, { server: true, client: false });
@ -26,14 +53,44 @@ 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) {
case 'listContainers':
console.log('[INFO] Handling \'listContainers\' command');
const containers = await docker.listContainers({ all: true });
response = { type: 'containers', data: containers };
try {
const containers = await docker.listContainers({ all: true });
const detailedContainers = await Promise.all(
containers.map(async (container) => {
try {
const details = await docker.getContainer(container.Id).inspect();
// Safely access the IP address
let ipAddress = 'No IP Assigned';
if (details.NetworkSettings && details.NetworkSettings.Networks) {
const networks = Object.values(details.NetworkSettings.Networks);
if (networks.length > 0 && networks[0].IPAddress) {
ipAddress = networks[0].IPAddress;
}
}
return { ...container, ipAddress }; // Add IP address to container data
} catch (error) {
console.error(`[ERROR] Failed to inspect container ${container.Id}: ${error.message}`);
return { ...container, ipAddress: 'Error Retrieving IP' }; // Return partial data with error
}
})
);
response = { type: 'containers', data: detailedContainers };
} catch (error) {
console.error(`[ERROR] Failed to list containers: ${error.message}`);
response = { error: 'Failed to list containers' };
}
break;
case 'inspectContainer':
@ -42,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');
@ -51,25 +201,146 @@ 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();
response = { success: true, message: `Container ${parsedData.args.id} stopped` };
break;
case 'restartContainer':
console.log(`[INFO] Handling 'restartContainer' command for container: ${parsedData.args.id}`);
await docker.getContainer(parsedData.args.id).restart();
response = { success: true, message: `Container ${parsedData.args.id} restarted` };
break;
case 'removeContainer':
console.log(`[INFO] Handling 'removeContainer' command for container: ${parsedData.args.id}`);
await docker.getContainer(parsedData.args.id).remove({ force: true });
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);
@ -239,44 +510,26 @@ docker.getEvents({}, (err, stream) => {
});
// Collect and stream container stats
docker.listContainers({ all: true }, (err, containers) => {
docker.listContainers({ all: true }, async (err, containers) => {
if (err) {
console.error(`[ERROR] Failed to list containers for stats: ${err.message}`);
return;
}
// Iterate over all containers
containers.forEach((containerInfo) => {
const container = docker.getContainer(containerInfo.Id);
container.stats({ stream: true }, (err, stream) => {
if (err) {
return;
}
stream.on('data', (data) => {
try {
const stats = JSON.parse(data.toString());
const cpuUsage = calculateCPUPercent(stats);
const memoryUsage = stats.memory_stats.usage;
const networks = stats.networks;
const ipAddress = networks ? Object.values(networks)[0].IPAddress : '-';
// Use the same logic as listContainers to pre-inspect and extract the IP address
container.inspect((inspectErr, details) => {
let ipAddress = 'No IP Assigned'; // Default fallback
const statsData = {
id: containerInfo.Id,
cpu: cpuUsage,
memory: memoryUsage,
ip: ipAddress,
};
// Broadcast stats to all connected peers
for (const peer of connectedPeers) {
peer.write(JSON.stringify({ type: 'stats', data: statsData }));
}
} catch (err) {
if (!inspectErr && details.NetworkSettings && details.NetworkSettings.Networks) {
const networks = Object.values(details.NetworkSettings.Networks);
if (networks.length > 0 && networks[0].IPAddress) {
ipAddress = networks[0].IPAddress; // Use the first network's IP
}
});
stream.on('error', (err) => {
});
}
});
});
});
@ -383,45 +636,96 @@ 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);
container.stats({ stream: true }, (err, stream) => {
if (err) {
console.error(`[ERROR] Failed to get stats for container ${containerInfo.Id}: ${err.message}`);
return;
}
// 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}`);
}
stream.on('data', (data) => {
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());
const cpuUsage = calculateCPUPercent(stats);
const memoryUsage = stats.memory_stats.usage;
const networks = stats.networks;
const ipAddress = networks ? Object.values(networks)[0].IPAddress : '-';
const statsData = {
id: containerInfo.Id,
cpu: cpuUsage,
memory: memoryUsage,
ip: ipAddress,
};
// Broadcast stats to all connected peers
for (const peer of connectedPeers) {
peer.write(JSON.stringify({ type: 'stats', data: statsData }));
}
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}`);
}
});
stream.on('error', (err) => {
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
process.on('SIGINT', () => {