Fix TypeScript and ESLint errors (#31)

- Add proper type annotations for WebSocketMessage and ServerInfo types
- Fix type imports to use type-only imports where appropriate
- Replace logical OR operators with nullish coalescing operators
- Fix floating promises by adding void operator
- Add proper type assertions for database results
- Fix useEffect dependencies in Terminal component
- Remove unused variables and fix unescaped entities
- Add JSDoc type annotations for database methods
- Fix singleton instance type annotations
This commit is contained in:
Michel Roegl-Brunner
2025-09-30 11:30:43 +02:00
committed by GitHub
parent 2f1df95d90
commit 433d8121e8
23 changed files with 2964 additions and 183 deletions

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
data/settings.db
# next.js
/.next/

265
README.md
View File

@@ -2,6 +2,16 @@
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming.
## 🎯 Deployment Options
This application can be deployed in multiple ways to suit different environments:
- **🐧 Proxmox Host**: Run directly on your Proxmox VE host system
- **📦 Debian LXC Container**: Deploy inside a Debian LXC container for better isolation
- **⚡ Quick Install**: Use the automated `install.sh` script for easy setup
All deployment methods provide the same functionality and web interface.
## 🌟 Features
- **Web-based Interface**: Modern React/Next.js frontend with real-time terminal emulation
@@ -33,108 +43,278 @@ A modern web-based management interface for Proxmox VE (PVE) helper scripts. Thi
- **Container Scripts**: Pre-configured LXC container setups
- **Installation Scripts**: System setup and configuration tools
### Database
- **SQLite Database**: Local database stored at `data/settings.db`
- **Server Management**: Stores Proxmox server configurations and credentials
- **Automatic Setup**: Database and tables are created automatically on first run
- **Data Persistence**: Settings persist across application restarts
## 📋 Prerequisites
### For All Deployment Methods
- **Node.js** 22+ and npm
- **Git** for cloning the repository
- **Proxmox VE environment**
- **build-essentials** ```apt install build-essential```
- **Proxmox VE environment** (host or access to Proxmox cluster)
- **SQLite** (included with Node.js better-sqlite3 package)
### For Proxmox Host Installation
- **build-essentials**: `apt install build-essential`
- Direct access to Proxmox host system
### For Debian LXC Container Installation
- **Debian LXC container** (Debian 11+ recommended)
- **build-essentials**: `apt install build-essential`
- Container with sufficient resources (2GB RAM, 4GB storage minimum)
- Network access from container to Proxmox host
- Optional: Privileged container for full Proxmox integration
### For Quick Install (install.sh)
- **Proxmox VE host** (script automatically detects and configures)
- Internet connectivity for downloading dependencies
## 🚀 Installation
You can either install automatically via the provided installer script or do a manual setup.
Choose the installation method that best fits your environment:
### Option 1: Install via Bash (Recommended)
### Option 1: Quick Install with install.sh (Recommended for Proxmox Host)
Run this command directly on your Proxmox VE host:
Run this command directly on your Proxmox VE host or on any Debian based lxc:
```bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"
```
## The script will:
- Verify that you are running on Proxmox VE
- Check and install git and Node.js 24.x if missing
- Clone the repository into /opt/PVESciptslocal (or your chosen path)
- Run npm install and build the project
- Set up .env from .env.example if missing
- Create a systemd service (pvescriptslocal.service) for easy start/stop management
**What the script does:**
- ✅ Installs required dependencies (build-essential, git, Node.js 24.x)
- ✅ Clones the repository into `/opt/PVESciptslocal` (or your chosen path)
- ✅ Runs npm install and builds the project
- ✅ Sets up `.env` from `.env.example` if missing
- ✅ Creates database directory (`data/`) for SQLite storage
- Creates a systemd service (`pvescriptslocal.service`) for easy management
After installation, the app will be accessible at:
👉 http://<YOUR_PVE_IP>:3000
**After installation:**
- 🌐 Access the app at: `http://<YOUR_PVE_OR_LXC_IP>:3000`
- 🔧 Manage the service with:
```bash
systemctl start pvescriptslocal
systemctl stop pvescriptslocal
systemctl status pvescriptslocal
```
### Option 2: Debian LXC Container Installation
For better isolation and security, you can run PVE Scripts Local inside a Debian LXC container:
#### Step 1: Create Debian LXC Container
You can manage the service with:
```bash
systemctl start pvescriptslocal
systemctl stop pvescriptslocal
systemctl status pvescriptslocal
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/debian.sh)"
```
Then run the installer:
```bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"
```
#### Step 2: Install Dependencies in Container when installer is not used
```bash
# Enter the container
pct enter 100
### Option 2: Manual Installation
# Update and install dependencies
apt update && apt install -y build-essential git curl
### 1. Clone the Repository
# Install Node.js 24.x
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs
```
#### Step 3: Clone and Setup Application
```bash
# Clone the repository
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git /opt/PVESciptslocal
cd /opt/PVESciptslocal
# Install dependencies and build
npm install
npm run build
# Setup environment
cp .env.example .env
# Create database directory
mkdir -p data
chmod 755 data
```
#### Step 4: Start the Application
```bash
# Start in production mode
npm start
# Or create a systemd service (optional)
# Follow the same systemd setup as the install.sh script
```
**Access the application:**
- 🌐 Container IP: `http://<CONTAINER_IP>:3000`
- 🔧 Container management: `pct start 100`, `pct stop 100`, `pct status 100`
### Option 3: Manual Installation (Proxmox Host)
#### Step 1: Clone the Repository
```bash
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git
cd PVESciptslocal
```
### 2. Install Dependencies
#### Step 2: Install Dependencies
```bash
npm install
```
### 3. Environment Configuration
Copy the example environment file and configure your settings:
#### Step 3: Environment Configuration
```bash
cp .env.example .env
# Edit .env file with your specific settings if needed
```
### 4. Start the Application
#### Production Mode
#### Step 4: Database Setup
```bash
# Create database directory
mkdir -p data
chmod 755 data
```
#### Step 5: Build and Start
```bash
# Production mode
npm run build
npm start
# Development mode
npm run dev:server
```
The application will be available at `http://IP:3000`
**Access the application:**
- 🌐 Available at: `http://<YOUR_IP>:3000`
## 📝 LXC Container Specific Notes
### Container Requirements
- **OS**: Debian 11+ (Debian 12 recommended)
- **Resources**: Minimum 2GB RAM, 4GB storage
- **Network**: Bridge connection to Proxmox network
- **Privileges**: Unprivileged containers work, but privileged containers provide better Proxmox integration
### Container Configuration Tips
- **Privileged Container**: Use `--unprivileged 0` for full Proxmox API access
- **Resource Allocation**: Allocate at least 2 CPU cores and 2GB RAM for optimal performance
- **Storage**: Use at least 8GB for the container to accommodate Node.js and dependencies
- **Network**: Ensure the container can reach the Proxmox host API
### Security Considerations
- **Unprivileged Containers**: More secure but may have limited Proxmox functionality
- **Privileged Containers**: Full Proxmox access but less secure isolation
- **Network Access**: Ensure proper firewall rules for the container
### Troubleshooting LXC Installation
- **Permission Issues**: Ensure the container has proper permissions for Proxmox API access
- **Network Connectivity**: Verify the container can reach the Proxmox host
- **Resource Limits**: Check if the container has sufficient resources allocated
## 🎯 Usage
### 1. Access the Web Interface
Open your browser and navigate to `http://IP:3000` (or your configured host/port).
The web interface is accessible regardless of your deployment method:
### 2. Browse Available Scripts
- **Proxmox Host Installation**: `http://<PROXMOX_HOST_IP>:3000`
- **LXC Container Installation**: `http://<CONTAINER_IP>:3000`
- **Custom Installation**: `http://<YOUR_IP>:3000`
### 2. Service Management
#### For install.sh installations (systemd service):
```bash
# Start the service
systemctl start pvescriptslocal
# Stop the service
systemctl stop pvescriptslocal
# Check service status
systemctl status pvescriptslocal
# Enable auto-start on boot
systemctl enable pvescriptslocal
# View service logs
journalctl -u pvescriptslocal -f
```
#### For LXC container installations:
```bash
# Container management
pct start <container_id> # Start container
pct stop <container_id> # Stop container
pct status <container_id> # Check container status
# Access container shell
pct enter <container_id>
# Inside container - start application
cd /opt/PVESciptslocal
npm start
```
#### For manual installations:
```bash
# Start application
npm start
# Development mode
npm run dev:server
# Build for production
npm run build
```
### 3. Browse Available Scripts
- The main page displays a grid of available Proxmox scripts
- Use the search functionality to find specific scripts
- Scripts are categorized by type (containers, installations, etc.)
### 3. Download Scripts
### 4. Download Scripts
- Click on any script card to view details
- Use the "Download" button to fetch scripts from GitHub
- Downloaded scripts are stored locally in the `scripts/` directory
### 4. Execute Scripts
### 5. Execute Scripts
- Click "Run Script" on any downloaded script
- A terminal window will open with real-time output
- Interact with the script through the web terminal
- Use the close button to stop execution
### 5. Script Management
### 6. Script Management
- View script execution history
- Update scripts to latest versions
- Manage local script collections
### 7. Database Management
The application uses SQLite for storing server configurations:
- **Database Location**: `data/settings.db`
- **Automatic Creation**: Database and tables are created on first run
- **Server Storage**: Proxmox server credentials and configurations
- **Backup**: Copy `data/settings.db` to backup your server configurations
- **Reset**: Delete `data/settings.db` to reset all server configurations
## 📁 Project Structure
```
@@ -151,7 +331,10 @@ PVESciptslocal/
│ │ ├── _components/ # React components
│ │ └── page.tsx # Main page
│ └── server/ # Server-side code
│ ├── database.js # SQLite database service
│ └── services/ # Business logic services
├── data/ # Database storage
│ └── settings.db # SQLite database file
├── public/ # Static assets
├── server.js # Main server file
└── package.json # Dependencies and scripts
@@ -201,6 +384,18 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- Server logs: Check console output or `server.log`
- Script execution: View in web terminal
## 🎯 Quick Start Summary
Choose your preferred deployment method:
| Method | Best For | Command |
|--------|----------|---------|
| **Quick Install** | Proxmox hosts or Debian LXC, easy setup | `bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"` |
| **LXC Container** | Better isolation, security | Create Debian LXC → Install dependencies → Clone repo → `npm start` |
| **Manual Install** | Custom setups, development | `git clone` → `npm install` → `npm run build` → `npm start` |
All methods provide the same web interface at `http://<IP>:3000` with full Proxmox script management capabilities.
---
**Note**: This is alpha software. Use with caution in production environments and always backup your Proxmox configuration before running scripts.

View File

@@ -15,20 +15,13 @@ msg_info() { echo -e "⏳ $YW$1$CL"; }
msg_ok() { echo -e "✔️ $GN$1$CL"; }
msg_err() { echo -e "$RD$1$CL"; }
# --- PVE Check ----------------------------------------------------------------
check_pve() {
if ! command -v pveversion >/dev/null 2>&1; then
msg_err "This script must be executed on a Proxmox VE host."
exit 1
fi
msg_ok "Proxmox VE detected: $(pveversion)"
}
# --- Dependency Check & Install -----------------------------------------------
check_dependencies() {
msg_info "Checking required packages (build-essential, git)..."
apt-get update
apt-get install -y build-essential git
apt-get install -y build-essential git sshpass expect
msg_ok "Dependencies installed."
}
@@ -75,6 +68,11 @@ setup_app() {
msg_ok ".env file already exists, keeping it."
fi
msg_info "Setting up database directory..."
mkdir -p data
chmod 755 data
msg_ok "Database directory created."
msg_info "Building application..."
npm run build
msg_ok "Build completed."

444
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"better-sqlite3": "^9.6.0",
"next": "^15.5.3",
"node-pty": "^1.0.0",
"react": "^19.0.0",
@@ -38,6 +39,7 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^24.3.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -2819,6 +2821,16 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
@@ -4055,6 +4067,57 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4112,6 +4175,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -4517,6 +4604,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -4527,6 +4629,15 @@
"node": ">=6"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4584,7 +4695,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -4647,6 +4757,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -5349,6 +5468,15 @@
"node": ">=0.10.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
@@ -5453,6 +5581,12 @@
"node": ">=16.0.0"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5545,6 +5679,12 @@
"node": ">=0.4.x"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5681,6 +5821,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -5995,6 +6141,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6042,6 +6208,18 @@
"node": ">=8"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -7232,6 +7410,18 @@
"node": ">=8.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -7259,7 +7449,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -7304,6 +7493,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -7344,6 +7539,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/napi-postinstall": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz",
@@ -7447,6 +7648,30 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-abi": {
"version": "3.77.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz",
"integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
@@ -7594,6 +7819,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -7839,6 +8073,32 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8023,6 +8283,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8054,6 +8324,30 @@
],
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
@@ -8110,6 +8404,20 @@
"react": ">= 0.14.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -8336,6 +8644,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -8637,6 +8965,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-git": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz",
@@ -8731,6 +9104,15 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -9102,6 +9484,40 @@
"node": ">=18"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar/node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -9359,6 +9775,18 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -9588,6 +10016,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
@@ -10080,6 +10514,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",

View File

@@ -44,7 +44,8 @@
"superjson": "^2.2.1",
"ws": "^8.18.3",
"xterm": "^5.3.0",
"zod": "^3.24.2"
"zod": "^3.24.2",
"better-sqlite3": "^9.6.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
@@ -67,7 +68,8 @@
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"@types/better-sqlite3": "^7.6.8"
},
"ct3aMetadata": {
"initVersion": "7.39.3"

View File

@@ -15,7 +15,9 @@ variables() {
CT_TYPE=${var_unprivileged:-$CT_TYPE}
}
source "$(dirname "${BASH_SOURCE[0]}")/core.func"
# Get absolute path to core directory
CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$CORE_DIR/core.func"
# This function enables error handling in the script by setting options and defining a trap for the ERR signal.
@@ -972,7 +974,7 @@ install_script() {
header_info
echo -e "${INFO}${HOLD} ${GN}Using Config File on node $PVEHOST_NAME${CL}"
METHOD="config_file"
source "$(dirname "${BASH_SOURCE[0]}")/config-file.func"
source "$CORE_DIR/config-file.func"
config_file
break
;;
@@ -1043,7 +1045,7 @@ check_container_storage() {
}
start() {
source "$(dirname "${BASH_SOURCE[0]}")/tools.func"
source "$CORE_DIR/tools.func"
if command -v pveversion >/dev/null 2>&1; then
install_script
@@ -1105,10 +1107,12 @@ build_container() {
TEMP_DIR=$(mktemp -d)
pushd "$TEMP_DIR" >/dev/null
# CORE_DIR is already defined at the top of the file
if [ "$var_os" == "alpine" ]; then
export FUNCTIONS_FILE_PATH="$(cat "$(dirname "${BASH_SOURCE[0]}")/core.func" && echo && cat "$(dirname "${BASH_SOURCE[0]}")/tools.func" && echo && cat "$(dirname "${BASH_SOURCE[0]}")/alpine-install.func")"
export FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/alpine-install.func")"
else
export FUNCTIONS_FILE_PATH="$(cat "$(dirname "${BASH_SOURCE[0]}")/core.func" && echo && cat "$(dirname "${BASH_SOURCE[0]}")/tools.func" && echo && cat "$(dirname "${BASH_SOURCE[0]}")/install.func")"
export FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/install.func")"
fi
export DIAGNOSTICS="$DIAGNOSTICS"
@@ -1143,7 +1147,7 @@ build_container() {
$PW
"
# This executes create_lxc.sh and creates the container and .conf file
bash "$(dirname "${BASH_SOURCE[0]}")/create_lxc.sh" $?
bash "$CORE_DIR/create_lxc.sh" $?
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
@@ -1337,7 +1341,7 @@ EOF'
msg_ok "Customized LXC Container"
lxc-attach -n "$CTID" -- bash -c "$(cat "$(dirname "${BASH_SOURCE[0]}")/../install/${var_install}.sh")"
lxc-attach -n "$CTID" -- bash -c "$(cat "$(dirname "$CORE_DIR")/install/${var_install}.sh")"
}
# This function sets the description of the container.

136
server.js
View File

@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
import { join, resolve } from 'path';
import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
const dev = process.env.NODE_ENV !== 'production';
const hostname = '0.0.0.0';
@@ -25,12 +26,28 @@ const handle = app.getRequestHandler();
* @property {ExtendedWebSocket} ws
*/
/**
* @typedef {Object} ServerInfo
* @property {string} name
* @property {string} ip
* @property {string} user
* @property {string} password
*/
/**
* @typedef {Object} ExecutionResult
* @property {any} process
* @property {Function} kill
*/
/**
* @typedef {Object} WebSocketMessage
* @property {string} action
* @property {string} [scriptPath]
* @property {string} [executionId]
* @property {string} [input]
* @property {string} [mode]
* @property {ServerInfo} [server]
*/
class ScriptExecutionHandler {
@@ -55,7 +72,10 @@ class ScriptExecutionHandler {
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
const rawMessage = data.toString();
console.log('Raw WebSocket message received:', rawMessage);
const message = JSON.parse(rawMessage);
console.log('Parsed WebSocket message:', message);
this.handleMessage(/** @type {ExtendedWebSocket} */ (ws), message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
@@ -83,12 +103,16 @@ class ScriptExecutionHandler {
* @param {WebSocketMessage} message
*/
async handleMessage(ws, message) {
const { action, scriptPath, executionId, input } = message;
const { action, scriptPath, executionId, input, mode, server } = message;
// Debug logging
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
console.log('Full message object:', JSON.stringify(message, null, 2));
switch (action) {
case 'start':
if (scriptPath && executionId) {
await this.startScriptExecution(ws, scriptPath, executionId);
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
} else {
this.sendMessage(ws, {
type: 'error',
@@ -123,10 +147,39 @@ class ScriptExecutionHandler {
* @param {ExtendedWebSocket} ws
* @param {string} scriptPath
* @param {string} executionId
* @param {string} mode
* @param {ServerInfo|null} server
*/
async startScriptExecution(ws, scriptPath, executionId) {
async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null) {
try {
// Basic validation
// Debug logging
console.log('startScriptExecution called with:', { mode, server: server ? { name: server.name, ip: server.ip } : null });
console.log('Full server object:', JSON.stringify(server, null, 2));
// Check if execution is already running
if (this.activeExecutions.has(executionId)) {
this.sendMessage(ws, {
type: 'error',
data: 'Script execution already running',
timestamp: Date.now()
});
return;
}
// Handle SSH execution
if (mode === 'ssh' && server) {
console.log('Starting SSH execution...');
await this.startSSHScriptExecution(ws, scriptPath, executionId, server);
return;
}
if (mode === 'ssh' && !server) {
console.log('SSH mode requested but no server provided, falling back to local execution');
}
console.log('Starting local execution...');
// Basic validation for local execution
const scriptsDir = join(process.cwd(), 'scripts');
const resolvedPath = resolve(scriptPath);
@@ -139,16 +192,6 @@ class ScriptExecutionHandler {
return;
}
// Check if execution is already running
if (this.activeExecutions.has(executionId)) {
this.sendMessage(ws, {
type: 'error',
data: 'Script execution already running',
timestamp: Date.now()
});
return;
}
// Start script execution with pty for proper TTY support
const childProcess = ptySpawn('bash', [resolvedPath], {
cwd: scriptsDir,
@@ -206,6 +249,69 @@ class ScriptExecutionHandler {
}
}
/**
* Start SSH script execution
* @param {ExtendedWebSocket} ws
* @param {string} scriptPath
* @param {string} executionId
* @param {ServerInfo} server
*/
async startSSHScriptExecution(ws, scriptPath, executionId, server) {
console.log('startSSHScriptExecution called with server:', server);
const sshService = getSSHExecutionService();
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting SSH execution of ${scriptPath} on ${server.name} (${server.ip})`,
timestamp: Date.now()
});
try {
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
server,
scriptPath,
/** @param {string} data */ (data) => {
// Handle data output
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */ (error) => {
// Handle errors
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */ (code) => {
// Handle process exit
this.sendMessage(ws, {
type: 'end',
data: `SSH script execution finished with code: ${code}`,
timestamp: Date.now()
});
// Clean up
this.activeExecutions.delete(executionId);
}
));
// Store the execution
this.activeExecutions.set(executionId, { process: execution.process, ws });
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `Failed to start SSH execution: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
}
}
/**
* @param {string} executionId
*/

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect } from 'react';
import type { Server } from '../../types/server';
interface ExecutionModeModalProps {
isOpen: boolean;
onClose: () => void;
onExecute: (mode: 'local' | 'ssh', server?: Server) => void;
scriptName: string;
}
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
useEffect(() => {
if (isOpen) {
void fetchServers();
}
}, [isOpen]);
const fetchServers = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/servers');
if (!response.ok) {
throw new Error('Failed to fetch servers');
}
const data = await response.json();
console.log('Fetched servers:', data);
setServers(data as Server[]);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
const handleExecute = () => {
if (selectedMode === 'ssh' && !selectedServer) {
setError('Please select a server for SSH execution');
return;
}
console.log('ExecutionModeModal executing with:', { mode: selectedMode, server: selectedServer });
onExecute(selectedMode, selectedServer ?? undefined);
onClose();
};
const handleModeChange = (mode: 'local' | 'ssh') => {
setSelectedMode(mode);
if (mode === 'local') {
setSelectedServer(null);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Execution Mode</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">
How would you like to execute &quot;{scriptName}&quot;?
</h3>
<p className="text-gray-600 text-sm">
Choose between local execution or running the script on a remote server via SSH.
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{/* Execution Mode Selection */}
<div className="space-y-4 mb-6">
{/* Local Execution */}
<div
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
selectedMode === 'local'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => handleModeChange('local')}
>
<div className="flex items-center">
<input
type="radio"
id="local"
name="executionMode"
value="local"
checked={selectedMode === 'local'}
onChange={() => handleModeChange('local')}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<label htmlFor="local" className="ml-3 flex-1 cursor-pointer">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-gray-900">Local Execution</h4>
<p className="text-sm text-gray-500">Run the script on this server</p>
</div>
</div>
</label>
</div>
</div>
{/* SSH Execution */}
<div
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
selectedMode === 'ssh'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => handleModeChange('ssh')}
>
<div className="flex items-center">
<input
type="radio"
id="ssh"
name="executionMode"
value="ssh"
checked={selectedMode === 'ssh'}
onChange={() => handleModeChange('ssh')}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-gray-900">SSH Execution</h4>
<p className="text-sm text-gray-500">Run the script on a remote server</p>
</div>
</div>
</label>
</div>
</div>
</div>
{/* Server Selection (only for SSH mode) */}
{selectedMode === 'ssh' && (
<div className="mb-6">
<label htmlFor="server" className="block text-sm font-medium text-gray-700 mb-2">
Select Server
</label>
{loading ? (
<div className="text-center py-4">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<p className="mt-2 text-sm text-gray-600">Loading servers...</p>
</div>
) : servers.length === 0 ? (
<div className="text-center py-4 text-gray-500">
<p className="text-sm">No servers configured</p>
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p>
</div>
) : (
<select
id="server"
value={selectedServer?.id ?? ''}
onChange={(e) => {
const serverId = parseInt(e.target.value);
const server = servers.find(s => s.id === serverId);
console.log('Server selected:', { serverId, server });
setSelectedServer(server ?? null);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select a server...</option>
{servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name} ({server.ip}) - {server.user}
</option>
))}
</select>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
<button
onClick={handleExecute}
disabled={selectedMode === 'ssh' && !selectedServer}
className={`px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
selectedMode === 'ssh' && !selectedServer
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -6,12 +6,13 @@ import { api } from '~/trpc/react';
import type { Script } from '~/types/script';
import { DiffViewer } from './DiffViewer';
import { TextViewer } from './TextViewer';
import { ExecutionModeModal } from './ExecutionModeModal';
interface ScriptDetailModalProps {
script: Script | null;
isOpen: boolean;
onClose: () => void;
onInstallScript?: (scriptPath: string, scriptName: string) => void;
onInstallScript?: (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => void;
}
export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: ScriptDetailModalProps) {
@@ -21,6 +22,7 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
const [diffViewerOpen, setDiffViewerOpen] = useState(false);
const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null);
const [textViewerOpen, setTextViewerOpen] = useState(false);
const [executionModeOpen, setExecutionModeOpen] = useState(false);
// Check if script files exist locally
const { data: scriptFilesData, refetch: refetchScriptFiles, isLoading: scriptFilesLoading } = api.scripts.checkScriptFiles.useQuery(
@@ -79,6 +81,11 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
};
const handleInstallScript = () => {
if (!script) return;
setExecutionModeOpen(true);
};
const handleExecuteScript = (mode: 'local' | 'ssh', server?: any) => {
if (!script || !onInstallScript) return;
// Find the script path (CT or tools)
@@ -86,7 +93,9 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`;
const scriptName = script.name;
onInstallScript(scriptPath, scriptName);
// Pass execution mode and server info to the parent
onInstallScript(scriptPath, scriptName, mode, server);
// Scroll to top of the page to see the terminal
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -513,6 +522,16 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
onClose={() => setTextViewerOpen(false)}
/>
)}
{/* Execution Mode Modal */}
{script && (
<ExecutionModeModal
scriptName={script.name}
isOpen={executionModeOpen}
onClose={() => setExecutionModeOpen(false)}
onExecute={handleExecuteScript}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import type { CreateServerData } from '../../types/server';
interface ServerFormProps {
onSubmit: (data: CreateServerData) => void;
initialData?: CreateServerData;
isEditing?: boolean;
onCancel?: () => void;
}
export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) {
const [formData, setFormData] = useState<CreateServerData>(
initialData ?? {
name: '',
ip: '',
user: '',
password: '',
}
);
const [errors, setErrors] = useState<Partial<CreateServerData>>({});
const validateForm = (): boolean => {
const newErrors: Partial<CreateServerData> = {};
if (!formData.name.trim()) {
newErrors.name = 'Server name is required';
}
if (!formData.ip.trim()) {
newErrors.ip = 'IP address is required';
} else {
// Basic IP validation
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipRegex.test(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address';
}
}
if (!formData.user.trim()) {
newErrors.user = 'Username is required';
}
if (!formData.password.trim()) {
newErrors.password = 'Password is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
onSubmit(formData);
if (!isEditing) {
setFormData({ name: '', ip: '', user: '', password: '' });
}
}
};
const handleChange = (field: keyof CreateServerData) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Server Name *
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={handleChange('name')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.name ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="e.g., Production Server"
/>
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>}
</div>
<div>
<label htmlFor="ip" className="block text-sm font-medium text-gray-700 mb-1">
IP Address *
</label>
<input
type="text"
id="ip"
value={formData.ip}
onChange={handleChange('ip')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.ip ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="e.g., 192.168.1.100"
/>
{errors.ip && <p className="mt-1 text-sm text-red-600">{errors.ip}</p>}
</div>
<div>
<label htmlFor="user" className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<input
type="text"
id="user"
value={formData.user}
onChange={handleChange('user')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.user ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="e.g., root"
/>
{errors.user && <p className="mt-1 text-sm text-red-600">{errors.user}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password *
</label>
<input
type="password"
id="password"
value={formData.password}
onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.password ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Enter password"
/>
{errors.password && <p className="mt-1 text-sm text-red-600">{errors.password}</p>}
</div>
</div>
<div className="flex justify-end space-x-3 pt-4">
{isEditing && onCancel && (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
)}
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{isEditing ? 'Update Server' : 'Add Server'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import { useState } from 'react';
import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm';
interface ServerListProps {
servers: Server[];
onUpdate: (id: number, data: CreateServerData) => void;
onDelete: (id: number) => void;
}
export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
const [editingId, setEditingId] = useState<number | null>(null);
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
const [connectionResults, setConnectionResults] = useState<Map<number, { success: boolean; message: string }>>(new Map());
const handleEdit = (server: Server) => {
setEditingId(server.id);
};
const handleUpdate = (data: CreateServerData) => {
if (editingId) {
onUpdate(editingId, data);
setEditingId(null);
}
};
const handleCancel = () => {
setEditingId(null);
};
const handleDelete = (id: number) => {
if (window.confirm('Are you sure you want to delete this server configuration?')) {
onDelete(id);
}
};
const handleTestConnection = async (server: Server) => {
setTestingConnections(prev => new Set(prev).add(server.id));
setConnectionResults(prev => {
const newMap = new Map(prev);
newMap.delete(server.id);
return newMap;
});
try {
const response = await fetch(`/api/servers/${server.id}/test-connection`, {
method: 'POST',
});
const result = await response.json();
setConnectionResults(prev => new Map(prev).set(server.id, {
success: result.success,
message: result.message
}));
} catch {
setConnectionResults(prev => new Map(prev).set(server.id, {
success: false,
message: 'Failed to test connection - network error'
}));
} finally {
setTestingConnections(prev => {
const newSet = new Set(prev);
newSet.delete(server.id);
return newSet;
});
}
};
if (servers.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No servers configured</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding a new server configuration above.</p>
</div>
);
}
return (
<div className="space-y-4">
{servers.map((server) => (
<div key={server.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
{editingId === server.id ? (
<div>
<h4 className="text-lg font-medium text-gray-900 mb-4">Edit Server</h4>
<ServerForm
initialData={{
name: server.name,
ip: server.ip,
user: server.user,
password: server.password,
}}
onSubmit={handleUpdate}
isEditing={true}
onCancel={handleCancel}
/>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">{server.name}</h3>
<div className="mt-1 flex items-center space-x-4 text-sm text-gray-500">
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
</svg>
{server.ip}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{server.user}
</span>
</div>
<div className="mt-1 text-xs text-gray-400">
Created: {new Date(server.created_at).toLocaleDateString()}
{server.updated_at !== server.created_at && (
<span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
)}
</div>
{/* Connection Test Result */}
{connectionResults.has(server.id) && (
<div className={`mt-2 p-2 rounded-md text-xs ${
connectionResults.get(server.id)?.success
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
<div className="flex items-center">
{connectionResults.get(server.id)?.success ? (
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span className="font-medium">
{connectionResults.get(server.id)?.success ? 'Connection Successful' : 'Connection Failed'}
</span>
</div>
<p className="mt-1">{connectionResults.get(server.id)?.message}</p>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleTestConnection(server)}
disabled={testingConnections.has(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-green-300 text-xs font-medium rounded text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{testingConnections.has(server.id) ? (
<>
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Testing...
</>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Test Connection
</>
)}
</button>
<button
onClick={() => handleEdit(server)}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
onClick={() => handleDelete(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-red-300 text-xs font-medium rounded text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
import { SettingsModal } from './SettingsModal';
export function SettingsButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
title="Add PVE Server"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Add PVE Server
</button>
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState, useEffect } from 'react';
import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm';
import { ServerList } from './ServerList';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
useEffect(() => {
if (isOpen) {
void fetchServers();
}
}, [isOpen]);
const fetchServers = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/servers');
if (!response.ok) {
throw new Error('Failed to fetch servers');
}
const data = await response.json();
setServers(data as Server[]);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
const handleCreateServer = async (serverData: CreateServerData) => {
try {
const response = await fetch('/api/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(serverData),
});
if (!response.ok) {
throw new Error('Failed to create server');
}
await fetchServers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
}
};
const handleUpdateServer = async (id: number, serverData: CreateServerData) => {
try {
const response = await fetch(`/api/servers/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(serverData),
});
if (!response.ok) {
throw new Error('Failed to update server');
}
await fetchServers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update server');
}
};
const handleDeleteServer = async (id: number) => {
try {
const response = await fetch(`/api/servers/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete server');
}
await fetchServers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Settings</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex space-x-8 px-6">
<button
onClick={() => setActiveTab('servers')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'servers'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Server Settings
</button>
<button
onClick={() => setActiveTab('general')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'general'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
General
</button>
</nav>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
</div>
)}
{activeTab === 'servers' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Server Configurations</h3>
<ServerForm onSubmit={handleCreateServer} />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Saved Servers</h3>
{loading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading servers...</p>
</div>
) : (
<ServerList
servers={servers}
onUpdate={handleUpdateServer}
onDelete={handleDeleteServer}
/>
)}
</div>
</div>
)}
{activeTab === 'general' && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">General Settings</h3>
<p className="text-gray-600">General settings will be available in a future update.</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -6,6 +6,8 @@ import '@xterm/xterm/css/xterm.css';
interface TerminalProps {
scriptPath: string;
onClose: () => void;
mode?: 'local' | 'ssh';
server?: any;
}
interface TerminalMessage {
@@ -14,7 +16,7 @@ interface TerminalMessage {
timestamp: number;
}
export function Terminal({ scriptPath, onClose }: TerminalProps) {
export function Terminal({ scriptPath, onClose, mode = 'local', server }: TerminalProps) {
const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false);
@@ -58,6 +60,12 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
cursorStyle: 'block',
scrollback: 1000,
tabStopWidth: 4,
allowTransparency: false,
convertEol: true,
disableStdin: false,
macOptionIsMeta: false,
rightClickSelectsWord: false,
wordSeparator: ' ()[]{}\'"`<>|',
});
// Add addons
@@ -148,11 +156,15 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
isConnectingRef.current = false;
// Send start message immediately after connection
ws.send(JSON.stringify({
const message = {
action: 'start',
scriptPath,
executionId
}));
executionId,
mode,
server
};
console.log('Sending WebSocket message:', message);
ws.send(JSON.stringify(message));
};
ws.onmessage = (event) => {
@@ -189,7 +201,7 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
wsRef.current.close();
}
};
}, [scriptPath, executionId]);
}, [scriptPath, executionId, mode, server]);
const handleMessage = (message: TerminalMessage) => {
if (!xtermRef.current) return;
@@ -211,6 +223,12 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
// This is likely terminal output sent to stderr, treat it as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('TERM environment variable not set')) {
// This is a common warning, treat as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
// This is a script error, show it with error prefix
xtermRef.current.writeln(`${prefix}${message.data}`);
} else {
// This is a real error, show it with error prefix
xtermRef.current.writeln(`${prefix}${message.data}`);
@@ -228,7 +246,9 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
wsRef.current.send(JSON.stringify({
action: 'start',
scriptPath,
executionId
executionId,
mode,
server
}));
}
};
@@ -282,7 +302,7 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<span className="text-gray-300 font-mono text-sm ml-2">
{scriptName}
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
</span>
</div>

View File

@@ -0,0 +1,143 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../server/database';
import type { CreateServerData } from '../../../../types/server';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid server ID' },
{ status: 400 }
);
}
const db = getDatabase();
const server = db.getServerById(id);
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
);
}
return NextResponse.json(server);
} catch (error) {
console.error('Error fetching server:', error);
return NextResponse.json(
{ error: 'Failed to fetch server' },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid server ID' },
{ status: 400 }
);
}
const body = await request.json();
const { name, ip, user, password }: CreateServerData = body;
// Validate required fields
if (!name || !ip || !user || !password) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const db = getDatabase();
// Check if server exists
const existingServer = db.getServerById(id);
if (!existingServer) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
);
}
const result = db.updateServer(id, { name, ip, user, password });
return NextResponse.json(
{
message: 'Server updated successfully',
changes: result.changes
}
);
} catch (error) {
console.error('Error updating server:', error);
// Handle unique constraint violation
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
return NextResponse.json(
{ error: 'A server with this name already exists' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to update server' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid server ID' },
{ status: 400 }
);
}
const db = getDatabase();
// Check if server exists
const existingServer = db.getServerById(id);
if (!existingServer) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
);
}
const result = db.deleteServer(id);
return NextResponse.json(
{
message: 'Server deleted successfully',
changes: result.changes
}
);
} catch (error) {
console.error('Error deleting server:', error);
return NextResponse.json(
{ error: 'Failed to delete server' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database';
import { getSSHService } from '../../../../../server/ssh-service';
import type { Server } from '../../../../../types/server';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid server ID' },
{ status: 400 }
);
}
const db = getDatabase();
const server = db.getServerById(id) as Server;
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
);
}
// Test SSH connection
const sshService = getSSHService();
const connectionResult = await sshService.testConnection(server);
return NextResponse.json(connectionResult);
} catch (error) {
console.error('Error testing SSH connection:', error);
return NextResponse.json(
{ error: 'Failed to test SSH connection' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,60 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../server/database';
import type { CreateServerData } from '../../../types/server';
export async function GET() {
try {
const db = getDatabase();
const servers = db.getAllServers();
return NextResponse.json(servers);
} catch (error) {
console.error('Error fetching servers:', error);
return NextResponse.json(
{ error: 'Failed to fetch servers' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, ip, user, password }: CreateServerData = body;
// Validate required fields
if (!name || !ip || !user || !password) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const db = getDatabase();
const result = db.createServer({ name, ip, user, password });
return NextResponse.json(
{
message: 'Server created successfully',
id: result.lastInsertRowid
},
{ status: 201 }
);
} catch (error) {
console.error('Error creating server:', error);
// Handle unique constraint violation
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
return NextResponse.json(
{ error: 'A server with this name already exists' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to create server' },
{ status: 500 }
);
}
}

View File

@@ -4,15 +4,15 @@
import { useState } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { ResyncButton } from './_components/ResyncButton';
import { RepoStatusButton } from './_components/RepoStatusButton';
import { Terminal } from './_components/Terminal';
import { ProxmoxCheck } from './_components/ProxmoxCheck';
import { SettingsButton } from './_components/SettingsButton';
export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string } | null>(null);
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const handleRunScript = (scriptPath: string, scriptName: string) => {
setRunningScript({ path: scriptPath, name: scriptName });
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
console.log('handleRunScript called with:', { scriptPath, scriptName, mode, server });
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
};
const handleCloseTerminal = () => {
@@ -20,43 +20,43 @@ export default function Home() {
};
return (
<ProxmoxCheck>
<main className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
🚀 PVE Scripts Management
</h1>
<p className="text-gray-600">
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
</div>
<main className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
🚀 PVE Scripts Management
</h1>
<p className="text-gray-600">
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
</div>
{/* Resync Button */}
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div></div>
<ResyncButton />
</div>
{/* Controls */}
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<SettingsButton />
<ResyncButton />
</div>
{/* Running Script Terminal */}
{runningScript && (
<div className="mb-8">
<Terminal
scriptPath={runningScript.path}
onClose={handleCloseTerminal}
/>
</div>
)}
{/* Scripts List */}
<ScriptsGrid onInstallScript={handleRunScript} />
</div>
</main>
</ProxmoxCheck>
{/* Running Script Terminal */}
{runningScript && (
<div className="mb-8">
<Terminal
scriptPath={runningScript.path}
onClose={handleCloseTerminal}
mode={runningScript.mode}
server={runningScript.server}
/>
</div>
)}
{/* Scripts List */}
<ScriptsGrid onInstallScript={handleRunScript} />
</div>
</main>
);
}

View File

@@ -1,6 +1,8 @@
import { WebSocketServer, WebSocket } from 'ws';
import type { IncomingMessage } from 'http';
import { scriptManager } from '~/server/lib/scripts';
import { getSSHExecutionService } from '~/server/ssh-execution-service';
import type { Server } from '~/types/server';
interface ScriptExecutionMessage {
type: 'start' | 'output' | 'error' | 'end';
@@ -48,13 +50,15 @@ export class ScriptExecutionHandler {
});
}
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string }) {
const { action, scriptPath, executionId } = message;
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) {
const { action, scriptPath, executionId, mode, server } = message;
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
switch (action) {
case 'start':
if (scriptPath && executionId) {
await this.startScriptExecution(ws, scriptPath, executionId);
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
} else {
this.sendMessage(ws, {
type: 'error',
@@ -79,19 +83,10 @@ export class ScriptExecutionHandler {
}
}
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string) {
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
console.log('startScriptExecution called with:', { scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
try {
// Validate script path
const validation = scriptManager.validateScriptPath(scriptPath);
if (!validation.valid) {
this.sendMessage(ws, {
type: 'error',
data: validation.message ?? 'Invalid script path',
timestamp: Date.now()
});
return;
}
// Check if execution is already running
if (this.activeExecutions.has(executionId)) {
this.sendMessage(ws, {
@@ -102,61 +97,135 @@ export class ScriptExecutionHandler {
return;
}
// Start script execution
const process = await scriptManager.executeScript(scriptPath);
let process: any;
if (mode === 'ssh' && server) {
// SSH execution
console.log('Starting SSH execution:', { scriptPath, server });
console.log('SSH execution mode detected, calling SSH service...');
console.log('Mode check: mode=', mode, 'server=', !!server);
this.sendMessage(ws, {
type: 'start',
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
timestamp: Date.now()
});
const sshService = getSSHExecutionService();
console.log('SSH service obtained, calling executeScript...');
console.log('SSH service object:', typeof sshService, sshService.constructor.name);
try {
const result = await sshService.executeScript(server as Server, scriptPath,
(data: string) => {
console.log('SSH onData callback:', data.substring(0, 100) + '...');
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
(error: string) => {
console.log('SSH onError callback:', error);
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
(code: number) => {
console.log('SSH onExit callback, code:', code);
this.sendMessage(ws, {
type: 'end',
data: `SSH script execution finished with code: ${code}`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
}
);
console.log('SSH service executeScript completed, result:', result);
process = (result as any).process;
} catch (sshError) {
console.error('SSH service executeScript failed:', sshError);
this.sendMessage(ws, {
type: 'error',
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
timestamp: Date.now()
});
return;
}
} else {
// Local execution
console.log('Starting local execution:', { scriptPath });
console.log('Local execution mode detected, calling local script manager...');
console.log('Mode check: mode=', mode, 'server=', !!server, 'condition result:', mode === 'ssh' && server);
// Validate script path
const validation = scriptManager.validateScriptPath(scriptPath);
if (!validation.valid) {
this.sendMessage(ws, {
type: 'error',
data: validation.message ?? 'Invalid script path',
timestamp: Date.now()
});
return;
}
// Start script execution
process = await scriptManager.executeScript(scriptPath);
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting execution of ${scriptPath}`,
timestamp: Date.now()
});
// Handle stdout
process.stdout?.on('data', (data: Buffer) => {
this.sendMessage(ws, {
type: 'output',
data: data.toString(),
timestamp: Date.now()
});
});
// Handle stderr
process.stderr?.on('data', (data: Buffer) => {
this.sendMessage(ws, {
type: 'error',
data: data.toString(),
timestamp: Date.now()
});
});
// Handle process exit
process.on('exit', (code: number | null, signal: string | null) => {
this.sendMessage(ws, {
type: 'end',
data: `Script execution finished with code: ${code}, signal: ${signal}`,
timestamp: Date.now()
});
// Clean up
this.activeExecutions.delete(executionId);
});
// Handle process error
process.on('error', (error: Error) => {
this.sendMessage(ws, {
type: 'error',
data: `Process error: ${error.message}`,
timestamp: Date.now()
});
// Clean up
this.activeExecutions.delete(executionId);
});
}
// Store the execution
this.activeExecutions.set(executionId, { process, ws });
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting execution of ${scriptPath}`,
timestamp: Date.now()
});
// Handle stdout
process.stdout?.on('data', (data: Buffer) => {
this.sendMessage(ws, {
type: 'output',
data: data.toString(),
timestamp: Date.now()
});
});
// Handle stderr
process.stderr?.on('data', (data: Buffer) => {
this.sendMessage(ws, {
type: 'error',
data: data.toString(),
timestamp: Date.now()
});
});
// Handle process exit
process.on('exit', (code: number | null, signal: string | null) => {
this.sendMessage(ws, {
type: 'end',
data: `Script execution finished with code: ${code}, signal: ${signal}`,
timestamp: Date.now()
});
// Clean up
this.activeExecutions.delete(executionId);
});
// Handle process error
process.on('error', (error: Error) => {
this.sendMessage(ws, {
type: 'error',
data: `Process error: ${error.message}`,
timestamp: Date.now()
});
// Clean up
this.activeExecutions.delete(executionId);
});
} catch (error) {
this.sendMessage(ws, {
type: 'error',

100
src/server/database.js Normal file
View File

@@ -0,0 +1,100 @@
import Database from 'better-sqlite3';
import { join } from 'path';
class DatabaseService {
constructor() {
const dbPath = join(process.cwd(), 'data', 'settings.db');
this.db = new Database(dbPath);
this.init();
}
init() {
// Create servers table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL,
user TEXT NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Create trigger to update updated_at on row update
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS update_servers_timestamp
AFTER UPDATE ON servers
BEGIN
UPDATE servers SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`);
}
// Server CRUD operations
/**
* @param {import('../types/server').CreateServerData} serverData
*/
createServer(serverData) {
const { name, ip, user, password } = serverData;
const stmt = this.db.prepare(`
INSERT INTO servers (name, ip, user, password)
VALUES (?, ?, ?, ?)
`);
return stmt.run(name, ip, user, password);
}
getAllServers() {
const stmt = this.db.prepare('SELECT * FROM servers ORDER BY created_at DESC');
return stmt.all();
}
/**
* @param {number} id
*/
getServerById(id) {
const stmt = this.db.prepare('SELECT * FROM servers WHERE id = ?');
return stmt.get(id);
}
/**
* @param {number} id
* @param {import('../types/server').CreateServerData} serverData
*/
updateServer(id, serverData) {
const { name, ip, user, password } = serverData;
const stmt = this.db.prepare(`
UPDATE servers
SET name = ?, ip = ?, user = ?, password = ?
WHERE id = ?
`);
return stmt.run(name, ip, user, password, id);
}
/**
* @param {number} id
*/
deleteServer(id) {
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
return stmt.run(id);
}
close() {
this.db.close();
}
}
// Singleton instance
/** @type {DatabaseService | null} */
let dbInstance = null;
export function getDatabase() {
if (!dbInstance) {
dbInstance = new DatabaseService();
}
return dbInstance;
}
export default DatabaseService;

View File

@@ -0,0 +1,157 @@
import { spawn } from 'child_process';
import { spawn as ptySpawn } from 'node-pty';
/**
* @typedef {Object} Server
* @property {string} ip - Server IP address
* @property {string} user - Username
* @property {string} password - Password
* @property {string} name - Server name
*/
class SSHExecutionService {
/**
* Execute a script on a remote server via SSH
* @param {Server} server - Server configuration
* @param {string} scriptPath - Path to the script
* @param {Function} onData - Callback for data output
* @param {Function} onError - Callback for errors
* @param {Function} onExit - Callback for process exit
* @returns {Promise<Object>} Process information
*/
async executeScript(server, scriptPath, onData, onError, onExit) {
const { ip, user, password } = server;
try {
await this.transferScriptsFolder(server, onData, onError);
return new Promise((resolve, reject) => {
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
// Use ptySpawn for proper terminal emulation and color support
const sshCommand = ptySpawn('sshpass', [
'-p', password,
'ssh',
'-t',
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=yes',
'-o', 'PubkeyAuthentication=no',
'-o', 'RequestTTY=yes',
'-o', 'SetEnv=TERM=xterm-256color',
'-o', 'SetEnv=COLUMNS=120',
'-o', 'SetEnv=LINES=30',
'-o', 'SetEnv=COLORTERM=truecolor',
'-o', 'SetEnv=FORCE_COLOR=1',
'-o', 'SetEnv=NO_COLOR=0',
'-o', 'SetEnv=CLICOLOR=1',
'-o', 'SetEnv=CLICOLOR_FORCE=1',
`${user}@${ip}`,
`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`
], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
TERM: 'xterm-256color',
COLUMNS: '120',
LINES: '30',
SHELL: '/bin/bash',
COLORTERM: 'truecolor',
FORCE_COLOR: '1',
NO_COLOR: '0',
CLICOLOR: '1',
CLICOLOR_FORCE: '1'
}
});
// Use pty's onData method which handles both stdout and stderr combined
sshCommand.onData((data) => {
// pty handles encoding automatically and preserves ANSI codes
onData(data);
});
sshCommand.onExit((e) => {
onExit(e.exitCode);
});
resolve({
process: sshCommand,
kill: () => sshCommand.kill('SIGTERM')
});
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
onError(`SSH execution failed: ${errorMessage}`);
throw error;
}
}
/**
* Transfer the entire scripts folder to the remote server
* @param {Server} server - Server configuration
* @param {Function} onData - Callback for data output
* @param {Function} onError - Callback for errors
* @returns {Promise<void>}
*/
async transferScriptsFolder(server, onData, onError) {
const { ip, user, password } = server;
return new Promise((resolve, reject) => {
const rsyncCommand = spawn('rsync', [
'-avz',
'--delete',
'--exclude=*.log',
'--exclude=*.tmp',
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
'scripts/',
`${user}@${ip}:/tmp/scripts/`
], {
stdio: ['pipe', 'pipe', 'pipe']
});
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
// Ensure proper UTF-8 encoding for ANSI colors
const output = data.toString('utf8');
onData(output);
});
rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => {
// Ensure proper UTF-8 encoding for ANSI colors
const output = data.toString('utf8');
onError(output);
});
rsyncCommand.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`rsync failed with code ${code}`));
}
});
rsyncCommand.on('error', (error) => {
reject(error);
});
});
}
}
// Singleton instance
/** @type {SSHExecutionService | null} */
let sshExecutionInstance = null;
export function getSSHExecutionService() {
if (!sshExecutionInstance) {
sshExecutionInstance = new SSHExecutionService();
}
return sshExecutionInstance;
}
export default SSHExecutionService;

539
src/server/ssh-service.js Normal file
View File

@@ -0,0 +1,539 @@
import { spawn } from 'child_process';
import { writeFileSync, unlinkSync, chmodSync } from 'fs';
import { join } from 'path';
class SSHService {
/**
* Test SSH connection with actual login verification
* This method tests if the user can actually log in with the provided credentials
* @param {import('../types/server').Server} server - Server configuration
* @returns {Promise<Object>} Connection test result
*/
async testConnection(server) {
const { ip, user, password } = server;
return new Promise((resolve) => {
const timeout = 15000; // 15 seconds timeout for login test
let resolved = false;
// Try sshpass first if available
this.testWithSshpass(server).then(result => {
if (!resolved) {
resolved = true;
resolve(result);
}
}).catch(() => {
// If sshpass fails, try expect
this.testWithExpect(server).then(result => {
if (!resolved) {
resolved = true;
resolve(result);
}
}).catch(() => {
// If both fail, return error
if (!resolved) {
resolved = true;
resolve({
success: false,
message: 'SSH login test requires sshpass or expect - neither available or working',
details: {
method: 'no_auth_tools'
}
});
}
});
});
// Set up overall timeout
setTimeout(() => {
if (!resolved) {
resolved = true;
resolve({
success: false,
message: 'SSH login test timeout - server did not respond within 15 seconds',
details: { timeout: true, method: 'ssh_login_test' }
});
}
}, timeout);
});
}
/**
* Test SSH connection using sshpass
* @param {import('../types/server').Server} server - Server configuration
* @returns {Promise<Object>} Connection test result
*/
async testWithSshpass(server) {
const { ip, user, password } = server;
return new Promise((resolve, reject) => {
const timeout = 10000;
let resolved = false;
const sshCommand = spawn('sshpass', [
'-p', password,
'ssh',
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=yes',
'-o', 'PubkeyAuthentication=no',
`${user}@${ip}`,
'echo "SSH_LOGIN_SUCCESS"'
], {
stdio: ['pipe', 'pipe', 'pipe']
});
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
sshCommand.kill('SIGTERM');
reject(new Error('SSH login timeout'));
}
}, timeout);
let output = '';
let errorOutput = '';
sshCommand.stdout.on('data', (data) => {
output += data.toString();
});
sshCommand.stderr.on('data', (data) => {
errorOutput += data.toString();
});
sshCommand.on('close', (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) {
resolve({
success: true,
message: 'SSH login successful - credentials verified',
details: {
server: server.name || 'Unknown',
ip: ip,
user: user,
method: 'sshpass_verified'
}
});
} else {
let errorMessage = 'SSH login failed';
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
errorMessage = 'Authentication failed - check username and password';
} else if (errorOutput.includes('Connection refused')) {
errorMessage = 'Connection refused - server may be down or SSH not running';
} else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) {
errorMessage = 'Host not found - check IP address';
} else if (errorOutput.includes('Connection timed out')) {
errorMessage = 'Connection timeout - server may be unreachable';
} else {
errorMessage = `SSH login failed: ${errorOutput.trim()}`;
}
reject(new Error(errorMessage));
}
}
});
sshCommand.on('error', (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
reject(error);
}
});
});
}
/**
* Test SSH connection using expect
* @param {import('../types/server').Server} server - Server configuration
* @returns {Promise<Object>} Connection test result
*/
async testWithExpect(server) {
const { ip, user, password } = server;
return new Promise((resolve, reject) => {
const timeout = 10000;
let resolved = false;
const expectScript = `#!/usr/bin/expect -f
set timeout 10
spawn ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
expect {
"password:" {
send "${password}\r"
exp_continue
}
"Password:" {
send "${password}\r"
exp_continue
}
"SSH_LOGIN_SUCCESS" {
exit 0
}
timeout {
exit 1
}
eof {
exit 1
}
}`;
const expectCommand = spawn('expect', ['-c', expectScript], {
stdio: ['pipe', 'pipe', 'pipe']
});
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
expectCommand.kill('SIGTERM');
reject(new Error('SSH login timeout'));
}
}, timeout);
let output = '';
let errorOutput = '';
expectCommand.stdout.on('data', (data) => {
output += data.toString();
});
expectCommand.stderr.on('data', (data) => {
errorOutput += data.toString();
});
expectCommand.on('close', (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
if (code === 0) {
resolve({
success: true,
message: 'SSH login successful - credentials verified',
details: {
server: server.name || 'Unknown',
ip: ip,
user: user,
method: 'expect_verified'
}
});
} else {
let errorMessage = 'SSH login failed';
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
errorMessage = 'Authentication failed - check username and password';
} else if (errorOutput.includes('Connection refused')) {
errorMessage = 'Connection refused - server may be down or SSH not running';
} else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) {
errorMessage = 'Host not found - check IP address';
} else if (errorOutput.includes('Connection timed out')) {
errorMessage = 'Connection timeout - server may be unreachable';
} else {
errorMessage = `SSH login failed: ${errorOutput.trim()}`;
}
reject(new Error(errorMessage));
}
}
});
expectCommand.on('error', (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
reject(error);
}
});
});
}
/**
* Test SSH connection using basic connectivity check (fallback method)
* This method tests if the SSH port is open and reachable
* @param {import('../types/server').Server} server - Server configuration
* @returns {Promise<Object>} Connection test result
*/
async testConnectionBasic(server) {
const { ip, user, password } = server;
return new Promise((resolve) => {
const timeout = 10000; // 10 seconds timeout
let resolved = false;
// First, test if the SSH port is open using netcat or telnet
const portTestCommand = spawn('nc', ['-z', '-w', '5', ip, '22'], {
stdio: ['pipe', 'pipe', 'pipe']
});
// Set up timeout
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
portTestCommand.kill('SIGTERM');
resolve({
success: false,
message: 'Connection timeout - server did not respond within 10 seconds',
details: { timeout: true, method: 'port_check' }
});
}
}, timeout);
// Handle port test results
portTestCommand.on('close', (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
if (code === 0) {
// Port is open, now try a basic SSH connection test
this.testSSHConnection(server).then(resolve).catch(() => {
resolve({
success: false,
message: 'SSH port is open but connection failed - check credentials',
details: {
portOpen: true,
method: 'ssh_connection_test'
}
});
});
} else {
resolve({
success: false,
message: 'SSH port (22) is not accessible - server may be down or SSH not running',
details: {
portOpen: false,
exitCode: code,
method: 'port_check'
}
});
}
}
});
// Handle port test errors
portTestCommand.on('error', (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
// If netcat is not available, try with telnet
this.testWithTelnet(server).then(resolve).catch(() => {
resolve({
success: false,
message: 'Cannot test SSH connectivity - netcat and telnet not available',
details: {
error: error.message,
method: 'port_check_fallback'
}
});
});
}
});
});
}
/**
* Test SSH connection using telnet as fallback
* @param {import('../types/server').Server} server - Server configuration
* @returns {Promise<Object>} Connection test result
*/
async testWithTelnet(server) {
const { ip } = server;
return new Promise((resolve) => {
const timeout = 5000;
let resolved = false;
const telnetCommand = spawn('timeout', ['5', 'telnet', ip, '22'], {
stdio: ['pipe', 'pipe', 'pipe']
});
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
telnetCommand.kill('SIGTERM');
resolve({
success: false,
message: 'SSH port test timeout',
details: { method: 'telnet_timeout' }
});
}
}, timeout);
let output = '';
telnetCommand.stdout.on('data', (data) => {
output += data.toString();
});
telnetCommand.stderr.on('data', (data) => {
output += data.toString();
});
telnetCommand.on('close', (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
if (output.includes('Connected') || output.includes('SSH')) {
resolve({
success: true,
message: 'SSH port is accessible - basic connectivity confirmed',
details: {
portOpen: true,
method: 'telnet_test'
}
});
} else {
resolve({
success: false,
message: 'SSH port is not accessible',
details: {
portOpen: false,
output: output.trim(),
method: 'telnet_test'
}
});
}
}
});
telnetCommand.on('error', (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
resolve({
success: false,
message: 'Cannot test SSH connectivity - required tools not available',
details: {
error: error.message,
method: 'telnet_error'
}
});
}
});
});
}
/**
* Test actual SSH connection (without password authentication)
* @param {import('../types/server').Server} server - Server configuration
* @returns {Promise<Object>} Connection test result
*/
async testSSHConnection(server) {
const { ip, user } = server;
return new Promise((resolve) => {
const timeout = 5000;
let resolved = false;
const sshCommand = spawn('ssh', [
'-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=no',
'-o', 'PubkeyAuthentication=no',
'-o', 'PreferredAuthentications=none',
`${user}@${ip}`,
'exit'
], {
stdio: ['pipe', 'pipe', 'pipe']
});
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
sshCommand.kill('SIGTERM');
resolve({
success: false,
message: 'SSH connection timeout',
details: { method: 'ssh_timeout' }
});
}
}, timeout);
let errorOutput = '';
sshCommand.stderr.on('data', (data) => {
errorOutput += data.toString();
});
sshCommand.on('close', (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
// SSH connection was established but authentication failed
// This is actually a good sign - it means SSH is working
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
resolve({
success: true,
message: 'SSH service is running and accessible - authentication required',
details: {
server: server.name || 'Unknown',
ip: ip,
user: user,
method: 'ssh_auth_required'
}
});
} else if (errorOutput.includes('Connection refused')) {
resolve({
success: false,
message: 'SSH connection refused - service may not be running',
details: {
error: errorOutput.trim(),
method: 'ssh_connection_refused'
}
});
} else {
resolve({
success: false,
message: `SSH connection failed: ${errorOutput.trim()}`,
details: {
error: errorOutput.trim(),
method: 'ssh_connection_failed'
}
});
}
}
});
sshCommand.on('error', (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
resolve({
success: false,
message: `SSH command failed: ${error.message}`,
details: {
error: error.message,
method: 'ssh_command_error'
}
});
}
});
});
}
}
// Singleton instance
/** @type {SSHService | null} */
let sshInstance = null;
export function getSSHService() {
if (!sshInstance) {
sshInstance = new SSHService();
}
return sshInstance;
}
export default SSHService;

21
src/types/server.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface Server {
id: number;
name: string;
ip: string;
user: string;
password: string;
created_at: string;
updated_at: string;
}
export interface CreateServerData {
name: string;
ip: string;
user: string;
password: string;
}
export interface UpdateServerData extends CreateServerData {
id: number;
}