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:
committed by
GitHub
parent
2f1df95d90
commit
433d8121e8
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@
|
||||
/prisma/db.sqlite
|
||||
/prisma/db.sqlite-journal
|
||||
db.sqlite
|
||||
data/settings.db
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
265
README.md
265
README.md
@@ -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.
|
||||
|
||||
16
install.sh
16
install.sh
@@ -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
444
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
136
server.js
@@ -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
|
||||
*/
|
||||
|
||||
246
src/app/_components/ExecutionModeModal.tsx
Normal file
246
src/app/_components/ExecutionModeModal.tsx
Normal 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 "{scriptName}"?
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
166
src/app/_components/ServerForm.tsx
Normal file
166
src/app/_components/ServerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
212
src/app/_components/ServerList.tsx
Normal file
212
src/app/_components/ServerList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
43
src/app/_components/SettingsButton.tsx
Normal file
43
src/app/_components/SettingsButton.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
196
src/app/_components/SettingsModal.tsx
Normal file
196
src/app/_components/SettingsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
143
src/app/api/servers/[id]/route.ts
Normal file
143
src/app/api/servers/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
44
src/app/api/servers/[id]/test-connection/route.ts
Normal file
44
src/app/api/servers/[id]/test-connection/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
60
src/app/api/servers/route.ts
Normal file
60
src/app/api/servers/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
100
src/server/database.js
Normal 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;
|
||||
|
||||
157
src/server/ssh-execution-service.js
Normal file
157
src/server/ssh-execution-service.js
Normal 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
539
src/server/ssh-service.js
Normal 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
21
src/types/server.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user