Compare commits
1 Commits
hotfix/env
...
fix/counte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e82a832ba7 |
@@ -25,5 +25,4 @@ AUTH_USERNAME=
|
||||
AUTH_PASSWORD_HASH=
|
||||
AUTH_ENABLED=false
|
||||
AUTH_SETUP_COMPLETED=false
|
||||
JWT_SECRET=
|
||||
DATABASE_URL="file:./data/database.sqlite"
|
||||
JWT_SECRET=
|
||||
3
.github/release-drafter.yml
vendored
3
.github/release-drafter.yml
vendored
@@ -7,9 +7,6 @@ exclude-labels:
|
||||
- automated
|
||||
|
||||
categories:
|
||||
- title: "Breaking Changes"
|
||||
labels:
|
||||
- breaking
|
||||
- title: "🚀 Features"
|
||||
labels:
|
||||
- feature
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -16,9 +16,6 @@
|
||||
db.sqlite
|
||||
data/settings.db
|
||||
|
||||
# ssh keys (sensitive)
|
||||
data/ssh-keys/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
@@ -49,5 +46,4 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
# idea files
|
||||
.idea
|
||||
/generated/prisma
|
||||
.idea
|
||||
243
README.md
243
README.md
@@ -210,249 +210,6 @@ The application uses SQLite for storing server configurations:
|
||||
- **Backup**: Copy `data/settings.db` to backup your server configurations
|
||||
- **Reset**: Delete `data/settings.db` to reset all server configurations
|
||||
|
||||
## 📖 Feature Guide
|
||||
|
||||
This section provides detailed information about the application's key features and how to use them effectively.
|
||||
|
||||
### Server Settings
|
||||
|
||||
Manage your Proxmox VE servers and configure connection settings.
|
||||
|
||||
**Adding PVE Servers:**
|
||||
- **Server Name**: A friendly name to identify your server
|
||||
- **IP Address**: The IP address or hostname of your PVE server
|
||||
- **Username**: PVE user account (usually root or a dedicated user)
|
||||
- **SSH Port**: Default is 22, change if your server uses a different port
|
||||
|
||||
**Authentication Types:**
|
||||
- **Password**: Use username and password authentication
|
||||
- **SSH Key**: Use SSH key pair for secure authentication
|
||||
- **Both**: Try SSH key first, fallback to password if needed
|
||||
|
||||
**Server Color Coding:**
|
||||
Assign colors to servers for visual distinction throughout the application. This helps identify which server you're working with when managing scripts. This needs to be enabled in the General Settings.
|
||||
|
||||
### General Settings
|
||||
|
||||
Configure application preferences and behavior.
|
||||
|
||||
**Save Filters:**
|
||||
When enabled, your script filter preferences (search terms, categories, sorting) will be automatically saved and restored when you return to the application:
|
||||
- Search queries are preserved
|
||||
- Selected script types are remembered
|
||||
- Sort preferences are maintained
|
||||
- Category selections are saved
|
||||
|
||||
**Server Color Coding:**
|
||||
Enable visual color coding for servers throughout the application. This makes it easier to identify which server you're working with.
|
||||
|
||||
**GitHub Integration:**
|
||||
Add a GitHub Personal Access Token to increase API rate limits and improve performance:
|
||||
- Bypasses GitHub's rate limiting for unauthenticated requests
|
||||
- Improves script loading and syncing performance
|
||||
- Token is stored securely and only used for API calls
|
||||
|
||||
**Authentication:**
|
||||
Secure your application with username and password authentication:
|
||||
- Set up username and password for app access
|
||||
- Enable/disable authentication as needed
|
||||
- Credentials are stored securely
|
||||
|
||||
### Sync Button
|
||||
|
||||
Synchronize script metadata from the ProxmoxVE GitHub repository.
|
||||
|
||||
**What Does Syncing Do?**
|
||||
- **Updates Script Metadata**: Downloads the latest script information (JSON files)
|
||||
- **Refreshes Available Scripts**: Updates the list of scripts you can download
|
||||
- **Updates Categories**: Refreshes script categories and organization
|
||||
- **Checks for Updates**: Identifies which downloaded scripts have newer versions
|
||||
|
||||
**Important Notes:**
|
||||
- **Metadata Only**: Syncing only updates script information, not the actual script files
|
||||
- **No Downloads**: Script files are downloaded separately when you choose to install them
|
||||
- **Last Sync Time**: Shows when the last successful sync occurred
|
||||
- **Rate Limits**: GitHub API limits may apply without a personal access token
|
||||
|
||||
**When to Sync:**
|
||||
- When you want to see the latest available scripts
|
||||
- To check for updates to your downloaded scripts
|
||||
- If you notice scripts are missing or outdated
|
||||
- After the ProxmoxVE repository has been updated
|
||||
|
||||
### Available Scripts
|
||||
|
||||
Browse and discover scripts from the ProxmoxVE repository.
|
||||
|
||||
**Browsing Scripts:**
|
||||
- **Category Sidebar**: Filter scripts by category (Storage, Network, Security, etc.)
|
||||
- **Search**: Find scripts by name or description
|
||||
- **View Modes**: Switch between card and list view
|
||||
- **Sorting**: Sort by name or creation date
|
||||
|
||||
**Filtering Options:**
|
||||
- **Script Types**: Filter by CT (Container) or other script types
|
||||
- **Update Status**: Show only scripts with available updates
|
||||
- **Search Query**: Search within script names and descriptions
|
||||
- **Categories**: Filter by specific script categories
|
||||
|
||||
**Script Actions:**
|
||||
- **View Details**: Click on a script to see full information and documentation
|
||||
- **Download**: Download script files to your local system
|
||||
- **Install**: Run scripts directly on your PVE servers
|
||||
- **Preview**: View script content before downloading
|
||||
|
||||
### Downloaded Scripts
|
||||
|
||||
Manage scripts that have been downloaded to your local system.
|
||||
|
||||
**What Are Downloaded Scripts?**
|
||||
These are scripts that you've downloaded from the repository and are stored locally on your system:
|
||||
- Script files are stored in your local scripts directory
|
||||
- You can run these scripts on your PVE servers
|
||||
- Scripts can be updated when newer versions are available
|
||||
|
||||
**Update Detection:**
|
||||
The system automatically checks if newer versions of your downloaded scripts are available:
|
||||
- Scripts with updates available are marked with an update indicator
|
||||
- You can filter to show only scripts with available updates
|
||||
- Update detection happens when you sync with the repository
|
||||
|
||||
**Managing Downloaded Scripts:**
|
||||
- **Update Scripts**: Download the latest version of a script
|
||||
- **View Details**: See script information and documentation
|
||||
- **Install/Run**: Execute scripts on your PVE servers
|
||||
- **Filter & Search**: Use the same filtering options as Available Scripts
|
||||
|
||||
### Installed Scripts
|
||||
|
||||
Track and manage scripts that are installed on your PVE servers.
|
||||
|
||||
**Auto-Detection (Primary Feature):**
|
||||
The system can automatically detect LXC containers that have community-script tags on your PVE servers:
|
||||
- **Automatic Discovery**: Scans your PVE servers for containers with community-script tags
|
||||
- **Container Detection**: Identifies LXC containers running Proxmox helper scripts
|
||||
- **Server Association**: Links detected scripts to the specific PVE server
|
||||
- **Bulk Import**: Automatically creates records for all detected scripts
|
||||
|
||||
**How Auto-Detection Works:**
|
||||
1. Connects to your configured PVE servers
|
||||
2. Scans LXC container configurations
|
||||
3. Looks for containers with community-script tags
|
||||
4. Creates installed script records automatically
|
||||
|
||||
**Manual Script Management:**
|
||||
- **Add Scripts Manually**: Create records for scripts not auto-detected
|
||||
- **Edit Script Details**: Update script names and container IDs
|
||||
- **Delete Scripts**: Remove scripts from tracking
|
||||
- **Bulk Operations**: Clean up old or invalid script records
|
||||
|
||||
**Script Tracking Features:**
|
||||
- **Installation Status**: Track success, failure, or in-progress installations
|
||||
- **Server Association**: Know which server each script is installed on
|
||||
- **Container ID**: Link scripts to specific LXC containers
|
||||
- **Web UI Access**: Track and access Web UI IP addresses and ports
|
||||
- **Execution Logs**: View output and logs from script installations
|
||||
- **Filtering**: Filter by server, status, or search terms
|
||||
|
||||
**Managing Installed Scripts:**
|
||||
- **View All Scripts**: See all tracked scripts across all servers
|
||||
- **Filter by Server**: Show scripts for a specific PVE server
|
||||
- **Filter by Status**: Show successful, failed, or in-progress installations
|
||||
- **Sort Options**: Sort by name, container ID, server, status, or date
|
||||
- **Update Scripts**: Re-run or update existing script installations
|
||||
|
||||
**Web UI Access:**
|
||||
Automatically detect and access Web UI interfaces for your installed scripts:
|
||||
- **Auto-Detection**: Automatically detects Web UI URLs from script installation output
|
||||
- **IP & Port Tracking**: Stores and displays Web UI IP addresses and ports
|
||||
- **One-Click Access**: Click IP:port to open Web UI in new tab
|
||||
- **Manual Detection**: Re-detect IP using `hostname -I` inside container
|
||||
- **Port Detection**: Uses script metadata to get correct port (e.g., actualbudget:5006)
|
||||
- **Editable Fields**: Manually edit IP and port values as needed
|
||||
|
||||
**Actions Dropdown:**
|
||||
Clean interface with all actions organized in a dropdown menu:
|
||||
- **Edit Button**: Always visible for quick script editing
|
||||
- **Actions Dropdown**: Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete
|
||||
- **Smart Visibility**: Dropdown only appears when actions are available
|
||||
- **Auto-Close**: Dropdown closes after clicking any action
|
||||
- **Disabled States**: Actions are disabled when container is stopped
|
||||
|
||||
**Container Control:**
|
||||
Directly control LXC containers from the installed scripts page via SSH:
|
||||
- **Start/Stop Button**: Control container state with `pct start/stop <ID>`
|
||||
- **Container Status**: Real-time status indicator (running/stopped/unknown)
|
||||
- **Destroy Button**: Permanently remove LXC container with `pct destroy <ID>`
|
||||
- **Confirmation Modals**: Simple OK/Cancel for start/stop, type container ID to confirm destroy
|
||||
- **SSH Execution**: All commands executed remotely via configured SSH connections
|
||||
|
||||
**Safety Features:**
|
||||
- Start/Stop actions require simple confirmation
|
||||
- Destroy action requires typing the container ID to confirm
|
||||
- All actions show loading states and error handling
|
||||
- Only works with SSH scripts that have valid container IDs
|
||||
|
||||
### Update System
|
||||
|
||||
Keep your PVE Scripts Management application up to date with the latest features and improvements.
|
||||
|
||||
**What Does Updating Do?**
|
||||
- **Downloads Latest Version**: Fetches the newest release from the GitHub repository
|
||||
- **Updates Application Files**: Replaces current files with the latest version
|
||||
- **Installs Dependencies**: Updates Node.js packages and dependencies
|
||||
- **Rebuilds Application**: Compiles the application with latest changes
|
||||
- **Restarts Server**: Automatically restarts the application server
|
||||
|
||||
**How to Update:**
|
||||
|
||||
**Automatic Update (Recommended):**
|
||||
- Click the "Update Now" button when an update is available
|
||||
- The system will handle everything automatically
|
||||
- You'll see a progress overlay with update logs
|
||||
- The page will reload automatically when complete
|
||||
|
||||
**Manual Update (Advanced):**
|
||||
If automatic update fails, you can update manually:
|
||||
```bash
|
||||
# Navigate to the application directory
|
||||
cd $PVESCRIPTLOCAL_DIR
|
||||
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build the application
|
||||
npm run build
|
||||
|
||||
# Start the application
|
||||
npm start
|
||||
```
|
||||
|
||||
**Update Process:**
|
||||
1. **Check for Updates**: System automatically checks GitHub for new releases
|
||||
2. **Download Update**: Downloads the latest release files
|
||||
3. **Backup Current Version**: Creates backup of current installation
|
||||
4. **Install New Version**: Replaces files and updates dependencies
|
||||
5. **Build Application**: Compiles the updated code
|
||||
6. **Restart Server**: Stops old server and starts new version
|
||||
7. **Reload Page**: Automatically refreshes the browser
|
||||
|
||||
**Release Notes:**
|
||||
Click the external link icon next to the update button to view detailed release notes on GitHub:
|
||||
- See what's new in each version
|
||||
- Read about bug fixes and improvements
|
||||
- Check for any breaking changes
|
||||
- View installation requirements
|
||||
|
||||
**Important Notes:**
|
||||
- **Backup**: Your data and settings are preserved during updates
|
||||
- **Downtime**: Brief downtime occurs during the update process
|
||||
- **Compatibility**: Updates maintain backward compatibility with your data
|
||||
- **Rollback**: If issues occur, you can manually revert to previous version
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
|
||||
2860
package-lock.json
generated
2860
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -22,12 +22,9 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.17.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
@@ -37,18 +34,17 @@
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "^15.5.5",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"refractor": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"superjson": "^2.2.1",
|
||||
@@ -65,19 +61,18 @@
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.8.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-next": "^15.5.5",
|
||||
"eslint-config-next": "^15.5.4",
|
||||
"jsdom": "^27.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.0",
|
||||
"prisma": "^6.17.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model InstalledScript {
|
||||
id Int @id @default(autoincrement())
|
||||
script_name String
|
||||
script_path String
|
||||
container_id String?
|
||||
server_id Int?
|
||||
execution_mode String
|
||||
installation_date DateTime? @default(now())
|
||||
status String
|
||||
output_log String?
|
||||
web_ui_ip String?
|
||||
web_ui_port Int?
|
||||
server Server? @relation(fields: [server_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("installed_scripts")
|
||||
}
|
||||
|
||||
model Server {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
ip String
|
||||
user String
|
||||
password String?
|
||||
auth_type String? @default("password")
|
||||
ssh_key String?
|
||||
ssh_key_passphrase String?
|
||||
ssh_port Int? @default(22)
|
||||
color String?
|
||||
created_at DateTime? @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
ssh_key_path String?
|
||||
key_generated Boolean? @default(false)
|
||||
installed_scripts InstalledScript[]
|
||||
|
||||
@@map("servers")
|
||||
}
|
||||
651
scripts/vm/openwrt-vm.sh
Normal file
651
scripts/vm/openwrt-vm.sh
Normal file
@@ -0,0 +1,651 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# Jon Spriggs (jontheniceguy)
|
||||
# License: MIT
|
||||
# https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Based on work from https://i12bretro.github.io/tutorials/0405.html
|
||||
|
||||
source /dev/stdin <<<$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/api.func)
|
||||
|
||||
function header_info {
|
||||
clear
|
||||
cat <<"EOF"
|
||||
____ _ __ __
|
||||
/ __ \____ ___ ____| | / /____/ /_
|
||||
/ / / / __ \/ _ \/ __ \ | /| / / ___/ __/
|
||||
/ /_/ / /_/ / __/ / / / |/ |/ / / / /_
|
||||
\____/ .___/\___/_/ /_/|__/|__/_/ \__/
|
||||
/_/ W I R E L E S S F R E E D O M
|
||||
|
||||
EOF
|
||||
}
|
||||
header_info
|
||||
echo -e "\n Loading..."
|
||||
RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)"
|
||||
METHOD=""
|
||||
NSAPP="openwrt-vm"
|
||||
var_os="openwrt"
|
||||
var_version=" "
|
||||
DISK_SIZE="1G"
|
||||
GEN_MAC=02:$(openssl rand -hex 5 | awk '{print toupper($0)}' | sed 's/\(..\)/\1:/g; s/.$//')
|
||||
GEN_MAC_LAN=02:$(openssl rand -hex 5 | awk '{print toupper($0)}' | sed 's/\(..\)/\1:/g; s/.$//')
|
||||
|
||||
YW=$(echo "\033[33m")
|
||||
BL=$(echo "\033[36m")
|
||||
HA=$(echo "\033[1;34m")
|
||||
RD=$(echo "\033[01;31m")
|
||||
BGN=$(echo "\033[4;92m")
|
||||
GN=$(echo "\033[1;92m")
|
||||
DGN=$(echo "\033[32m")
|
||||
CL=$(echo "\033[m")
|
||||
|
||||
BOLD=$(echo "\033[1m")
|
||||
BFR="\\r\\033[K"
|
||||
HOLD=" "
|
||||
TAB=" "
|
||||
|
||||
CM="${TAB}✔️${TAB}${CL}"
|
||||
CROSS="${TAB}✖️${TAB}${CL}"
|
||||
INFO="${TAB}💡${TAB}${CL}"
|
||||
OS="${TAB}🖥️${TAB}${CL}"
|
||||
CONTAINERTYPE="${TAB}📦${TAB}${CL}"
|
||||
DISKSIZE="${TAB}💾${TAB}${CL}"
|
||||
CPUCORE="${TAB}🧠${TAB}${CL}"
|
||||
RAMSIZE="${TAB}🛠️${TAB}${CL}"
|
||||
CONTAINERID="${TAB}🆔${TAB}${CL}"
|
||||
HOSTNAME="${TAB}🏠${TAB}${CL}"
|
||||
BRIDGE="${TAB}🌉${TAB}${CL}"
|
||||
GATEWAY="${TAB}🌐${TAB}${CL}"
|
||||
DEFAULT="${TAB}⚙️${TAB}${CL}"
|
||||
MACADDRESS="${TAB}🔗${TAB}${CL}"
|
||||
VLANTAG="${TAB}🏷️${TAB}${CL}"
|
||||
CREATING="${TAB}🚀${TAB}${CL}"
|
||||
ADVANCED="${TAB}🧩${TAB}${CL}"
|
||||
CLOUD="${TAB}☁️${TAB}${CL}"
|
||||
|
||||
set -Eeo pipefail
|
||||
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
||||
trap cleanup EXIT
|
||||
trap 'post_update_to_api "failed" "INTERRUPTED"' SIGINT
|
||||
trap 'post_update_to_api "failed" "TERMINATED"' SIGTERM
|
||||
function error_handler() {
|
||||
local exit_code="$?"
|
||||
local line_number="$1"
|
||||
local command="$2"
|
||||
post_update_to_api "failed" "$command"
|
||||
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
|
||||
echo -e "\n$error_message\n"
|
||||
cleanup_vmid
|
||||
}
|
||||
|
||||
function get_valid_nextid() {
|
||||
local try_id
|
||||
try_id=$(pvesh get /cluster/nextid)
|
||||
while true; do
|
||||
if [ -f "/etc/pve/qemu-server/${try_id}.conf" ] || [ -f "/etc/pve/lxc/${try_id}.conf" ]; then
|
||||
try_id=$((try_id + 1))
|
||||
continue
|
||||
fi
|
||||
if lvs --noheadings -o lv_name | grep -qE "(^|[-_])${try_id}($|[-_])"; then
|
||||
try_id=$((try_id + 1))
|
||||
continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
echo "$try_id"
|
||||
}
|
||||
|
||||
function cleanup_vmid() {
|
||||
if qm status $VMID &>/dev/null; then
|
||||
qm stop $VMID &>/dev/null
|
||||
qm destroy $VMID &>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
popd >/dev/null
|
||||
rm -rf $TEMP_DIR
|
||||
}
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
pushd $TEMP_DIR >/dev/null
|
||||
function send_line_to_vm() {
|
||||
echo -e "${DGN}Sending line: ${YW}$1${CL}"
|
||||
for ((i = 0; i < ${#1}; i++)); do
|
||||
character=${1:i:1}
|
||||
case $character in
|
||||
" ") character="spc" ;;
|
||||
"-") character="minus" ;;
|
||||
"=") character="equal" ;;
|
||||
",") character="comma" ;;
|
||||
".") character="dot" ;;
|
||||
"/") character="slash" ;;
|
||||
"'") character="apostrophe" ;;
|
||||
";") character="semicolon" ;;
|
||||
'\') character="backslash" ;;
|
||||
'`') character="grave_accent" ;;
|
||||
"[") character="bracket_left" ;;
|
||||
"]") character="bracket_right" ;;
|
||||
"_") character="shift-minus" ;;
|
||||
"+") character="shift-equal" ;;
|
||||
"?") character="shift-slash" ;;
|
||||
"<") character="shift-comma" ;;
|
||||
">") character="shift-dot" ;;
|
||||
'"') character="shift-apostrophe" ;;
|
||||
":") character="shift-semicolon" ;;
|
||||
"|") character="shift-backslash" ;;
|
||||
"~") character="shift-grave_accent" ;;
|
||||
"{") character="shift-bracket_left" ;;
|
||||
"}") character="shift-bracket_right" ;;
|
||||
"A") character="shift-a" ;;
|
||||
"B") character="shift-b" ;;
|
||||
"C") character="shift-c" ;;
|
||||
"D") character="shift-d" ;;
|
||||
"E") character="shift-e" ;;
|
||||
"F") character="shift-f" ;;
|
||||
"G") character="shift-g" ;;
|
||||
"H") character="shift-h" ;;
|
||||
"I") character="shift-i" ;;
|
||||
"J") character="shift-j" ;;
|
||||
"K") character="shift-k" ;;
|
||||
"L") character="shift-l" ;;
|
||||
"M") character="shift-m" ;;
|
||||
"N") character="shift-n" ;;
|
||||
"O") character="shift-o" ;;
|
||||
"P") character="shift-p" ;;
|
||||
"Q") character="shift-q" ;;
|
||||
"R") character="shift-r" ;;
|
||||
"S") character="shift-s" ;;
|
||||
"T") character="shift-t" ;;
|
||||
"U") character="shift-u" ;;
|
||||
"V") character="shift-v" ;;
|
||||
"W") character="shift-w" ;;
|
||||
"X") character="shift=x" ;;
|
||||
"Y") character="shift-y" ;;
|
||||
"Z") character="shift-z" ;;
|
||||
"!") character="shift-1" ;;
|
||||
"@") character="shift-2" ;;
|
||||
"#") character="shift-3" ;;
|
||||
'$') character="shift-4" ;;
|
||||
"%") character="shift-5" ;;
|
||||
"^") character="shift-6" ;;
|
||||
"&") character="shift-7" ;;
|
||||
"*") character="shift-8" ;;
|
||||
"(") character="shift-9" ;;
|
||||
")") character="shift-0" ;;
|
||||
esac
|
||||
qm sendkey $VMID "$character"
|
||||
done
|
||||
qm sendkey $VMID ret
|
||||
}
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
pushd $TEMP_DIR >/dev/null
|
||||
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "OpenWrt VM" --yesno "This will create a New OpenWrt VM. Proceed?" 10 58); then
|
||||
:
|
||||
else
|
||||
header_info && echo -e "⚠ User exited script \n" && exit
|
||||
fi
|
||||
|
||||
function msg_info() {
|
||||
local msg="$1"
|
||||
echo -ne " ${HOLD} ${YW}${msg}..."
|
||||
}
|
||||
|
||||
function msg_ok() {
|
||||
local msg="$1"
|
||||
echo -e "${BFR} ${CM} ${GN}${msg}${CL}"
|
||||
}
|
||||
|
||||
function msg_error() {
|
||||
local msg="$1"
|
||||
echo -e "${BFR} ${CROSS} ${RD}${msg}${CL}"
|
||||
}
|
||||
|
||||
# This function checks the version of Proxmox Virtual Environment (PVE) and exits if the version is not supported.
|
||||
# Supported: Proxmox VE 8.0.x – 8.9.x and 9.0 (NOT 9.1+)
|
||||
pve_check() {
|
||||
local PVE_VER
|
||||
PVE_VER="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')"
|
||||
|
||||
# Check for Proxmox VE 8.x: allow 8.0–8.9
|
||||
if [[ "$PVE_VER" =~ ^8\.([0-9]+) ]]; then
|
||||
local MINOR="${BASH_REMATCH[1]}"
|
||||
if ((MINOR < 0 || MINOR > 9)); then
|
||||
msg_error "This version of Proxmox VE is not supported."
|
||||
msg_error "Supported: Proxmox VE version 8.0 – 8.9"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for Proxmox VE 9.x: allow ONLY 9.0
|
||||
if [[ "$PVE_VER" =~ ^9\.([0-9]+) ]]; then
|
||||
local MINOR="${BASH_REMATCH[1]}"
|
||||
if ((MINOR != 0)); then
|
||||
msg_error "This version of Proxmox VE is not yet supported."
|
||||
msg_error "Supported: Proxmox VE version 9.0"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# All other unsupported versions
|
||||
msg_error "This version of Proxmox VE is not supported."
|
||||
msg_error "Supported versions: Proxmox VE 8.0 – 8.x or 9.0"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function arch_check() {
|
||||
if [ "$(dpkg --print-architecture)" != "amd64" ]; then
|
||||
echo -e "\n ${CROSS} This script will not work with PiMox! \n"
|
||||
echo -e "Exiting..."
|
||||
sleep 2
|
||||
exit
|
||||
fi
|
||||
}
|
||||
|
||||
function ssh_check() {
|
||||
if command -v pveversion >/dev/null 2>&1; then
|
||||
if [ -n "${SSH_CLIENT:+x}" ]; then
|
||||
if whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "SSH DETECTED" --yesno "It's suggested to use the Proxmox shell instead of SSH, since SSH can create issues while gathering variables. Would you like to proceed with using SSH?" 10 62; then
|
||||
echo "you've been warned"
|
||||
else
|
||||
clear
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function exit-script() {
|
||||
clear
|
||||
echo -e "⚠ User exited script \n"
|
||||
exit
|
||||
}
|
||||
|
||||
function default_settings() {
|
||||
VMID=$(get_valid_nextid)
|
||||
HN="openwrt"
|
||||
CORE_COUNT="1"
|
||||
RAM_SIZE="256"
|
||||
BRG="vmbr0"
|
||||
LAN_BRG="vmbr0"
|
||||
MAC=$GEN_MAC
|
||||
LAN_MAC=$GEN_MAC_LAN
|
||||
VLAN=""
|
||||
LAN_VLAN=",tag=999"
|
||||
LAN_IP_ADDR="192.168.1.1"
|
||||
LAN_NETMASK="255.255.255.0"
|
||||
MTU=""
|
||||
START_VM="yes"
|
||||
METHOD="default"
|
||||
DISK_SIZE="1G"
|
||||
echo -e "${CONTAINERID}${BOLD}${DGN}VMID: ${BGN}${VMID}${CL}"
|
||||
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}${HN}${CL}"
|
||||
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}${CORE_COUNT}${CL}"
|
||||
echo -e "${RAMSIZE}${BOLD}${DGN}RAM: ${BGN}${RAM_SIZE}${CL}"
|
||||
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE}${CL}"
|
||||
echo -e "${BRIDGE}${BOLD}${DGN}WAN Bridge: ${BGN}${BRG}${CL}"
|
||||
echo -e "${BRIDGE}${BOLD}${DGN}LAN Bridge: ${BGN}${LAN_BRG}${CL}"
|
||||
echo -e "${MACADDRESS}${BOLD}${DGN}WAN MAC: ${BGN}${MAC}${CL}"
|
||||
echo -e "${MACADDRESS}${BOLD}${DGN}LAN MAC: ${BGN}${LAN_MAC}${CL}"
|
||||
}
|
||||
|
||||
function advanced_settings() {
|
||||
METHOD="advanced"
|
||||
[ -z "${VMID:-}" ] && VMID=$(get_valid_nextid)
|
||||
while true; do
|
||||
if VMID=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Virtual Machine ID" 8 58 $VMID --title "VIRTUAL MACHINE ID" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$VMID" ]; then
|
||||
VMID=$(get_valid_nextid)
|
||||
fi
|
||||
if pct status "$VMID" &>/dev/null || qm status "$VMID" &>/dev/null; then
|
||||
echo -e "${CROSS}${RD} ID $VMID is already in use${CL}"
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
echo -e "${DGN}Virtual Machine ID: ${BGN}$VMID${CL}"
|
||||
break
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
done
|
||||
|
||||
if VM_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 openwrt --title "HOSTNAME" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $VM_NAME ]; then
|
||||
HN="openwrt"
|
||||
else
|
||||
HN=$(echo ${VM_NAME,,} | tr -d ' ')
|
||||
fi
|
||||
echo -e "${DGN}Using Hostname: ${BGN}$HN${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if CORE_COUNT=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Allocate CPU Cores" 8 58 1 --title "CORE COUNT" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $CORE_COUNT ]; then
|
||||
CORE_COUNT="1"
|
||||
fi
|
||||
echo -e "${DGN}Allocated Cores: ${BGN}$CORE_COUNT${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if RAM_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Allocate RAM in MiB" 8 58 256 --title "RAM" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $RAM_SIZE ]; then
|
||||
RAM_SIZE="256"
|
||||
fi
|
||||
echo -e "${DGN}Allocated RAM: ${BGN}$RAM_SIZE${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if DISK_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
--inputbox "Set Disk Size in GiB (e.g., 1, 2, 4)" 8 58 "1" \
|
||||
--title "DISK SIZE" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [[ "$DISK_SIZE" =~ ^[0-9]+$ ]]; then
|
||||
DISK_SIZE="${DISK_SIZE}G"
|
||||
fi
|
||||
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE}${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if BRG=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a WAN Bridge" 8 58 vmbr0 --title "WAN BRIDGE" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $BRG ]; then
|
||||
BRG="vmbr0"
|
||||
fi
|
||||
echo -e "${DGN}Using WAN Bridge: ${BGN}$BRG${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if LAN_BRG=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a LAN Bridge" 8 58 vmbr0 --title "LAN BRIDGE" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $LAN_BRG ]; then
|
||||
LAN_BRG="vmbr0"
|
||||
fi
|
||||
echo -e "${DGN}Using LAN Bridge: ${BGN}$LAN_BRG${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if LAN_IP_ADDR=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a router IP" 8 58 $LAN_IP_ADDR --title "LAN IP ADDRESS" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $LAN_IP_ADDR ]; then
|
||||
LAN_IP_ADDR="192.168.1.1"
|
||||
fi
|
||||
echo -e "${DGN}Using LAN IP ADDRESS: ${BGN}$LAN_IP_ADDR${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if LAN_NETMASK=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a router netmask" 8 58 $LAN_NETMASK --title "LAN NETMASK" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $LAN_NETMASK ]; then
|
||||
LAN_NETMASK="255.255.255.0"
|
||||
fi
|
||||
echo -e "${DGN}Using LAN NETMASK: ${BGN}$LAN_NETMASK${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if MAC1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a WAN MAC Address" 8 58 $GEN_MAC --title "WAN MAC ADDRESS" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $MAC1 ]; then
|
||||
MAC="$GEN_MAC"
|
||||
else
|
||||
MAC="$MAC1"
|
||||
fi
|
||||
echo -e "${DGN}Using WAN MAC Address: ${BGN}$MAC${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if MAC2=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a LAN MAC Address" 8 58 $GEN_MAC_LAN --title "LAN MAC ADDRESS" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $MAC2 ]; then
|
||||
LAN_MAC="$GEN_MAC_LAN"
|
||||
else
|
||||
LAN_MAC="$MAC2"
|
||||
fi
|
||||
echo -e "${DGN}Using LAN MAC Address: ${BGN}$LAN_MAC${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if VLAN1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a WAN Vlan (leave blank for default)" 8 58 --title "WAN VLAN" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $VLAN1 ]; then
|
||||
VLAN1="Default"
|
||||
VLAN=""
|
||||
else
|
||||
VLAN=",tag=$VLAN1"
|
||||
fi
|
||||
echo -e "${DGN}Using WAN Vlan: ${BGN}$VLAN1${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if VLAN2=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a LAN Vlan" 8 58 999 --title "LAN VLAN" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $VLAN2 ]; then
|
||||
VLAN2="999"
|
||||
LAN_VLAN=",tag=$VLAN2"
|
||||
else
|
||||
LAN_VLAN=",tag=$VLAN2"
|
||||
fi
|
||||
echo -e "${DGN}Using LAN Vlan: ${BGN}$VLAN2${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if MTU1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Interface MTU Size (leave blank for default)" 8 58 --title "MTU SIZE" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||
if [ -z $MTU1 ]; then
|
||||
MTU1="Default"
|
||||
MTU=""
|
||||
else
|
||||
MTU=",mtu=$MTU1"
|
||||
fi
|
||||
echo -e "${DGN}Using Interface MTU Size: ${BGN}$MTU1${CL}"
|
||||
else
|
||||
exit-script
|
||||
fi
|
||||
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "START VIRTUAL MACHINE" --yesno "Start VM when completed?" 10 58); then
|
||||
START_VM="yes"
|
||||
else
|
||||
START_VM="no"
|
||||
fi
|
||||
echo -e "${DGN}Start VM when completed: ${BGN}$START_VM${CL}"
|
||||
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "ADVANCED SETTINGS COMPLETE" --yesno "Ready to create OpenWrt VM?" --no-button Do-Over 10 58); then
|
||||
echo -e "${RD}Creating a OpenWrt VM using the above advanced settings${CL}"
|
||||
else
|
||||
header_info
|
||||
echo -e "${RD}Using Advanced Settings${CL}"
|
||||
advanced_settings
|
||||
fi
|
||||
}
|
||||
|
||||
function start_script() {
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "SETTINGS" --yesno "Use Default Settings?" --no-button Advanced 10 58); then
|
||||
header_info
|
||||
echo -e "${BL}Using Default Settings${CL}"
|
||||
default_settings
|
||||
else
|
||||
header_info
|
||||
echo -e "${RD}Using Advanced Settings${CL}"
|
||||
advanced_settings
|
||||
fi
|
||||
}
|
||||
|
||||
arch_check
|
||||
pve_check
|
||||
ssh_check
|
||||
start_script
|
||||
post_to_api_vm
|
||||
|
||||
msg_info "Validating Storage"
|
||||
while read -r line; do
|
||||
TAG=$(echo $line | awk '{print $1}')
|
||||
TYPE=$(echo $line | awk '{printf "%-10s", $2}')
|
||||
FREE=$(echo $line | numfmt --field 4-6 --from-unit=K --to=iec --format %.2f | awk '{printf( "%9sB", $6)}')
|
||||
ITEM=" Type: $TYPE Free: $FREE "
|
||||
OFFSET=2
|
||||
if [[ $((${#ITEM} + $OFFSET)) -gt ${MSG_MAX_LENGTH:-} ]]; then
|
||||
MSG_MAX_LENGTH=$((${#ITEM} + $OFFSET))
|
||||
fi
|
||||
STORAGE_MENU+=("$TAG" "$ITEM" "OFF")
|
||||
done < <(pvesm status -content images | awk 'NR>1')
|
||||
VALID=$(pvesm status -content images | awk 'NR>1')
|
||||
if [ -z "$VALID" ]; then
|
||||
echo -e "\n${RD}⚠ Unable to detect a valid storage location.${CL}"
|
||||
echo -e "Exiting..."
|
||||
exit
|
||||
elif [ $((${#STORAGE_MENU[@]} / 3)) -eq 1 ]; then
|
||||
STORAGE=${STORAGE_MENU[0]}
|
||||
else
|
||||
while [ -z "${STORAGE:+x}" ]; do
|
||||
STORAGE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "Storage Pools" --radiolist \
|
||||
"Which storage pool would you like to use for the OpenWrt VM?\n\n" \
|
||||
16 $(($MSG_MAX_LENGTH + 23)) 6 \
|
||||
"${STORAGE_MENU[@]}" 3>&1 1>&2 2>&3)
|
||||
done
|
||||
fi
|
||||
msg_ok "Using ${CL}${BL}$STORAGE${CL} ${GN}for Storage Location."
|
||||
msg_ok "Virtual Machine ID is ${CL}${BL}$VMID${CL}."
|
||||
msg_info "Getting URL for OpenWrt Disk Image"
|
||||
|
||||
response=$(curl -fsSL https://openwrt.org)
|
||||
stableversion=$(echo "$response" | sed -n 's/.*Current stable release - OpenWrt \([0-9.]\+\).*/\1/p' | head -n 1)
|
||||
URL="https://downloads.openwrt.org/releases/$stableversion/targets/x86/64/openwrt-$stableversion-x86-64-generic-ext4-combined.img.gz"
|
||||
|
||||
msg_ok "${CL}${BL}${URL}${CL}"
|
||||
curl -f#SL -o "$(basename "$URL")" "$URL"
|
||||
FILE=$(basename "$URL")
|
||||
msg_ok "Downloaded ${CL}${BL}$FILE${CL}"
|
||||
|
||||
gunzip -f "$FILE" >/dev/null 2>&1 || true
|
||||
FILE="${FILE%.*}"
|
||||
msg_ok "Extracted OpenWrt Disk Image ${CL}${BL}$FILE${CL}"
|
||||
|
||||
msg_info "Creating OpenWrt VM"
|
||||
qm create "$VMID" -cores "$CORE_COUNT" -memory "$RAM_SIZE" -name "$HN" \
|
||||
-onboot 1 -ostype l26 -scsihw virtio-scsi-pci --tablet 0
|
||||
if [[ "$(pvesm status | awk -v s=$STORAGE '$1==s {print $2}')" == "dir" ]]; then
|
||||
qm set "$VMID" -efidisk0 "${STORAGE}:0,efitype=4m,size=4M"
|
||||
else
|
||||
pvesm alloc "$STORAGE" "$VMID" "vm-$VMID-disk-0" 4M >/dev/null
|
||||
qm set "$VMID" -efidisk0 "${STORAGE}:vm-$VMID-disk-0,efitype=4m,size=4M"
|
||||
fi
|
||||
|
||||
IMPORT_OUT="$(qm importdisk "$VMID" "$FILE" "$STORAGE" --format raw 2>&1 || true)"
|
||||
DISK_REF="$(printf '%s\n' "$IMPORT_OUT" | sed -n "s/.*successfully imported disk '\([^']\+\)'.*/\1/p")"
|
||||
|
||||
if [[ -z "$DISK_REF" ]]; then
|
||||
DISK_REF="$(pvesm list "$STORAGE" | awk -v id="$VMID" '$1 ~ ("vm-"id"-disk-") {print $1}' | sort | tail -n1)"
|
||||
fi
|
||||
|
||||
if [[ -z "$DISK_REF" ]]; then
|
||||
msg_error "Unable to determine imported disk reference."
|
||||
echo "$IMPORT_OUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
qm set "$VMID" \
|
||||
-efidisk0 "${STORAGE}:0,efitype=4m,size=4M" \
|
||||
-scsi0 "${DISK_REF},size=${DISK_SIZE}" \
|
||||
-boot order=scsi0 \
|
||||
-tags community-script >/dev/null
|
||||
msg_ok "Attached disk (${DISK_SIZE})"
|
||||
|
||||
DESCRIPTION=$(
|
||||
cat <<EOF
|
||||
<div align='center'>
|
||||
<a href='https://Helper-Scripts.com' target='_blank' rel='noopener noreferrer'>
|
||||
<img src='https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/images/logo-81x112.png' alt='Logo' style='width:81px;height:112px;'/>
|
||||
</a>
|
||||
|
||||
<h2 style='font-size: 24px; margin: 20px 0;'>OpenWrt VM</h2>
|
||||
|
||||
<p style='margin: 16px 0;'>
|
||||
<a href='https://ko-fi.com/community_scripts' target='_blank' rel='noopener noreferrer'>
|
||||
<img src='https://img.shields.io/badge/☕-Buy us a coffee-blue' alt='spend Coffee' />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<span style='margin: 0 10px;'>
|
||||
<i class="fa fa-github fa-fw" style="color: #f5f5f5;"></i>
|
||||
<a href='https://github.com/community-scripts/ProxmoxVE' target='_blank' rel='noopener noreferrer' style='text-decoration: none; color: #00617f;'>GitHub</a>
|
||||
</span>
|
||||
<span style='margin: 0 10px;'>
|
||||
<i class="fa fa-comments fa-fw" style="color: #f5f5f5;"></i>
|
||||
<a href='https://github.com/community-scripts/ProxmoxVE/discussions' target='_blank' rel='noopener noreferrer' style='text-decoration: none; color: #00617f;'>Discussions</a>
|
||||
</span>
|
||||
<span style='margin: 0 10px;'>
|
||||
<i class="fa fa-exclamation-circle fa-fw" style="color: #f5f5f5;"></i>
|
||||
<a href='https://github.com/community-scripts/ProxmoxVE/issues' target='_blank' rel='noopener noreferrer' style='text-decoration: none; color: #00617f;'>Issues</a>
|
||||
</span>
|
||||
</div>
|
||||
EOF
|
||||
)
|
||||
qm set "$VMID" -description "$DESCRIPTION" >/dev/null
|
||||
|
||||
msg_ok "Created OpenWrt VM ${CL}${BL}(${HN})"
|
||||
msg_info "OpenWrt is being started in order to configure the network interfaces."
|
||||
qm start $VMID
|
||||
sleep 15
|
||||
msg_info "Waiting for OpenWrt to boot..."
|
||||
for i in {1..30}; do
|
||||
if qm status "$VMID" | grep -q "running"; then
|
||||
sleep 5
|
||||
msg_ok "OpenWrt is running"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
msg_ok "Network interfaces are being configured as OpenWrt initiates."
|
||||
|
||||
if qm status "$VMID" | grep -q "running"; then
|
||||
send_line_to_vm ""
|
||||
send_line_to_vm "uci delete network.@device[0]"
|
||||
send_line_to_vm "uci set network.wan=interface"
|
||||
send_line_to_vm "uci set network.wan.device=eth1"
|
||||
send_line_to_vm "uci set network.wan.proto=dhcp"
|
||||
send_line_to_vm "uci delete network.lan"
|
||||
send_line_to_vm "uci set network.lan=interface"
|
||||
send_line_to_vm "uci set network.lan.device=eth0"
|
||||
send_line_to_vm "uci set network.lan.proto=static"
|
||||
send_line_to_vm "uci set network.lan.ipaddr=${LAN_IP_ADDR}"
|
||||
send_line_to_vm "uci set network.lan.netmask=${LAN_NETMASK}"
|
||||
send_line_to_vm "uci commit"
|
||||
send_line_to_vm "halt"
|
||||
msg_ok "Network interfaces configured in OpenWrt"
|
||||
else
|
||||
msg_error "VM is not running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_info "Waiting for OpenWrt to shut down..."
|
||||
until qm status "$VMID" | grep -q "stopped"; do
|
||||
sleep 2
|
||||
done
|
||||
msg_ok "OpenWrt has shut down"
|
||||
|
||||
msg_info "Adding bridge interfaces on Proxmox side"
|
||||
qm set "$VMID" \
|
||||
-net0 virtio,bridge="${LAN_BRG}",macaddr="${LAN_MAC}${LAN_VLAN}${MTU}" \
|
||||
-net1 virtio,bridge="${BRG}",macaddr="${MAC}${VLAN}${MTU}" >/dev/null
|
||||
msg_ok "Bridge interfaces added"
|
||||
|
||||
if [ "$START_VM" = "yes" ]; then
|
||||
msg_info "Starting OpenWrt VM"
|
||||
qm start "$VMID"
|
||||
msg_ok "Started OpenWrt VM"
|
||||
fi
|
||||
|
||||
VLAN_FINISH=""
|
||||
if [ -z "$VLAN" ] && [ "$VLAN2" != "999" ]; then
|
||||
VLAN_FINISH=" Please remember to adjust the VLAN tags to suit your network."
|
||||
fi
|
||||
post_update_to_api "done" "none"
|
||||
msg_ok "Completed Successfully!${VLAN_FINISH:+\n$VLAN_FINISH}"
|
||||
251
server.js
251
server.js
@@ -7,7 +7,7 @@ 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';
|
||||
import { getDatabase } from './src/server/database-prisma.js';
|
||||
import { getDatabase } from './src/server/database.js';
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const hostname = '0.0.0.0';
|
||||
@@ -51,7 +51,6 @@ const handle = app.getRequestHandler();
|
||||
* @property {string} [mode]
|
||||
* @property {ServerInfo} [server]
|
||||
* @property {boolean} [isUpdate]
|
||||
* @property {boolean} [isShell]
|
||||
* @property {string} [containerId]
|
||||
*/
|
||||
|
||||
@@ -131,66 +130,17 @@ class ScriptExecutionHandler {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Web UI URL from terminal output
|
||||
* @param {string} output - Terminal output to parse
|
||||
* @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise
|
||||
*/
|
||||
parseWebUIUrl(output) {
|
||||
// First, strip ANSI color codes to make pattern matching more reliable
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
|
||||
// Look for URL patterns with any valid IP address (private or public)
|
||||
const patterns = [
|
||||
// HTTP/HTTPS URLs with IP and port
|
||||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi,
|
||||
// URLs without explicit port (assume default ports)
|
||||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi,
|
||||
// URLs with trailing slash and port
|
||||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi,
|
||||
// URLs with just IP and port (no protocol)
|
||||
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi,
|
||||
// URLs with just IP (no protocol, no port)
|
||||
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi,
|
||||
];
|
||||
|
||||
// Try patterns on both original and cleaned output
|
||||
const outputsToTry = [output, cleanOutput];
|
||||
|
||||
for (const testOutput of outputsToTry) {
|
||||
for (const pattern of patterns) {
|
||||
const matches = [...testOutput.matchAll(pattern)];
|
||||
for (const match of matches) {
|
||||
if (match[1]) {
|
||||
const ip = match[1];
|
||||
const port = match[2] || (match[0].startsWith('https') ? '443' : '80');
|
||||
|
||||
// Validate IP address format
|
||||
if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
|
||||
return {
|
||||
ip: ip,
|
||||
port: parseInt(port, 10)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create installation record
|
||||
* @param {string} scriptName - Name of the script
|
||||
* @param {string} scriptPath - Path to the script
|
||||
* @param {string} executionMode - 'local' or 'ssh'
|
||||
* @param {number|null} serverId - Server ID for SSH executions
|
||||
* @returns {Promise<number|null>} - Installation record ID
|
||||
* @returns {number|null} - Installation record ID
|
||||
*/
|
||||
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
||||
createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
||||
try {
|
||||
const result = await this.db.createInstalledScript({
|
||||
const result = this.db.createInstalledScript({
|
||||
script_name: scriptName,
|
||||
script_path: scriptPath,
|
||||
container_id: undefined,
|
||||
@@ -199,7 +149,7 @@ class ScriptExecutionHandler {
|
||||
status: 'in_progress',
|
||||
output_log: ''
|
||||
});
|
||||
return Number(result.id);
|
||||
return Number(result.lastInsertRowid);
|
||||
} catch (error) {
|
||||
console.error('Error creating installation record:', error);
|
||||
return null;
|
||||
@@ -211,9 +161,9 @@ class ScriptExecutionHandler {
|
||||
* @param {number} installationId - Installation record ID
|
||||
* @param {Object} updateData - Data to update
|
||||
*/
|
||||
async updateInstallationRecord(installationId, updateData) {
|
||||
updateInstallationRecord(installationId, updateData) {
|
||||
try {
|
||||
await this.db.updateInstalledScript(installationId, updateData);
|
||||
this.db.updateInstalledScript(installationId, updateData);
|
||||
} catch (error) {
|
||||
console.error('Error updating installation record:', error);
|
||||
}
|
||||
@@ -257,15 +207,13 @@ class ScriptExecutionHandler {
|
||||
* @param {WebSocketMessage} message
|
||||
*/
|
||||
async handleMessage(ws, message) {
|
||||
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
|
||||
const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message;
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
if (scriptPath && executionId) {
|
||||
if (isUpdate && containerId) {
|
||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
|
||||
} else if (isShell && containerId) {
|
||||
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
||||
} else {
|
||||
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
|
||||
}
|
||||
@@ -327,7 +275,7 @@ class ScriptExecutionHandler {
|
||||
|
||||
// Create installation record
|
||||
const serverId = server ? (server.id ?? null) : null;
|
||||
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
||||
installationId = this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
||||
|
||||
if (!installationId) {
|
||||
console.error('Failed to create installation record');
|
||||
@@ -356,7 +304,7 @@ class ScriptExecutionHandler {
|
||||
|
||||
// Update installation record with failure
|
||||
if (installationId) {
|
||||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -394,7 +342,7 @@ class ScriptExecutionHandler {
|
||||
});
|
||||
|
||||
// Handle pty data (both stdout and stderr combined)
|
||||
childProcess.onData(async (data) => {
|
||||
childProcess.onData((data) => {
|
||||
const output = data.toString();
|
||||
|
||||
// Store output in buffer for logging
|
||||
@@ -410,19 +358,7 @@ class ScriptExecutionHandler {
|
||||
// Parse for Container ID
|
||||
const containerId = this.parseContainerId(output);
|
||||
if (containerId && installationId) {
|
||||
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||
}
|
||||
|
||||
// Parse for Web UI URL
|
||||
const webUIUrl = this.parseWebUIUrl(output);
|
||||
if (webUIUrl && installationId) {
|
||||
const { ip, port } = webUIUrl;
|
||||
if (ip && port) {
|
||||
await this.updateInstallationRecord(installationId, {
|
||||
web_ui_ip: ip,
|
||||
web_ui_port: port
|
||||
});
|
||||
}
|
||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||
}
|
||||
|
||||
this.sendMessage(ws, {
|
||||
@@ -464,7 +400,7 @@ class ScriptExecutionHandler {
|
||||
|
||||
// Update installation record with failure
|
||||
if (installationId) {
|
||||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,7 +427,7 @@ class ScriptExecutionHandler {
|
||||
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
|
||||
server,
|
||||
scriptPath,
|
||||
/** @param {string} data */ async (data) => {
|
||||
/** @param {string} data */ (data) => {
|
||||
// Store output in buffer for logging
|
||||
const exec = this.activeExecutions.get(executionId);
|
||||
if (exec) {
|
||||
@@ -505,19 +441,7 @@ class ScriptExecutionHandler {
|
||||
// Parse for Container ID
|
||||
const containerId = this.parseContainerId(data);
|
||||
if (containerId && installationId) {
|
||||
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||
}
|
||||
|
||||
// Parse for Web UI URL
|
||||
const webUIUrl = this.parseWebUIUrl(data);
|
||||
if (webUIUrl && installationId) {
|
||||
const { ip, port } = webUIUrl;
|
||||
if (ip && port) {
|
||||
await this.updateInstallationRecord(installationId, {
|
||||
web_ui_ip: ip,
|
||||
web_ui_port: port
|
||||
});
|
||||
}
|
||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||
}
|
||||
|
||||
// Handle data output
|
||||
@@ -545,13 +469,13 @@ class ScriptExecutionHandler {
|
||||
timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
/** @param {number} code */ async (code) => {
|
||||
/** @param {number} code */ (code) => {
|
||||
const exec = this.activeExecutions.get(executionId);
|
||||
const isSuccess = code === 0;
|
||||
|
||||
// Update installation record with final status and output
|
||||
if (installationId && exec) {
|
||||
await this.updateInstallationRecord(installationId, {
|
||||
this.updateInstallationRecord(installationId, {
|
||||
status: isSuccess ? 'success' : 'failed',
|
||||
output_log: exec.outputBuffer
|
||||
});
|
||||
@@ -586,7 +510,7 @@ class ScriptExecutionHandler {
|
||||
|
||||
// Update installation record with failure
|
||||
if (installationId) {
|
||||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,145 +709,6 @@ class ScriptExecutionHandler {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start shell execution
|
||||
* @param {ExtendedWebSocket} ws
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
* @param {string} mode
|
||||
* @param {ServerInfo|null} server
|
||||
*/
|
||||
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
|
||||
try {
|
||||
|
||||
// Send start message
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting shell session for container ${containerId}...`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
if (mode === 'ssh' && server) {
|
||||
await this.startSSHShellExecution(ws, containerId, executionId, server);
|
||||
} else {
|
||||
await this.startLocalShellExecution(ws, containerId, executionId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start local shell execution
|
||||
* @param {ExtendedWebSocket} ws
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
*/
|
||||
async startLocalShellExecution(ws, containerId, executionId) {
|
||||
const { spawn } = await import('node-pty');
|
||||
|
||||
// Create a shell process that will run pct enter
|
||||
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.cwd(),
|
||||
env: process.env
|
||||
});
|
||||
|
||||
// Store the execution
|
||||
this.activeExecutions.set(executionId, {
|
||||
process: childProcess,
|
||||
ws
|
||||
});
|
||||
|
||||
// Handle pty data
|
||||
childProcess.onData((data) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data.toString(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Note: No automatic command is sent - user can type commands interactively
|
||||
|
||||
// Handle process exit
|
||||
childProcess.onExit((e) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'end',
|
||||
data: `Shell session ended with exit code: ${e.exitCode}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
this.activeExecutions.delete(executionId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSH shell execution
|
||||
* @param {ExtendedWebSocket} ws
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
* @param {ServerInfo} server
|
||||
*/
|
||||
async startSSHShellExecution(ws, containerId, executionId, server) {
|
||||
const sshService = getSSHExecutionService();
|
||||
|
||||
try {
|
||||
const execution = await sshService.executeCommand(
|
||||
server,
|
||||
`pct enter ${containerId}`,
|
||||
/** @param {string} data */
|
||||
(data) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
/** @param {string} error */
|
||||
(error) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: error,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
/** @param {number} code */
|
||||
(code) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'end',
|
||||
data: `Shell session ended with exit code: ${code}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
this.activeExecutions.delete(executionId);
|
||||
}
|
||||
);
|
||||
|
||||
// Store the execution
|
||||
this.activeExecutions.set(executionId, {
|
||||
process: /** @type {any} */ (execution).process,
|
||||
ws
|
||||
});
|
||||
|
||||
// Note: No automatic command is sent - user can type commands interactively
|
||||
|
||||
} catch (error) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TerminalHandler removed - not used by current application
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { HelpModal } from './HelpModal';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
interface ContextualHelpIconProps {
|
||||
@@ -25,13 +26,15 @@ export function ContextualHelpIcon({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer inline-flex items-center justify-center rounded-md transition-colors ${className}`}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted ${className}`}
|
||||
title={tooltip}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<HelpModal
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -93,6 +93,17 @@ export function FilterBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="mb-4 flex items-center justify-center py-1">
|
||||
<div className="flex items-center space-x-2 text-xs text-green-600">
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Filters are being saved automatically</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Header */}
|
||||
{!isLoadingFilters && (
|
||||
@@ -380,30 +391,18 @@ export function FilterBar({
|
||||
|
||||
{/* Filter Summary and Clear All */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="font-medium text-blue-600">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="flex items-center space-x-1 text-xs text-green-600">
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Filters are being saved automatically</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="font-medium text-blue-600">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,15 +55,8 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Password:</strong> Use username and password authentication</li>
|
||||
<li>• <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
|
||||
<li>• <strong>Both:</strong> Try SSH key first, fallback to password if needed</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md">
|
||||
<h5 className="font-medium text-blue-900 dark:text-blue-100 mb-2">SSH Key Features:</h5>
|
||||
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<li>• <strong>Generate Key Pair:</strong> Create new SSH keys automatically</li>
|
||||
<li>• <strong>View Public Key:</strong> Copy public key for server setup</li>
|
||||
<li>• <strong>Persistent Storage:</strong> Keys are stored securely on disk</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
@@ -326,7 +319,6 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
<li>• <strong>Installation Status:</strong> Track success, failure, or in-progress installations</li>
|
||||
<li>• <strong>Server Association:</strong> Know which server each script is installed on</li>
|
||||
<li>• <strong>Container ID:</strong> Link scripts to specific LXC containers</li>
|
||||
<li>• <strong>Web UI Access:</strong> Track and access Web UI IP addresses and ports</li>
|
||||
<li>• <strong>Execution Logs:</strong> View output and logs from script installations</li>
|
||||
<li>• <strong>Filtering:</strong> Filter by server, status, or search terms</li>
|
||||
</ul>
|
||||
@@ -343,47 +335,8 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-blue-900/20 border-blue-700/50">
|
||||
<h4 className="font-medium text-foreground mb-2">Web UI Access </h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Automatically detect and access Web UI interfaces for your installed scripts.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Auto-Detection:</strong> Automatically detects Web UI URLs from script installation output</li>
|
||||
<li>• <strong>IP & Port Tracking:</strong> Stores and displays Web UI IP addresses and ports</li>
|
||||
<li>• <strong>One-Click Access:</strong> Click IP:port to open Web UI in new tab</li>
|
||||
<li>• <strong>Manual Detection:</strong> Re-detect IP using <code>hostname -I</code> inside container</li>
|
||||
<li>• <strong>Port Detection:</strong> Uses script metadata to get correct port (e.g., actualbudget:5006)</li>
|
||||
<li>• <strong>Editable Fields:</strong> Manually edit IP and port values as needed</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-blue-900/30 rounded-lg border border-blue-700/30">
|
||||
<p className="text-sm font-medium text-blue-300">💡 How it works:</p>
|
||||
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
|
||||
<li>• Scripts automatically detect URLs like <code>http://10.10.10.1:3000</code> during installation</li>
|
||||
<li>• Re-detect button runs <code>hostname -I</code> inside the container via SSH</li>
|
||||
<li>• Port defaults to 80, but uses script metadata when available</li>
|
||||
<li>• Web UI buttons are disabled when container is stopped</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
|
||||
<h4 className="font-medium text-foreground mb-2">Actions Dropdown </h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Clean interface with all actions organized in a dropdown menu.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Edit Button:</strong> Always visible for quick script editing</li>
|
||||
<li>• <strong>Actions Dropdown:</strong> Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete</li>
|
||||
<li>• <strong>Smart Visibility:</strong> Dropdown only appears when actions are available</li>
|
||||
<li>• <strong>Color Coding:</strong> Start (green), Stop (red), Update (cyan), Shell (gray), Open UI (blue)</li>
|
||||
<li>• <strong>Auto-Close:</strong> Dropdown closes after clicking any action</li>
|
||||
<li>• <strong>Disabled States:</strong> Actions are disabled when container is stopped</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
|
||||
<h4 className="font-medium text-foreground mb-2">Container Control</h4>
|
||||
<h4 className="font-medium text-foreground mb-2">Container Control (NEW)</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Directly control LXC containers from the installed scripts page via SSH.
|
||||
</p>
|
||||
|
||||
@@ -8,15 +8,7 @@ import { Button } from './ui/button';
|
||||
import { ScriptInstallationCard } from './ScriptInstallationCard';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { ErrorModal } from './ErrorModal';
|
||||
import { LoadingModal } from './LoadingModal';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from './ui/dropdown-menu';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
@@ -28,18 +20,12 @@ interface InstalledScript {
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
server_auth_type: string | null;
|
||||
server_ssh_key: string | null;
|
||||
server_ssh_key_passphrase: string | null;
|
||||
server_ssh_port: number | null;
|
||||
server_color: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
execution_mode: 'local' | 'ssh';
|
||||
container_status?: 'running' | 'stopped' | 'unknown';
|
||||
web_ui_ip: string | null;
|
||||
web_ui_port: number | null;
|
||||
}
|
||||
|
||||
export function InstalledScriptsTab() {
|
||||
@@ -49,9 +35,8 @@ export function InstalledScriptsTab() {
|
||||
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||
const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
|
||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
|
||||
const [showAutoDetectForm, setShowAutoDetectForm] = useState(false);
|
||||
@@ -85,12 +70,6 @@ export function InstalledScriptsTab() {
|
||||
type?: 'error' | 'success';
|
||||
} | null>(null);
|
||||
|
||||
// Loading modal state
|
||||
const [loadingModal, setLoadingModal] = useState<{
|
||||
isOpen: boolean;
|
||||
action: string;
|
||||
} | null>(null);
|
||||
|
||||
// Fetch installed scripts
|
||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
|
||||
@@ -108,7 +87,7 @@ export function InstalledScriptsTab() {
|
||||
onSuccess: () => {
|
||||
void refetchScripts();
|
||||
setEditingScriptId(null);
|
||||
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
|
||||
setEditFormData({ script_name: '', container_id: '' });
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(`Error updating script: ${error.message}`);
|
||||
@@ -222,30 +201,7 @@ export function InstalledScriptsTab() {
|
||||
message: error.message ?? 'Cleanup failed. Please try again.'
|
||||
});
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-detect Web UI mutation
|
||||
const autoDetectWebUIMutation = api.installedScripts.autoDetectWebUI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('✅ Auto-detect WebUI success:', data);
|
||||
void refetchScripts();
|
||||
setAutoDetectStatus({
|
||||
type: 'success',
|
||||
message: data.message ?? 'Web UI IP detected successfully!'
|
||||
});
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ Auto-detect Web UI error:', error);
|
||||
setAutoDetectStatus({
|
||||
type: 'error',
|
||||
message: error.message ?? 'Auto-detect failed. Please try again.'
|
||||
});
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
||||
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -254,7 +210,6 @@ export function InstalledScriptsTab() {
|
||||
|
||||
const controlContainerMutation = api.installedScripts.controlContainer.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
setLoadingModal(null);
|
||||
setControllingScriptId(null);
|
||||
|
||||
if (data.success) {
|
||||
@@ -295,7 +250,6 @@ export function InstalledScriptsTab() {
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Container control error:', error);
|
||||
setLoadingModal(null);
|
||||
setControllingScriptId(null);
|
||||
|
||||
// Show detailed error message
|
||||
@@ -311,7 +265,6 @@ export function InstalledScriptsTab() {
|
||||
|
||||
const destroyContainerMutation = api.installedScripts.destroyContainer.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setLoadingModal(null);
|
||||
setControllingScriptId(null);
|
||||
|
||||
if (data.success) {
|
||||
@@ -336,7 +289,6 @@ export function InstalledScriptsTab() {
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Container destroy error:', error);
|
||||
setLoadingModal(null);
|
||||
setControllingScriptId(null);
|
||||
|
||||
// Show detailed error message
|
||||
@@ -388,7 +340,7 @@ export function InstalledScriptsTab() {
|
||||
containerStatusMutation.mutate({ serverIds });
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
}, []); // Remove containerStatusMutation from dependencies to prevent loops
|
||||
|
||||
// Run cleanup when component mounts and scripts are loaded (only once)
|
||||
useEffect(() => {
|
||||
@@ -404,7 +356,7 @@ export function InstalledScriptsTab() {
|
||||
console.log('Status check triggered - scripts length:', scripts.length);
|
||||
fetchContainerStatuses();
|
||||
}
|
||||
}, [scripts.length, fetchContainerStatuses]);
|
||||
}, [scripts.length]); // Remove fetchContainerStatuses from dependencies
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
@@ -526,7 +478,6 @@ export function InstalledScriptsTab() {
|
||||
message: `Are you sure you want to ${action} container ${script.container_id} (${script.script_name})?`,
|
||||
onConfirm: () => {
|
||||
setControllingScriptId(script.id);
|
||||
setLoadingModal({ isOpen: true, action: `${action === 'start' ? 'Starting' : 'Stopping'} container ${script.container_id}...` });
|
||||
void controlContainerMutation.mutate({ id: script.id, action });
|
||||
setConfirmationModal(null);
|
||||
}
|
||||
@@ -547,7 +498,6 @@ export function InstalledScriptsTab() {
|
||||
confirmText: script.container_id,
|
||||
onConfirm: () => {
|
||||
setControllingScriptId(script.id);
|
||||
setLoadingModal({ isOpen: true, action: `Destroying container ${script.container_id}...` });
|
||||
void destroyContainerMutation.mutate({ id: script.id });
|
||||
setConfirmationModal(null);
|
||||
}
|
||||
@@ -576,17 +526,13 @@ export function InstalledScriptsTab() {
|
||||
onConfirm: () => {
|
||||
// Get server info if it's SSH mode
|
||||
let server = null;
|
||||
if (script.server_id && script.server_user) {
|
||||
if (script.server_id && script.server_user && script.server_password) {
|
||||
server = {
|
||||
id: script.server_id,
|
||||
name: script.server_name,
|
||||
ip: script.server_ip,
|
||||
user: script.server_user,
|
||||
password: script.server_password,
|
||||
auth_type: script.server_auth_type ?? 'password',
|
||||
ssh_key: script.server_ssh_key,
|
||||
ssh_key_passphrase: script.server_ssh_key_passphrase,
|
||||
ssh_port: script.server_ssh_port ?? 22
|
||||
password: script.server_password
|
||||
};
|
||||
}
|
||||
|
||||
@@ -604,104 +550,17 @@ export function InstalledScriptsTab() {
|
||||
setUpdatingScript(null);
|
||||
};
|
||||
|
||||
const handleOpenShell = (script: InstalledScript) => {
|
||||
if (!script.container_id) {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Shell Access Failed',
|
||||
message: 'No Container ID available for this script',
|
||||
details: 'This script does not have a valid container ID and cannot be accessed via shell.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get server info if it's SSH mode
|
||||
let server = null;
|
||||
if (script.server_id && script.server_user) {
|
||||
server = {
|
||||
id: script.server_id,
|
||||
name: script.server_name,
|
||||
ip: script.server_ip,
|
||||
user: script.server_user,
|
||||
password: script.server_password,
|
||||
auth_type: script.server_auth_type ?? 'password',
|
||||
ssh_key: script.server_ssh_key,
|
||||
ssh_key_passphrase: script.server_ssh_key_passphrase,
|
||||
ssh_port: script.server_ssh_port ?? 22
|
||||
};
|
||||
}
|
||||
|
||||
setOpeningShell({
|
||||
id: script.id,
|
||||
containerId: script.container_id,
|
||||
server: server
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseShellTerminal = () => {
|
||||
setOpeningShell(null);
|
||||
};
|
||||
|
||||
// Auto-scroll to terminals when they open
|
||||
useEffect(() => {
|
||||
if (openingShell) {
|
||||
// Small delay to ensure the terminal is rendered
|
||||
setTimeout(() => {
|
||||
const terminalElement = document.querySelector('[data-terminal="shell"]');
|
||||
if (terminalElement) {
|
||||
// Scroll to the terminal with smooth animation
|
||||
terminalElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
|
||||
// Add a subtle highlight effect
|
||||
terminalElement.classList.add('animate-pulse');
|
||||
setTimeout(() => {
|
||||
terminalElement.classList.remove('animate-pulse');
|
||||
}, 2000);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, [openingShell]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updatingScript) {
|
||||
// Small delay to ensure the terminal is rendered
|
||||
setTimeout(() => {
|
||||
const terminalElement = document.querySelector('[data-terminal="update"]');
|
||||
if (terminalElement) {
|
||||
// Scroll to the terminal with smooth animation
|
||||
terminalElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
|
||||
// Add a subtle highlight effect
|
||||
terminalElement.classList.add('animate-pulse');
|
||||
setTimeout(() => {
|
||||
terminalElement.classList.remove('animate-pulse');
|
||||
}, 2000);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, [updatingScript]);
|
||||
|
||||
const handleEditScript = (script: InstalledScript) => {
|
||||
setEditingScriptId(script.id);
|
||||
setEditFormData({
|
||||
script_name: script.script_name,
|
||||
container_id: script.container_id ?? '',
|
||||
web_ui_ip: script.web_ui_ip ?? '',
|
||||
web_ui_port: script.web_ui_port?.toString() ?? ''
|
||||
container_id: script.container_id ?? ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingScriptId(null);
|
||||
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
|
||||
setEditFormData({ script_name: '', container_id: '' });
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
@@ -720,13 +579,11 @@ export function InstalledScriptsTab() {
|
||||
id: editingScriptId,
|
||||
script_name: editFormData.script_name.trim(),
|
||||
container_id: editFormData.container_id.trim() || undefined,
|
||||
web_ui_ip: editFormData.web_ui_ip.trim() || undefined,
|
||||
web_ui_port: editFormData.web_ui_port.trim() ? parseInt(editFormData.web_ui_port, 10) : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => {
|
||||
const handleInputChange = (field: 'script_name' | 'container_id', value: string) => {
|
||||
setEditFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
@@ -788,54 +645,6 @@ export function InstalledScriptsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoDetectWebUI = (script: InstalledScript) => {
|
||||
console.log('🔍 Auto-detect WebUI clicked for script:', script);
|
||||
console.log('Script validation:', {
|
||||
hasContainerId: !!script.container_id,
|
||||
isSSHMode: script.execution_mode === 'ssh',
|
||||
containerId: script.container_id,
|
||||
executionMode: script.execution_mode
|
||||
});
|
||||
|
||||
if (!script.container_id || script.execution_mode !== 'ssh') {
|
||||
console.log('❌ Auto-detect validation failed');
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Auto-Detect Failed',
|
||||
message: 'Auto-detect only works for SSH mode scripts with container ID',
|
||||
details: 'This script does not have a valid container ID or is not in SSH mode.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Calling autoDetectWebUIMutation.mutate with id:', script.id);
|
||||
autoDetectWebUIMutation.mutate({ id: script.id });
|
||||
};
|
||||
|
||||
const handleOpenWebUI = (script: InstalledScript) => {
|
||||
if (!script.web_ui_ip) {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Web UI Access Failed',
|
||||
message: 'No IP address configured for this script',
|
||||
details: 'Please set the Web UI IP address before opening the interface.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const port = script.web_ui_port ?? 80;
|
||||
const url = `http://${script.web_ui_ip}:${port}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
// Helper function to check if a script has any actions available
|
||||
const hasActions = (script: InstalledScript) => {
|
||||
if (script.container_id && script.execution_mode === 'ssh') return true;
|
||||
if (script.web_ui_ip != null) return true;
|
||||
if (!script.container_id || script.execution_mode !== 'ssh') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
@@ -853,7 +662,7 @@ export function InstalledScriptsTab() {
|
||||
<div className="space-y-6">
|
||||
{/* Update Terminal */}
|
||||
{updatingScript && (
|
||||
<div className="mb-8" data-terminal="update">
|
||||
<div className="mb-8">
|
||||
<Terminal
|
||||
scriptPath={`update-${updatingScript.containerId}`}
|
||||
onClose={handleCloseUpdateTerminal}
|
||||
@@ -865,20 +674,6 @@ export function InstalledScriptsTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shell Terminal */}
|
||||
{openingShell && (
|
||||
<div className="mb-8" data-terminal="shell">
|
||||
<Terminal
|
||||
scriptPath={`shell-${openingShell.containerId}`}
|
||||
onClose={handleCloseShellTerminal}
|
||||
mode={openingShell.server ? 'ssh' : 'local'}
|
||||
server={openingShell.server}
|
||||
isShell={true}
|
||||
containerId={openingShell.containerId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header with Stats */}
|
||||
<div className="bg-card rounded-lg shadow p-6">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
|
||||
@@ -1200,7 +995,6 @@ export function InstalledScriptsTab() {
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
onUpdate={() => handleUpdateScript(script)}
|
||||
onShell={() => handleOpenShell(script)}
|
||||
onDelete={() => handleDeleteScript(Number(script.id))}
|
||||
isUpdating={updateScriptMutation.isPending}
|
||||
isDeleting={deleteScriptMutation.isPending}
|
||||
@@ -1208,9 +1002,6 @@ export function InstalledScriptsTab() {
|
||||
onStartStop={(action) => handleStartStop(script, action)}
|
||||
onDestroy={() => handleDestroy(script)}
|
||||
isControlling={controllingScriptId === script.id}
|
||||
onOpenWebUI={() => handleOpenWebUI(script)}
|
||||
onAutoDetectWebUI={() => handleAutoDetectWebUI(script)}
|
||||
isAutoDetecting={autoDetectWebUIMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1246,9 +1037,6 @@ export function InstalledScriptsTab() {
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Web UI
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('server_name')}
|
||||
@@ -1357,58 +1145,6 @@ export function InstalledScriptsTab() {
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<div className="flex items-center space-x-2 min-h-[2.5rem]">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.web_ui_ip}
|
||||
onChange={(e) => handleInputChange('web_ui_ip', e.target.value)}
|
||||
className="w-32 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="IP"
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={editFormData.web_ui_port}
|
||||
onChange={(e) => handleInputChange('web_ui_port', e.target.value)}
|
||||
className="w-20 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="Port"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
script.web_ui_ip ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm text-foreground">
|
||||
{script.web_ui_ip}:{script.web_ui_port ?? 80}
|
||||
</span>
|
||||
{containerStatuses.get(script.id) === 'running' && (
|
||||
<button
|
||||
onClick={() => handleOpenWebUI(script)}
|
||||
className="text-xs px-2 py-1 bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md rounded disabled:opacity-50 flex-shrink-0"
|
||||
title="Open Web UI"
|
||||
>
|
||||
Open UI
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<button
|
||||
onClick={() => handleAutoDetectWebUI(script)}
|
||||
disabled={autoDetectWebUIMutation.isPending}
|
||||
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors"
|
||||
title="Re-detect IP and port"
|
||||
>
|
||||
{autoDetectWebUIMutation.isPending ? '...' : 'Re-detect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-left">
|
||||
<span
|
||||
className="text-sm px-3 py-1 rounded inline-block"
|
||||
@@ -1457,90 +1193,52 @@ export function InstalledScriptsTab() {
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{hasActions(script) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-gray-900 border-gray-700">
|
||||
{script.container_id && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleUpdateScript(script)}
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-900/20 focus:bg-cyan-900/20"
|
||||
>
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenShell(script)}
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
className="text-gray-300 hover:text-gray-200 hover:bg-gray-800/20 focus:bg-gray-800/20"
|
||||
>
|
||||
Shell
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.web_ui_ip && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenWebUI(script)}
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
|
||||
>
|
||||
Open UI
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && script.web_ui_ip && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAutoDetectWebUI(script)}
|
||||
disabled={autoDetectWebUIMutation.isPending || containerStatuses.get(script.id) === 'stopped'}
|
||||
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
|
||||
>
|
||||
{autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
|
||||
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
|
||||
className={(containerStatuses.get(script.id) ?? 'unknown') === 'running'
|
||||
? "text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||
: "text-green-300 hover:text-green-200 hover:bg-green-900/20 focus:bg-green-900/20"
|
||||
}
|
||||
>
|
||||
{controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDestroy(script)}
|
||||
disabled={controllingScriptId === script.id}
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||
>
|
||||
{controllingScriptId === script.id ? 'Working...' : 'Destroy'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteScript(Number(script.id))}
|
||||
disabled={deleteScriptMutation.isPending}
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||
>
|
||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={() => handleUpdateScript(script)}
|
||||
variant="update"
|
||||
size="sm"
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{/* Container Control Buttons - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
|
||||
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
|
||||
variant={(containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
className={(containerStatuses.get(script.id) ?? 'unknown') === 'running'
|
||||
? "bg-red-600 hover:bg-red-700 text-white border border-red-500 hover:border-red-400 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 transition-all duration-200 disabled:hover:scale-100"
|
||||
: "bg-green-600 hover:bg-green-700 text-white border border-green-500 hover:border-green-400 hover:scale-105 hover:shadow-lg hover:shadow-green-500/25 transition-all duration-200 disabled:hover:scale-100"
|
||||
}
|
||||
>
|
||||
{controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDestroy(script)}
|
||||
disabled={controllingScriptId === script.id}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="bg-red-800 hover:bg-red-900 text-white border border-red-600 hover:border-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-600/30 transition-all duration-200 disabled:hover:scale-100"
|
||||
>
|
||||
{controllingScriptId === script.id ? 'Working...' : 'Destroy'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Fallback to old Delete button for non-SSH scripts */}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<Button
|
||||
onClick={() => handleDeleteScript(Number(script.id))}
|
||||
variant="delete"
|
||||
size="sm"
|
||||
disabled={deleteScriptMutation.isPending}
|
||||
>
|
||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1579,14 +1277,6 @@ export function InstalledScriptsTab() {
|
||||
type={errorModal.type ?? 'error'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Loading Modal */}
|
||||
{loadingModal && (
|
||||
<LoadingModal
|
||||
isOpen={loadingModal.isOpen}
|
||||
action={loadingModal.action}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingModalProps {
|
||||
isOpen: boolean;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border p-8">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||
Processing
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{action}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Please wait...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, Copy, Check, Server, Globe } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface PublicKeyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
publicKey: string;
|
||||
serverName: string;
|
||||
serverIp: string;
|
||||
}
|
||||
|
||||
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(publicKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
// Fallback for older browsers or non-HTTPS
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = publicKey;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback copy failed:', fallbackError);
|
||||
// If all else fails, show the key in an alert
|
||||
alert('Please manually copy this key:\n\n' + publicKey);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
// Fallback: show the key in an alert
|
||||
alert('Please manually copy this key:\n\n' + publicKey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Server className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-card-foreground">SSH Public Key</h2>
|
||||
<p className="text-sm text-muted-foreground">Add this key to your server's authorized_keys</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Server Info */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="font-medium">{serverName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{serverIp}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-foreground">Instructions:</h3>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Copy the public key below</li>
|
||||
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
|
||||
<li>Add the key to authorized_keys: <code className="bg-muted px-1 rounded">echo "<paste-key>" >> ~/.ssh/authorized_keys</code></li>
|
||||
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Public Key */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">Public Key:</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
value={publicKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[120px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="Public key will appear here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface ReleaseNotesModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -172,23 +170,9 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
|
||||
{/* Release Body */}
|
||||
{release.body && (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
|
||||
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
|
||||
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
|
||||
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
|
||||
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
|
||||
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>,
|
||||
li: ({children}) => <li className="text-card-foreground">{children}</li>,
|
||||
a: ({href, children}) => <a href={href} className="text-blue-500 hover:text-blue-400 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
|
||||
strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>,
|
||||
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
|
||||
}}
|
||||
>
|
||||
<div className="whitespace-pre-wrap text-sm text-card-foreground leading-relaxed">
|
||||
{release.body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -104,6 +104,9 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
keyType = 'ECDSA';
|
||||
} else if (keyLine.includes('OPENSSH PRIVATE KEY')) {
|
||||
// For OpenSSH format keys, try to detect type from the key content
|
||||
// Look for common patterns in the base64 content
|
||||
const base64Content = keyContent.replace(/-----BEGIN.*?-----/, '').replace(/-----END.*?-----/, '').replace(/\s/g, '');
|
||||
|
||||
// This is a heuristic - OpenSSH ED25519 keys typically start with specific patterns
|
||||
// We'll default to "OpenSSH" for now since we can't reliably detect the type
|
||||
keyType = 'OpenSSH';
|
||||
|
||||
@@ -359,91 +359,91 @@ export function ScriptDetailModal({
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Load Message */}
|
||||
{loadMessage && (
|
||||
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
{loadMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script Files Status */}
|
||||
{(scriptFilesLoading || comparisonLoading) && (
|
||||
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<span>Loading script status...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scriptFilesData?.success &&
|
||||
!scriptFilesLoading &&
|
||||
(() => {
|
||||
// Determine script type from the first install method
|
||||
const firstScript = script?.install_methods?.[0]?.script;
|
||||
let scriptType = "Script";
|
||||
if (firstScript?.startsWith("ct/")) {
|
||||
scriptType = "CT Script";
|
||||
} else if (firstScript?.startsWith("tools/")) {
|
||||
scriptType = "Tools Script";
|
||||
} else if (firstScript?.startsWith("vm/")) {
|
||||
scriptType = "VM Script";
|
||||
} else if (firstScript?.startsWith("vw/")) {
|
||||
scriptType = "VW Script";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
|
||||
></div>
|
||||
<span>
|
||||
{scriptType}:{" "}
|
||||
{scriptFilesData.ctExists ? "Available" : "Not loaded"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-muted"}`}
|
||||
></div>
|
||||
<span>
|
||||
Install Script:{" "}
|
||||
{scriptFilesData.installExists
|
||||
? "Available"
|
||||
: "Not loaded"}
|
||||
</span>
|
||||
</div>
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists ||
|
||||
scriptFilesData.installExists) &&
|
||||
comparisonData?.success &&
|
||||
!comparisonLoading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-orange-500" : "bg-green-500"}`}
|
||||
></div>
|
||||
<span>
|
||||
Status:{" "}
|
||||
{comparisonData.hasDifferences
|
||||
? "Update available"
|
||||
: "Up to date"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{scriptFilesData.files.length > 0 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground break-words">
|
||||
Files: {scriptFilesData.files.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
{/* Script Files Status */}
|
||||
{(scriptFilesLoading || comparisonLoading) && (
|
||||
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<span>Loading script status...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scriptFilesData?.success &&
|
||||
!scriptFilesLoading &&
|
||||
(() => {
|
||||
// Determine script type from the first install method
|
||||
const firstScript = script?.install_methods?.[0]?.script;
|
||||
let scriptType = "Script";
|
||||
if (firstScript?.startsWith("ct/")) {
|
||||
scriptType = "CT Script";
|
||||
} else if (firstScript?.startsWith("tools/")) {
|
||||
scriptType = "Tools Script";
|
||||
} else if (firstScript?.startsWith("vm/")) {
|
||||
scriptType = "VM Script";
|
||||
} else if (firstScript?.startsWith("vw/")) {
|
||||
scriptType = "VW Script";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
|
||||
></div>
|
||||
<span>
|
||||
{scriptType}:{" "}
|
||||
{scriptFilesData.ctExists ? "Available" : "Not loaded"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-muted"}`}
|
||||
></div>
|
||||
<span>
|
||||
Install Script:{" "}
|
||||
{scriptFilesData.installExists
|
||||
? "Available"
|
||||
: "Not loaded"}
|
||||
</span>
|
||||
</div>
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists ||
|
||||
scriptFilesData.installExists) &&
|
||||
comparisonData?.success &&
|
||||
!comparisonLoading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-orange-500" : "bg-green-500"}`}
|
||||
></div>
|
||||
<span>
|
||||
Status:{" "}
|
||||
{comparisonData.hasDifferences
|
||||
? "Update available"
|
||||
: "Up to date"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{scriptFilesData.files.length > 0 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground break-words">
|
||||
Files: {scriptFilesData.files.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Load Message */}
|
||||
{loadMessage && (
|
||||
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
{loadMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
import { Button } from './ui/button';
|
||||
import { StatusBadge } from './Badge';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from './ui/dropdown-menu';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
@@ -21,30 +14,23 @@ interface InstalledScript {
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
server_auth_type: string | null;
|
||||
server_ssh_key: string | null;
|
||||
server_ssh_key_passphrase: string | null;
|
||||
server_ssh_port: number | null;
|
||||
server_color: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
execution_mode: 'local' | 'ssh';
|
||||
container_status?: 'running' | 'stopped' | 'unknown';
|
||||
web_ui_ip: string | null;
|
||||
web_ui_port: number | null;
|
||||
}
|
||||
|
||||
interface ScriptInstallationCardProps {
|
||||
script: InstalledScript;
|
||||
isEditing: boolean;
|
||||
editFormData: { script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string };
|
||||
onInputChange: (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => void;
|
||||
editFormData: { script_name: string; container_id: string };
|
||||
onInputChange: (field: 'script_name' | 'container_id', value: string) => void;
|
||||
onEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onUpdate: () => void;
|
||||
onShell: () => void;
|
||||
onDelete: () => void;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
@@ -53,10 +39,6 @@ interface ScriptInstallationCardProps {
|
||||
onStartStop: (action: 'start' | 'stop') => void;
|
||||
onDestroy: () => void;
|
||||
isControlling: boolean;
|
||||
// Web UI props
|
||||
onOpenWebUI: () => void;
|
||||
onAutoDetectWebUI: () => void;
|
||||
isAutoDetecting: boolean;
|
||||
}
|
||||
|
||||
export function ScriptInstallationCard({
|
||||
@@ -68,30 +50,18 @@ export function ScriptInstallationCard({
|
||||
onSave,
|
||||
onCancel,
|
||||
onUpdate,
|
||||
onShell,
|
||||
onDelete,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
containerStatus,
|
||||
onStartStop,
|
||||
onDestroy,
|
||||
isControlling,
|
||||
onOpenWebUI,
|
||||
onAutoDetectWebUI,
|
||||
isAutoDetecting
|
||||
isControlling
|
||||
}: ScriptInstallationCardProps) {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// Helper function to check if a script has any actions available
|
||||
const hasActions = (script: InstalledScript) => {
|
||||
if (script.container_id && script.execution_mode === 'ssh') return true;
|
||||
if (script.web_ui_ip != null) return true;
|
||||
if (!script.container_id || script.execution_mode !== 'ssh') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
|
||||
@@ -167,70 +137,6 @@ export function ScriptInstallationCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web UI */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">IP:PORT</div>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.web_ui_ip}
|
||||
onChange={(e) => onInputChange('web_ui_ip', e.target.value)}
|
||||
className="flex-1 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="IP"
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={editFormData.web_ui_port}
|
||||
onChange={(e) => onInputChange('web_ui_port', e.target.value)}
|
||||
className="w-20 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Port"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm font-mono text-foreground">
|
||||
{script.web_ui_ip ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
onClick={onOpenWebUI}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className={`text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline flex-shrink-0 ${
|
||||
containerStatus === 'stopped' ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{script.web_ui_ip}:{script.web_ui_port ?? 80}
|
||||
</button>
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<button
|
||||
onClick={onAutoDetectWebUI}
|
||||
disabled={isAutoDetecting}
|
||||
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors flex-shrink-0 ml-2"
|
||||
title="Re-detect IP and port"
|
||||
>
|
||||
{isAutoDetecting ? '...' : 'Re-detect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground">-</span>
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<button
|
||||
onClick={onAutoDetectWebUI}
|
||||
disabled={isAutoDetecting}
|
||||
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors"
|
||||
title="Re-detect IP and port"
|
||||
>
|
||||
{isAutoDetecting ? '...' : 'Re-detect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
||||
@@ -286,81 +192,51 @@ export function ScriptInstallationCard({
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{hasActions(script) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0 bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-gray-900 border-gray-700">
|
||||
{script.container_id && (
|
||||
<DropdownMenuItem
|
||||
onClick={onUpdate}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-900/20 focus:bg-cyan-900/20"
|
||||
>
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<DropdownMenuItem
|
||||
onClick={onShell}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className="text-gray-300 hover:text-gray-200 hover:bg-gray-800/20 focus:bg-gray-800/20"
|
||||
>
|
||||
Shell
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.web_ui_ip && (
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenWebUI}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
|
||||
>
|
||||
Open UI
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
|
||||
disabled={isControlling || containerStatus === 'unknown'}
|
||||
className={containerStatus === 'running'
|
||||
? "text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||
: "text-green-300 hover:text-green-200 hover:bg-green-900/20 focus:bg-green-900/20"
|
||||
}
|
||||
>
|
||||
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onDestroy}
|
||||
disabled={isControlling}
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||
>
|
||||
{isControlling ? 'Working...' : 'Destroy'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={onUpdate}
|
||||
variant="update"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
disabled={containerStatus === 'stopped'}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{/* Container Control Buttons - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
|
||||
disabled={isControlling || containerStatus === 'unknown'}
|
||||
variant={containerStatus === 'running' ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDestroy}
|
||||
disabled={isControlling}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isControlling ? 'Working...' : 'Destroy'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Fallback to old Delete button for non-SSH scripts */}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="delete"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useState, useEffect } from 'react';
|
||||
import type { CreateServerData } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { SSHKeyInput } from './SSHKeyInput';
|
||||
import { PublicKeyModal } from './PublicKeyModal';
|
||||
import { Key } from 'lucide-react';
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (data: CreateServerData) => void;
|
||||
@@ -32,11 +30,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
||||
const [sshKeyError, setSshKeyError] = useState<string>('');
|
||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
||||
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
|
||||
const [, setIsGeneratedKey] = useState(false);
|
||||
const [, setGeneratedServerId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadColorCodingSetting = async () => {
|
||||
@@ -82,18 +75,25 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
// Validate authentication based on auth_type
|
||||
const authType = formData.auth_type ?? 'password';
|
||||
|
||||
if (authType === 'password') {
|
||||
if (authType === 'password' || authType === 'both') {
|
||||
if (!formData.password?.trim()) {
|
||||
newErrors.password = 'Password is required for password authentication';
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'key') {
|
||||
if (authType === 'key' || authType === 'both') {
|
||||
if (!formData.ssh_key?.trim()) {
|
||||
newErrors.ssh_key = 'SSH key is required for key authentication';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one authentication method is provided
|
||||
if (authType === 'both') {
|
||||
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
|
||||
newErrors.password = 'At least one authentication method (password or SSH key) is required';
|
||||
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||
@@ -127,54 +127,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
|
||||
// Reset generated key state when switching auth types
|
||||
if (field === 'auth_type') {
|
||||
setIsGeneratedKey(false);
|
||||
setGeneratedPublicKey('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateKeyPair = async () => {
|
||||
setIsGeneratingKey(true);
|
||||
try {
|
||||
const response = await fetch('/api/servers/generate-keypair', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate key pair');
|
||||
}
|
||||
|
||||
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
|
||||
|
||||
if (data.success) {
|
||||
const serverId = data.serverId ?? 0;
|
||||
const keyPath = `data/ssh-keys/server_${serverId}_key`;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
ssh_key: data.privateKey ?? '',
|
||||
ssh_key_path: keyPath,
|
||||
key_generated: true
|
||||
}));
|
||||
setGeneratedPublicKey(data.publicKey ?? '');
|
||||
setGeneratedServerId(serverId);
|
||||
setIsGeneratedKey(true);
|
||||
setShowPublicKeyModal(true);
|
||||
setSshKeyError('');
|
||||
} else {
|
||||
throw new Error(data.error ?? 'Failed to generate key pair');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating key pair:', error);
|
||||
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
|
||||
} finally {
|
||||
setIsGeneratingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSSHKeyChange = (value: string) => {
|
||||
@@ -185,7 +137,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -270,6 +221,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
>
|
||||
<option value="password">Password Only</option>
|
||||
<option value="key">SSH Key Only</option>
|
||||
<option value="both">Both Password & SSH Key</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -295,10 +247,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
</div>
|
||||
|
||||
{/* Password Authentication */}
|
||||
{formData.auth_type === 'password' && (
|
||||
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Password *
|
||||
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -315,55 +267,19 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
)}
|
||||
|
||||
{/* SSH Key Authentication */}
|
||||
{formData.auth_type === 'key' && (
|
||||
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-muted-foreground">
|
||||
SSH Private Key *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateKeyPair}
|
||||
disabled={isGeneratingKey}
|
||||
className="gap-2"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Show manual key input only if no key has been generated */}
|
||||
{!formData.key_generated && (
|
||||
<>
|
||||
<SSHKeyInput
|
||||
value={formData.ssh_key ?? ''}
|
||||
onChange={handleSSHKeyChange}
|
||||
onError={setSshKeyError}
|
||||
/>
|
||||
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show generated key status */}
|
||||
{formData.key_generated && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
The private key has been generated and will be saved with the server.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||
</label>
|
||||
<SSHKeyInput
|
||||
value={formData.ssh_key ?? ''}
|
||||
onChange={handleSSHKeyChange}
|
||||
onError={setSshKeyError}
|
||||
/>
|
||||
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -407,16 +323,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Public Key Modal */}
|
||||
<PublicKeyModal
|
||||
isOpen={showPublicKeyModal}
|
||||
onClose={() => setShowPublicKeyModal(false)}
|
||||
publicKey={generatedPublicKey}
|
||||
serverName={formData.name || 'New Server'}
|
||||
serverIp={formData.ip}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@ import { useState } from 'react';
|
||||
import type { Server, CreateServerData } from '../../types/server';
|
||||
import { ServerForm } from './ServerForm';
|
||||
import { Button } from './ui/button';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { PublicKeyModal } from './PublicKeyModal';
|
||||
import { Key } from 'lucide-react';
|
||||
|
||||
interface ServerListProps {
|
||||
servers: Server[];
|
||||
@@ -18,20 +15,6 @@ 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 [confirmationModal, setConfirmationModal] = useState<{
|
||||
isOpen: boolean;
|
||||
variant: 'danger';
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||
const [publicKeyData, setPublicKeyData] = useState<{
|
||||
publicKey: string;
|
||||
serverName: string;
|
||||
serverIp: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleEdit = (server: Server) => {
|
||||
setEditingId(server.id);
|
||||
@@ -48,47 +31,10 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleViewPublicKey = async (server: Server) => {
|
||||
try {
|
||||
const response = await fetch(`/api/servers/${server.id}/public-key`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to retrieve public key');
|
||||
}
|
||||
|
||||
const data = await response.json() as { success: boolean; publicKey?: string; serverName?: string; serverIp?: string; error?: string };
|
||||
|
||||
if (data.success) {
|
||||
setPublicKeyData({
|
||||
publicKey: data.publicKey ?? '',
|
||||
serverName: data.serverName ?? '',
|
||||
serverIp: data.serverIp ?? ''
|
||||
});
|
||||
setShowPublicKeyModal(true);
|
||||
} else {
|
||||
throw new Error(data.error ?? 'Failed to retrieve public key');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error retrieving public key:', error);
|
||||
// You could show a toast notification here
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
const server = servers.find(s => s.id === id);
|
||||
if (!server) return;
|
||||
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
variant: 'danger',
|
||||
title: 'Delete Server',
|
||||
message: `This will permanently delete the server configuration "${server.name}" (${server.ip}) and all associated installed scripts. This action cannot be undone!`,
|
||||
confirmText: server.name,
|
||||
onConfirm: () => {
|
||||
onDelete(id);
|
||||
setConfirmationModal(null);
|
||||
}
|
||||
});
|
||||
if (window.confirm('Are you sure you want to delete this server configuration?')) {
|
||||
onDelete(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async (server: Server) => {
|
||||
@@ -192,8 +138,8 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Created: {server.created_at ? new Date(server.created_at).toLocaleDateString() : 'Unknown'}
|
||||
{server.updated_at && server.updated_at !== server.created_at && (
|
||||
Created: {new Date(server.created_at).toLocaleDateString()}
|
||||
{server.updated_at !== server.created_at && (
|
||||
<span> • Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -252,19 +198,6 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
{/* View Public Key button - only show for generated keys */}
|
||||
{server.key_generated === true && (
|
||||
<Button
|
||||
onClick={() => handleViewPublicKey(server)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
|
||||
>
|
||||
<Key className="w-4 h-4 mr-1" />
|
||||
<span className="hidden sm:inline">View Public Key</span>
|
||||
<span className="sm:hidden">Key</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleEdit(server)}
|
||||
variant="outline"
|
||||
@@ -295,35 +228,6 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmationModal && (
|
||||
<ConfirmationModal
|
||||
isOpen={confirmationModal.isOpen}
|
||||
onClose={() => setConfirmationModal(null)}
|
||||
onConfirm={confirmationModal.onConfirm}
|
||||
title={confirmationModal.title}
|
||||
message={confirmationModal.message}
|
||||
variant={confirmationModal.variant}
|
||||
confirmText={confirmationModal.confirmText}
|
||||
confirmButtonText="Delete Server"
|
||||
cancelButtonText="Cancel"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Public Key Modal */}
|
||||
{publicKeyData && (
|
||||
<PublicKeyModal
|
||||
isOpen={showPublicKeyModal}
|
||||
onClose={() => {
|
||||
setShowPublicKeyModal(false);
|
||||
setPublicKeyData(null);
|
||||
}}
|
||||
publicKey={publicKeyData.publicKey}
|
||||
serverName={publicKeyData.serverName}
|
||||
serverIp={publicKeyData.serverIp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ interface TerminalProps {
|
||||
mode?: 'local' | 'ssh';
|
||||
server?: any;
|
||||
isUpdate?: boolean;
|
||||
isShell?: boolean;
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
@@ -21,7 +20,7 @@ interface TerminalMessage {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
|
||||
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, containerId }: TerminalProps) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
@@ -333,7 +332,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
isShell,
|
||||
containerId
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
@@ -374,7 +372,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const startScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||
@@ -390,7 +388,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
isShell,
|
||||
containerId
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -37,10 +37,6 @@ const buttonVariants = cva(
|
||||
// Dark theme action button variants
|
||||
edit: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
update: "bg-cyan-900/20 hover:bg-cyan-900/30 border border-cyan-700/50 text-cyan-300 hover:text-cyan-200 hover:border-cyan-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
shell: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
openui: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
start: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
stop: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
delete: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
|
||||
save: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
|
||||
cancel: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../../../server/database-prisma.js';
|
||||
import { getSSHService } from '../../../../../server/ssh-service';
|
||||
|
||||
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 = await db.getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only allow viewing public key if it was generated by the system
|
||||
if (!(server as any).key_generated) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Public key not available for user-provided keys' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!(server as any).ssh_key_path) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SSH key path not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const sshService = getSSHService();
|
||||
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
publicKey,
|
||||
serverName: (server as any).name,
|
||||
serverIp: (server as any).ip
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error retrieving public key:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../../server/database-prisma.js';
|
||||
import { getDatabase } from '../../../../server/database';
|
||||
import type { CreateServerData } from '../../../../types/server';
|
||||
|
||||
export async function GET(
|
||||
@@ -18,7 +18,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(id);
|
||||
const server = db.getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
@@ -52,7 +52,7 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !ip || !user) {
|
||||
@@ -73,7 +73,7 @@ export async function PUT(
|
||||
// Validate authentication based on auth_type
|
||||
const authType = auth_type ?? 'password';
|
||||
|
||||
if (authType === 'password') {
|
||||
if (authType === 'password' || authType === 'both') {
|
||||
if (!password?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password is required for password authentication' },
|
||||
@@ -82,7 +82,7 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'key') {
|
||||
if (authType === 'key' || authType === 'both') {
|
||||
if (!ssh_key?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SSH key is required for key authentication' },
|
||||
@@ -91,11 +91,20 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one authentication method is provided
|
||||
if (authType === 'both') {
|
||||
if (!password?.trim() && !ssh_key?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least one authentication method (password or SSH key) is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
// Check if server exists
|
||||
const existingServer = await db.getServerById(id);
|
||||
const existingServer = db.getServerById(id);
|
||||
if (!existingServer) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
@@ -103,7 +112,7 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
await db.updateServer(id, {
|
||||
const result = db.updateServer(id, {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
@@ -112,15 +121,13 @@ export async function PUT(
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
color,
|
||||
key_generated: key_generated ?? false,
|
||||
ssh_key_path
|
||||
color
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Server updated successfully',
|
||||
changes: 1
|
||||
changes: result.changes
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -158,7 +165,7 @@ export async function DELETE(
|
||||
const db = getDatabase();
|
||||
|
||||
// Check if server exists
|
||||
const existingServer = await db.getServerById(id);
|
||||
const existingServer = db.getServerById(id);
|
||||
if (!existingServer) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
@@ -166,15 +173,12 @@ export async function DELETE(
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all installed scripts associated with this server
|
||||
await db.deleteInstalledScriptsByServer(id);
|
||||
|
||||
await db.deleteServer(id);
|
||||
const result = db.deleteServer(id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Server deleted successfully',
|
||||
changes: 1
|
||||
changes: result.changes
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../../../server/database-prisma.js';
|
||||
import { getDatabase } from '../../../../../server/database';
|
||||
import { getSSHService } from '../../../../../server/ssh-service';
|
||||
import type { Server } from '../../../../../types/server';
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(id) as Server;
|
||||
const server = db.getServerById(id) as Server;
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSSHService } from '../../../../server/ssh-service';
|
||||
import { getDatabase } from '../../../../server/database-prisma.js';
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
try {
|
||||
const sshService = getSSHService();
|
||||
const db = getDatabase();
|
||||
|
||||
// Get the next available server ID for key file naming
|
||||
const serverId = await db.getNextServerId();
|
||||
|
||||
const keyPair = await sshService.generateKeyPair(serverId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
serverId: serverId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating SSH key pair:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to generate SSH key pair'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../server/database-prisma.js';
|
||||
import { getDatabase } from '../../../server/database';
|
||||
import type { CreateServerData } from '../../../types/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const servers = await db.getAllServers();
|
||||
const servers = db.getAllServers();
|
||||
return NextResponse.json(servers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching servers:', error);
|
||||
@@ -20,7 +20,7 @@ export async function GET() {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !ip || !user) {
|
||||
@@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
|
||||
// Validate authentication based on auth_type
|
||||
const authType = auth_type ?? 'password';
|
||||
|
||||
if (authType === 'password') {
|
||||
if (authType === 'password' || authType === 'both') {
|
||||
if (!password?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password is required for password authentication' },
|
||||
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'key') {
|
||||
if (authType === 'key' || authType === 'both') {
|
||||
if (!ssh_key?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SSH key is required for key authentication' },
|
||||
@@ -59,9 +59,18 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one authentication method is provided
|
||||
if (authType === 'both') {
|
||||
if (!password?.trim() && !ssh_key?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least one authentication method (password or SSH key) is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const result = await db.createServer({
|
||||
const result = db.createServer({
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
@@ -70,15 +79,13 @@ export async function POST(request: NextRequest) {
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
color,
|
||||
key_generated: key_generated ?? false,
|
||||
ssh_key_path
|
||||
color
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Server created successfully',
|
||||
id: result.id
|
||||
id: result.lastInsertRowid
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function Home() {
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-2 sm:gap-3">
|
||||
|
||||
<Rocket className="h-6 w-6 sm:h-8 w-8 lg:h-9 lg:w-9" />
|
||||
<span className="break-words">PVE Scripts Management</span>
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { getDatabase } from "~/server/database-prisma.js";
|
||||
import { getDatabase } from "~/server/database";
|
||||
// Removed unused imports
|
||||
|
||||
|
||||
@@ -10,26 +10,10 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.query(async () => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const scripts = await db.getAllInstalledScripts();
|
||||
|
||||
// Transform scripts to flatten server data for frontend compatibility
|
||||
const transformedScripts = scripts.map(script => ({
|
||||
...script,
|
||||
server_name: script.server?.name ?? null,
|
||||
server_ip: script.server?.ip ?? null,
|
||||
server_user: script.server?.user ?? null,
|
||||
server_password: script.server?.password ?? null,
|
||||
server_auth_type: script.server?.auth_type ?? null,
|
||||
server_ssh_key: script.server?.ssh_key ?? null,
|
||||
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||
server_ssh_port: script.server?.ssh_port ?? null,
|
||||
server_color: script.server?.color ?? null,
|
||||
server: undefined // Remove nested server object
|
||||
}));
|
||||
|
||||
const scripts = db.getAllInstalledScripts();
|
||||
return {
|
||||
success: true,
|
||||
scripts: transformedScripts
|
||||
scripts
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getAllInstalledScripts:', error);
|
||||
@@ -47,26 +31,10 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const scripts = await db.getInstalledScriptsByServer(input.serverId);
|
||||
|
||||
// Transform scripts to flatten server data for frontend compatibility
|
||||
const transformedScripts = scripts.map(script => ({
|
||||
...script,
|
||||
server_name: script.server?.name ?? null,
|
||||
server_ip: script.server?.ip ?? null,
|
||||
server_user: script.server?.user ?? null,
|
||||
server_password: script.server?.password ?? null,
|
||||
server_auth_type: script.server?.auth_type ?? null,
|
||||
server_ssh_key: script.server?.ssh_key ?? null,
|
||||
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||
server_ssh_port: script.server?.ssh_port ?? null,
|
||||
server_color: script.server?.color ?? null,
|
||||
server: undefined // Remove nested server object
|
||||
}));
|
||||
|
||||
const scripts = db.getInstalledScriptsByServer(input.serverId);
|
||||
return {
|
||||
success: true,
|
||||
scripts: transformedScripts
|
||||
scripts
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getInstalledScriptsByServer:', error);
|
||||
@@ -84,7 +52,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const script = await db.getInstalledScriptById(input.id);
|
||||
const script = db.getInstalledScriptById(input.id);
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -92,24 +60,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
script: null
|
||||
};
|
||||
}
|
||||
// Transform script to flatten server data for frontend compatibility
|
||||
const transformedScript = {
|
||||
...script,
|
||||
server_name: script.server?.name ?? null,
|
||||
server_ip: script.server?.ip ?? null,
|
||||
server_user: script.server?.user ?? null,
|
||||
server_password: script.server?.password ?? null,
|
||||
server_auth_type: script.server?.auth_type ?? null,
|
||||
server_ssh_key: script.server?.ssh_key ?? null,
|
||||
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||
server_ssh_port: script.server?.ssh_port ?? null,
|
||||
server_color: script.server?.color ?? null,
|
||||
server: undefined // Remove nested server object
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
script: transformedScript
|
||||
script
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getInstalledScriptById:', error);
|
||||
@@ -130,17 +83,15 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
server_id: z.number().optional(),
|
||||
execution_mode: z.enum(['local', 'ssh']),
|
||||
status: z.enum(['in_progress', 'success', 'failed']),
|
||||
output_log: z.string().optional(),
|
||||
web_ui_ip: z.string().optional(),
|
||||
web_ui_port: z.number().optional()
|
||||
output_log: z.string().optional()
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const result = await db.createInstalledScript(input);
|
||||
const result = db.createInstalledScript(input);
|
||||
return {
|
||||
success: true,
|
||||
id: result.id,
|
||||
id: result.lastInsertRowid,
|
||||
message: 'Installed script record created successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -159,17 +110,15 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
script_name: z.string().optional(),
|
||||
container_id: z.string().optional(),
|
||||
status: z.enum(['in_progress', 'success', 'failed']).optional(),
|
||||
output_log: z.string().optional(),
|
||||
web_ui_ip: z.string().optional(),
|
||||
web_ui_port: z.number().optional()
|
||||
output_log: z.string().optional()
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const { id, ...updateData } = input;
|
||||
const db = getDatabase();
|
||||
const result = await db.updateInstalledScript(id, updateData);
|
||||
const result = db.updateInstalledScript(id, updateData);
|
||||
|
||||
if (!result) {
|
||||
if (result.changes === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No changes made or script not found'
|
||||
@@ -195,9 +144,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const result = await db.deleteInstalledScript(input.id);
|
||||
const result = db.deleteInstalledScript(input.id);
|
||||
|
||||
if (!result) {
|
||||
if (result.changes === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found or already deleted'
|
||||
@@ -222,7 +171,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.query(async () => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const allScripts = await db.getAllInstalledScripts();
|
||||
const allScripts = db.getAllInstalledScripts();
|
||||
|
||||
const stats = {
|
||||
total: allScripts.length,
|
||||
@@ -266,7 +215,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
try {
|
||||
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(input.serverId);
|
||||
const server = db.getServerById(input.serverId);
|
||||
|
||||
if (!server) {
|
||||
console.error('Server not found for ID:', input.serverId);
|
||||
@@ -397,7 +346,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
|
||||
// Get existing scripts to check for duplicates
|
||||
const existingScripts = await db.getAllInstalledScripts();
|
||||
const existingScripts = db.getAllInstalledScripts();
|
||||
|
||||
// Create installed script records for detected containers (skip duplicates)
|
||||
const createdScripts = [];
|
||||
@@ -420,7 +369,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await db.createInstalledScript({
|
||||
const result = db.createInstalledScript({
|
||||
script_name: container.hostname,
|
||||
script_path: `detected/${container.hostname}`,
|
||||
container_id: container.containerId,
|
||||
@@ -431,7 +380,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
createdScripts.push({
|
||||
id: result.id,
|
||||
id: result.lastInsertRowid,
|
||||
containerId: container.containerId,
|
||||
hostname: container.hostname,
|
||||
serverName: container.serverName
|
||||
@@ -467,8 +416,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
try {
|
||||
|
||||
const db = getDatabase();
|
||||
const allScripts = await db.getAllInstalledScripts();
|
||||
const allServers = await db.getAllServers();
|
||||
const allScripts = db.getAllInstalledScripts();
|
||||
const allServers = db.getAllServers();
|
||||
|
||||
|
||||
if (allScripts.length === 0) {
|
||||
@@ -499,7 +448,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const scriptData = script as any;
|
||||
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||
if (!server) {
|
||||
await db.deleteInstalledScript(Number(scriptData.id));
|
||||
db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
continue;
|
||||
}
|
||||
@@ -535,7 +484,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!containerExists) {
|
||||
await db.deleteInstalledScript(Number(scriptData.id));
|
||||
db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
} else {
|
||||
}
|
||||
@@ -572,7 +521,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
try {
|
||||
|
||||
const db = getDatabase();
|
||||
const allServers = await db.getAllServers();
|
||||
const allServers = db.getAllServers();
|
||||
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||
|
||||
// Import SSH services
|
||||
@@ -677,7 +626,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const script = await db.getInstalledScriptById(input.id);
|
||||
const script = db.getInstalledScriptById(input.id);
|
||||
|
||||
if (!script) {
|
||||
return {
|
||||
@@ -699,7 +648,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Get server info
|
||||
const server = await db.getServerById(Number(scriptData.server_id));
|
||||
const server = db.getServerById(Number(scriptData.server_id));
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -779,7 +728,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const script = await db.getInstalledScriptById(input.id);
|
||||
const script = db.getInstalledScriptById(input.id);
|
||||
|
||||
if (!script) {
|
||||
return {
|
||||
@@ -799,7 +748,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Get server info
|
||||
const server = await db.getServerById(Number(scriptData.server_id));
|
||||
const server = db.getServerById(Number(scriptData.server_id));
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -870,7 +819,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const script = await db.getInstalledScriptById(input.id);
|
||||
const script = db.getInstalledScriptById(input.id);
|
||||
|
||||
if (!script) {
|
||||
return {
|
||||
@@ -890,7 +839,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Get server info
|
||||
const server = await db.getServerById(Number(scriptData.server_id));
|
||||
const server = db.getServerById(Number(scriptData.server_id));
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -997,9 +946,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
// If destroy was successful, delete the database record
|
||||
const deleteResult = await db.deleteInstalledScript(input.id);
|
||||
const deleteResult = db.deleteInstalledScript(input.id);
|
||||
|
||||
if (!deleteResult) {
|
||||
if (deleteResult.changes === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Container destroyed but failed to delete database record'
|
||||
@@ -1023,179 +972,5 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
error: error instanceof Error ? error.message : 'Failed to destroy container'
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Auto-detect Web UI IP and port
|
||||
autoDetectWebUI: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
console.log('🔍 Auto-detect WebUI called with id:', input.id);
|
||||
const db = getDatabase();
|
||||
const script = await db.getInstalledScriptById(input.id);
|
||||
|
||||
if (!script) {
|
||||
console.log('❌ Script not found for id:', input.id);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found'
|
||||
};
|
||||
}
|
||||
|
||||
const scriptData = script as any;
|
||||
console.log('📋 Script data:', {
|
||||
id: scriptData.id,
|
||||
execution_mode: scriptData.execution_mode,
|
||||
server_id: scriptData.server_id,
|
||||
container_id: scriptData.container_id
|
||||
});
|
||||
|
||||
// Only works for SSH mode scripts with container_id
|
||||
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
|
||||
console.log('❌ Validation failed - not SSH mode or missing server/container ID');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-detect only works for SSH mode scripts with container ID'
|
||||
};
|
||||
}
|
||||
|
||||
// Get server info
|
||||
const server = await db.getServerById(Number(scriptData.server_id));
|
||||
if (!server) {
|
||||
console.log('❌ Server not found for id:', scriptData.server_id);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server not found'
|
||||
};
|
||||
}
|
||||
|
||||
console.log('🖥️ Server found:', { id: (server as any).id, name: (server as any).name, ip: (server as any).ip });
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
console.log('🔌 Testing SSH connection...');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
if (!(connectionTest as any).success) {
|
||||
console.log('❌ SSH connection failed:', (connectionTest as any).error);
|
||||
return {
|
||||
success: false,
|
||||
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
console.log('✅ SSH connection successful');
|
||||
|
||||
// Run hostname -I inside the container
|
||||
// Use pct exec instead of pct enter -c (which doesn't exist)
|
||||
const hostnameCommand = `pct exec ${scriptData.container_id} -- hostname -I`;
|
||||
console.log('🚀 Running command:', hostnameCommand);
|
||||
let commandOutput = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
hostnameCommand,
|
||||
(data: string) => {
|
||||
console.log('📤 Command output chunk:', data);
|
||||
commandOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.log('❌ Command error:', error);
|
||||
reject(new Error(error));
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.log('🏁 Command finished with exit code:', exitCode);
|
||||
if (exitCode !== 0) {
|
||||
reject(new Error(`Command failed with exit code ${exitCode}`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Parse output to get first IP address
|
||||
console.log('📝 Full command output:', commandOutput);
|
||||
const ips = commandOutput.trim().split(/\s+/);
|
||||
const detectedIp = ips[0];
|
||||
console.log('🔍 Parsed IPs:', ips);
|
||||
console.log('🎯 Detected IP:', detectedIp);
|
||||
|
||||
if (!detectedIp || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(detectedIp)) {
|
||||
console.log('❌ Invalid IP address detected:', detectedIp);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Could not detect valid IP address from container'
|
||||
};
|
||||
}
|
||||
|
||||
// Get the script's interface_port from metadata (prioritize metadata over existing database values)
|
||||
let detectedPort = 80; // Default fallback
|
||||
|
||||
try {
|
||||
// Import localScriptsService to get script metadata
|
||||
const { localScriptsService } = await import('~/server/services/localScripts');
|
||||
|
||||
// Get all scripts and find the one matching our script name
|
||||
const allScripts = await localScriptsService.getAllScripts();
|
||||
|
||||
// Extract script slug from script_name (remove .sh extension)
|
||||
const scriptSlug = scriptData.script_name.replace(/\.sh$/, '');
|
||||
console.log('🔍 Looking for script with slug:', scriptSlug);
|
||||
|
||||
const scriptMetadata = allScripts.find(script => script.slug === scriptSlug);
|
||||
|
||||
if (scriptMetadata?.interface_port) {
|
||||
detectedPort = scriptMetadata.interface_port;
|
||||
console.log('📋 Found interface_port in metadata:', detectedPort);
|
||||
} else {
|
||||
console.log('📋 No interface_port found in metadata, using default port 80');
|
||||
detectedPort = 80; // Default to port 80 if no metadata port found
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Error getting script metadata, using default port 80:', error);
|
||||
detectedPort = 80; // Default to port 80 if metadata lookup fails
|
||||
}
|
||||
|
||||
console.log('🎯 Final detected port:', detectedPort);
|
||||
|
||||
// Update the database with detected IP and port
|
||||
console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort);
|
||||
const updateResult = await db.updateInstalledScript(input.id, {
|
||||
web_ui_ip: detectedIp,
|
||||
web_ui_port: detectedPort
|
||||
});
|
||||
|
||||
if (!updateResult) {
|
||||
console.log('❌ Database update failed - no changes made');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update database with detected IP'
|
||||
};
|
||||
}
|
||||
|
||||
console.log('✅ Successfully updated database');
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully detected IP: ${detectedIp}:${detectedPort} for LXC ${scriptData.container_id} on ${(server as any).name}`,
|
||||
detectedIp,
|
||||
detectedPort: detectedPort,
|
||||
containerId: scriptData.container_id,
|
||||
serverName: (server as any).name
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in autoDetectWebUI:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP'
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { getDatabase } from "~/server/database-prisma.js";
|
||||
import { getDatabase } from "~/server/database";
|
||||
|
||||
export const serversRouter = createTRPCRouter({
|
||||
getAllServers: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const servers = await db.getAllServers();
|
||||
const servers = db.getAllServers();
|
||||
return { success: true, servers };
|
||||
} catch (error) {
|
||||
console.error('Error fetching servers:', error);
|
||||
@@ -24,7 +24,7 @@ export const serversRouter = createTRPCRouter({
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(input.id);
|
||||
const server = db.getServerById(input.id);
|
||||
if (!server) {
|
||||
return { success: false, error: 'Server not found', server: null };
|
||||
}
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { prisma } from './db.js';
|
||||
import { join } from 'path';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
class DatabaseServicePrisma {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ensure data/ssh-keys directory exists
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
if (!existsSync(sshKeysDir)) {
|
||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
// Server CRUD operations
|
||||
async createServer(serverData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
let ssh_key_path = null;
|
||||
|
||||
// If using SSH key authentication, create persistent key file
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
const serverId = await this.getNextServerId();
|
||||
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||
}
|
||||
|
||||
return await prisma.server.create({
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: auth_type ?? 'password',
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
ssh_key_path,
|
||||
key_generated: Boolean(key_generated),
|
||||
color,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllServers() {
|
||||
return await prisma.server.findMany({
|
||||
orderBy: { created_at: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async getServerById(id) {
|
||||
return await prisma.server.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async updateServer(id, serverData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
// Get existing server to check for key changes
|
||||
const existingServer = await this.getServerById(id);
|
||||
let ssh_key_path = existingServer?.ssh_key_path;
|
||||
|
||||
// Handle SSH key changes
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
// Delete old key file if it exists
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
// Also delete public key file if it exists
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete old SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new key file
|
||||
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||
} else if (auth_type !== 'key') {
|
||||
// If switching away from key auth, delete key files
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
ssh_key_path = null;
|
||||
}
|
||||
|
||||
return await prisma.server.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: auth_type ?? 'password',
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
ssh_key_path,
|
||||
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
|
||||
color,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteServer(id) {
|
||||
// Get server info before deletion to clean up key files
|
||||
const server = await this.getServerById(id);
|
||||
|
||||
// Delete SSH key files if they exist
|
||||
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(server.ssh_key_path);
|
||||
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.server.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
// Installed Scripts CRUD operations
|
||||
async createInstalledScript(scriptData) {
|
||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||
|
||||
return await prisma.installedScript.create({
|
||||
data: {
|
||||
script_name,
|
||||
script_path,
|
||||
container_id: container_id ?? null,
|
||||
server_id: server_id ?? null,
|
||||
execution_mode,
|
||||
status,
|
||||
output_log: output_log ?? null,
|
||||
web_ui_ip: web_ui_ip ?? null,
|
||||
web_ui_port: web_ui_port ?? null,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllInstalledScripts() {
|
||||
return await prisma.installedScript.findMany({
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledScriptById(id) {
|
||||
return await prisma.installedScript.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledScriptsByServer(server_id) {
|
||||
return await prisma.installedScript.findMany({
|
||||
where: { server_id },
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async updateInstalledScript(id, updateData) {
|
||||
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||
|
||||
const updateFields = {};
|
||||
if (script_name !== undefined) updateFields.script_name = script_name;
|
||||
if (container_id !== undefined) updateFields.container_id = container_id;
|
||||
if (status !== undefined) updateFields.status = status;
|
||||
if (output_log !== undefined) updateFields.output_log = output_log;
|
||||
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
|
||||
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
return await prisma.installedScript.update({
|
||||
where: { id },
|
||||
data: updateFields
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstalledScript(id) {
|
||||
return await prisma.installedScript.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstalledScriptsByServer(server_id) {
|
||||
return await prisma.installedScript.deleteMany({
|
||||
where: { server_id }
|
||||
});
|
||||
}
|
||||
|
||||
async getNextServerId() {
|
||||
const result = await prisma.server.findFirst({
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true }
|
||||
});
|
||||
return (result?.id ?? 0) + 1;
|
||||
}
|
||||
|
||||
createSSHKeyFile(serverId, sshKey) {
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||
writeFileSync(keyPath, normalizedKey);
|
||||
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let dbInstance = null;
|
||||
|
||||
export function getDatabase() {
|
||||
dbInstance ??= new DatabaseServicePrisma();
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export default DatabaseServicePrisma;
|
||||
@@ -1,279 +0,0 @@
|
||||
import { prisma } from './db';
|
||||
import { join } from 'path';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
import type { CreateServerData } from '../types/server';
|
||||
|
||||
class DatabaseServicePrisma {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ensure data/ssh-keys directory exists
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
if (!existsSync(sshKeysDir)) {
|
||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
// Server CRUD operations
|
||||
async createServer(serverData: CreateServerData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
let ssh_key_path = null;
|
||||
|
||||
// If using SSH key authentication, create persistent key file
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
const serverId = await this.getNextServerId();
|
||||
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||
}
|
||||
|
||||
return await prisma.server.create({
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: auth_type ?? 'password',
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
ssh_key_path,
|
||||
key_generated: Boolean(key_generated),
|
||||
color,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllServers() {
|
||||
return await prisma.server.findMany({
|
||||
orderBy: { created_at: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async getServerById(id: number) {
|
||||
return await prisma.server.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async updateServer(id: number, serverData: CreateServerData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
// Get existing server to check for key changes
|
||||
const existingServer = await this.getServerById(id);
|
||||
let ssh_key_path = existingServer?.ssh_key_path;
|
||||
|
||||
// Handle SSH key changes
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
// Delete old key file if it exists
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
// Also delete public key file if it exists
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete old SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new key file
|
||||
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||
} else if (auth_type !== 'key') {
|
||||
// If switching away from key auth, delete key files
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
ssh_key_path = null;
|
||||
}
|
||||
|
||||
return await prisma.server.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: auth_type ?? 'password',
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
ssh_key_path,
|
||||
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
|
||||
color,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteServer(id: number) {
|
||||
// Get server info before deletion to clean up key files
|
||||
const server = await this.getServerById(id);
|
||||
|
||||
// Delete SSH key files if they exist
|
||||
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(server.ssh_key_path);
|
||||
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.server.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
// Installed Scripts CRUD operations
|
||||
async createInstalledScript(scriptData: {
|
||||
script_name: string;
|
||||
script_path: string;
|
||||
container_id?: string;
|
||||
server_id?: number;
|
||||
execution_mode: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
}) {
|
||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||
|
||||
return await prisma.installedScript.create({
|
||||
data: {
|
||||
script_name,
|
||||
script_path,
|
||||
container_id: container_id ?? null,
|
||||
server_id: server_id ?? null,
|
||||
execution_mode,
|
||||
status,
|
||||
output_log: output_log ?? null,
|
||||
web_ui_ip: web_ui_ip ?? null,
|
||||
web_ui_port: web_ui_port ?? null,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllInstalledScripts() {
|
||||
return await prisma.installedScript.findMany({
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledScriptById(id: number) {
|
||||
return await prisma.installedScript.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledScriptsByServer(server_id: number) {
|
||||
return await prisma.installedScript.findMany({
|
||||
where: { server_id },
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async updateInstalledScript(id: number, updateData: {
|
||||
script_name?: string;
|
||||
container_id?: string;
|
||||
status?: 'in_progress' | 'success' | 'failed';
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
}) {
|
||||
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||
|
||||
const updateFields: {
|
||||
script_name?: string;
|
||||
container_id?: string;
|
||||
status?: 'in_progress' | 'success' | 'failed';
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
} = {};
|
||||
if (script_name !== undefined) updateFields.script_name = script_name;
|
||||
if (container_id !== undefined) updateFields.container_id = container_id;
|
||||
if (status !== undefined) updateFields.status = status;
|
||||
if (output_log !== undefined) updateFields.output_log = output_log;
|
||||
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
|
||||
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
return await prisma.installedScript.update({
|
||||
where: { id },
|
||||
data: updateFields
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstalledScript(id: number) {
|
||||
return await prisma.installedScript.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstalledScriptsByServer(server_id: number) {
|
||||
return await prisma.installedScript.deleteMany({
|
||||
where: { server_id }
|
||||
});
|
||||
}
|
||||
|
||||
async getNextServerId() {
|
||||
const result = await prisma.server.findFirst({
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true }
|
||||
});
|
||||
return (result?.id ?? 0) + 1;
|
||||
}
|
||||
|
||||
createSSHKeyFile(serverId: number, sshKey: string) {
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||
writeFileSync(keyPath, normalizedKey);
|
||||
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let dbInstance: DatabaseServicePrisma | null = null;
|
||||
|
||||
export function getDatabase() {
|
||||
dbInstance ??= new DatabaseServicePrisma();
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export default DatabaseServicePrisma;
|
||||
292
src/server/database.js
Normal file
292
src/server/database.js
Normal file
@@ -0,0 +1,292 @@
|
||||
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,
|
||||
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')),
|
||||
ssh_key TEXT,
|
||||
ssh_key_passphrase TEXT,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
color TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add new columns to existing servers table
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both'))
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_key TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN color TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Update existing servers to have auth_type='password' if not set
|
||||
this.db.exec(`
|
||||
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
|
||||
`);
|
||||
|
||||
// Update existing servers to have ssh_port=22 if not set
|
||||
this.db.exec(`
|
||||
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
|
||||
`);
|
||||
|
||||
// Create installed_scripts table if it doesn't exist
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
script_name TEXT NOT NULL,
|
||||
script_path TEXT NOT NULL,
|
||||
container_id TEXT,
|
||||
server_id INTEGER,
|
||||
execution_mode TEXT NOT NULL CHECK(execution_mode IN ('local', 'ssh')),
|
||||
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
|
||||
output_log TEXT,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// 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, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color);
|
||||
}
|
||||
|
||||
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, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE servers
|
||||
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
deleteServer(id) {
|
||||
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
|
||||
return stmt.run(id);
|
||||
}
|
||||
|
||||
// Installed Scripts CRUD operations
|
||||
/**
|
||||
* @param {Object} scriptData
|
||||
* @param {string} scriptData.script_name
|
||||
* @param {string} scriptData.script_path
|
||||
* @param {string} [scriptData.container_id]
|
||||
* @param {number} [scriptData.server_id]
|
||||
* @param {string} scriptData.execution_mode
|
||||
* @param {string} scriptData.status
|
||||
* @param {string} [scriptData.output_log]
|
||||
*/
|
||||
createInstalledScript(scriptData) {
|
||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log } = scriptData;
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null);
|
||||
}
|
||||
|
||||
getAllInstalledScripts() {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
inst.*,
|
||||
s.name as server_name,
|
||||
s.ip as server_ip,
|
||||
s.user as server_user,
|
||||
s.password as server_password,
|
||||
s.color as server_color
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
ORDER BY inst.installation_date DESC
|
||||
`);
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
getInstalledScriptById(id) {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
inst.*,
|
||||
s.name as server_name,
|
||||
s.ip as server_ip
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
WHERE inst.id = ?
|
||||
`);
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} server_id
|
||||
*/
|
||||
getInstalledScriptsByServer(server_id) {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
inst.*,
|
||||
s.name as server_name,
|
||||
s.ip as server_ip
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
WHERE inst.server_id = ?
|
||||
ORDER BY inst.installation_date DESC
|
||||
`);
|
||||
return stmt.all(server_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {Object} updateData
|
||||
* @param {string} [updateData.script_name]
|
||||
* @param {string} [updateData.container_id]
|
||||
* @param {string} [updateData.status]
|
||||
* @param {string} [updateData.output_log]
|
||||
*/
|
||||
updateInstalledScript(id, updateData) {
|
||||
const { script_name, container_id, status, output_log } = updateData;
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
if (script_name !== undefined) {
|
||||
updates.push('script_name = ?');
|
||||
values.push(script_name);
|
||||
}
|
||||
if (container_id !== undefined) {
|
||||
updates.push('container_id = ?');
|
||||
values.push(container_id);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.push('status = ?');
|
||||
values.push(status);
|
||||
}
|
||||
if (output_log !== undefined) {
|
||||
updates.push('output_log = ?');
|
||||
values.push(output_log);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE installed_scripts
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
return stmt.run(...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
deleteInstalledScript(id) {
|
||||
const stmt = this.db.prepare('DELETE FROM installed_scripts 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;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis;
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn as ptySpawn } from 'node-pty';
|
||||
import { existsSync } from 'fs';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
|
||||
/**
|
||||
@@ -9,22 +11,43 @@ import { existsSync } from 'fs';
|
||||
* @property {string} user - Username
|
||||
* @property {string} [password] - Password (optional)
|
||||
* @property {string} name - Server name
|
||||
* @property {string} [auth_type] - Authentication type ('password', 'key')
|
||||
* @property {string} [auth_type] - Authentication type ('password', 'key', 'both')
|
||||
* @property {string} [ssh_key] - SSH private key content
|
||||
* @property {string} [ssh_key_passphrase] - SSH key passphrase
|
||||
* @property {string} [ssh_key_path] - Path to persistent SSH key file
|
||||
* @property {number} [ssh_port] - SSH port (default: 22)
|
||||
*/
|
||||
|
||||
class SSHExecutionService {
|
||||
/**
|
||||
* Create a temporary SSH key file for authentication
|
||||
* @param {Server} server - Server configuration
|
||||
* @returns {string} Path to temporary key file
|
||||
*/
|
||||
createTempKeyFile(server) {
|
||||
const { ssh_key } = server;
|
||||
if (!ssh_key) {
|
||||
throw new Error('SSH key not provided');
|
||||
}
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
||||
const tempKeyPath = join(tempDir, 'private_key');
|
||||
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = ssh_key.trimEnd() + '\n';
|
||||
writeFileSync(tempKeyPath, normalizedKey);
|
||||
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||
|
||||
return tempKeyPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH command arguments based on authentication type
|
||||
* @param {Server} server - Server configuration
|
||||
* @param {string|null} [tempKeyPath=null] - Path to temporary key file (if using key auth)
|
||||
* @returns {{command: string, args: string[]}} Command and arguments for SSH
|
||||
*/
|
||||
buildSSHCommand(server) {
|
||||
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
|
||||
buildSSHCommand(server, tempKeyPath = null) {
|
||||
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server;
|
||||
|
||||
const baseArgs = [
|
||||
'-t',
|
||||
@@ -46,14 +69,12 @@ class SSHExecutionService {
|
||||
|
||||
if (auth_type === 'key') {
|
||||
// SSH key authentication
|
||||
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||
throw new Error('SSH key file not found');
|
||||
if (tempKeyPath) {
|
||||
baseArgs.push('-i', tempKeyPath);
|
||||
baseArgs.push('-o', 'PasswordAuthentication=no');
|
||||
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||
}
|
||||
|
||||
baseArgs.push('-i', ssh_key_path);
|
||||
baseArgs.push('-o', 'PasswordAuthentication=no');
|
||||
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||
|
||||
if (ssh_key_passphrase) {
|
||||
return {
|
||||
command: 'sshpass',
|
||||
@@ -65,6 +86,35 @@ class SSHExecutionService {
|
||||
args: [...baseArgs, `${user}@${ip}`]
|
||||
};
|
||||
}
|
||||
} else if (auth_type === 'both') {
|
||||
// Try SSH key first, then password
|
||||
if (tempKeyPath) {
|
||||
baseArgs.push('-i', tempKeyPath);
|
||||
baseArgs.push('-o', 'PasswordAuthentication=yes');
|
||||
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||
|
||||
if (ssh_key_passphrase) {
|
||||
return {
|
||||
command: 'sshpass',
|
||||
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
command: 'ssh',
|
||||
args: [...baseArgs, `${user}@${ip}`]
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Fallback to password
|
||||
if (password) {
|
||||
return {
|
||||
command: 'sshpass',
|
||||
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
|
||||
};
|
||||
} else {
|
||||
throw new Error('Password is required for password authentication');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Password authentication (default)
|
||||
if (password) {
|
||||
@@ -88,6 +138,9 @@ class SSHExecutionService {
|
||||
* @returns {Promise<Object>} Process information
|
||||
*/
|
||||
async executeScript(server, scriptPath, onData, onError, onExit) {
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
try {
|
||||
await this.transferScriptsFolder(server, onData, onError);
|
||||
|
||||
@@ -95,8 +148,13 @@ class SSHExecutionService {
|
||||
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
||||
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
|
||||
// Build SSH command based on authentication type
|
||||
const { command, args } = this.buildSSHCommand(server);
|
||||
const { command, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||
|
||||
// Add the script execution command to the args
|
||||
args.push(`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}`);
|
||||
@@ -135,10 +193,30 @@ class SSHExecutionService {
|
||||
process: sshCommand,
|
||||
kill: () => {
|
||||
sshCommand.kill('SIGTERM');
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Clean up temporary key file on error
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
@@ -157,24 +235,35 @@ class SSHExecutionService {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async transferScriptsFolder(server, onData, onError) {
|
||||
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
|
||||
const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (auth_type === 'key' || auth_type === 'both') {
|
||||
if (ssh_key) {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
}
|
||||
|
||||
// Build rsync command based on authentication type
|
||||
let rshCommand;
|
||||
if (auth_type === 'key') {
|
||||
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||
throw new Error('SSH key file not found');
|
||||
}
|
||||
|
||||
if (auth_type === 'key' && tempKeyPath) {
|
||||
if (ssh_key_passphrase) {
|
||||
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
} else {
|
||||
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
}
|
||||
} else if (auth_type === 'both' && tempKeyPath) {
|
||||
if (ssh_key_passphrase) {
|
||||
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
} else {
|
||||
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
}
|
||||
} else {
|
||||
// Password authentication
|
||||
// Fallback to password authentication
|
||||
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
}
|
||||
|
||||
@@ -203,6 +292,17 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
rsyncCommand.on('close', (code) => {
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
@@ -211,10 +311,30 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
rsyncCommand.on('error', (error) => {
|
||||
// Clean up temporary key file on error
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Clean up temporary key file on error
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
@@ -230,10 +350,18 @@ class SSHExecutionService {
|
||||
* @returns {Promise<Object>} Process information
|
||||
*/
|
||||
async executeCommand(server, command, onData, onError, onExit) {
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
|
||||
// Build SSH command based on authentication type
|
||||
const { command: sshCommandName, args } = this.buildSSHCommand(server);
|
||||
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||
|
||||
// Add the command to execute to the args
|
||||
args.push(command);
|
||||
@@ -252,6 +380,16 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
sshCommand.onExit((e) => {
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
onExit(e.exitCode);
|
||||
});
|
||||
|
||||
@@ -259,10 +397,30 @@ class SSHExecutionService {
|
||||
process: sshCommand,
|
||||
kill: () => {
|
||||
sshCommand.kill('SIGTERM');
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Clean up temporary key file on error
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync, readFileSync, existsSync } from 'fs';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
@@ -21,6 +21,9 @@ class SSHService {
|
||||
let authPromise;
|
||||
if (auth_type === 'key') {
|
||||
authPromise = this.testWithSSHKey(server);
|
||||
} else if (auth_type === 'both') {
|
||||
// Try SSH key first, then password
|
||||
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
|
||||
} else {
|
||||
// Default to password authentication
|
||||
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
|
||||
@@ -537,20 +540,31 @@ expect {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testWithSSHKey(server) {
|
||||
const { ip, user, ssh_key_path, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||
|
||||
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||
throw new Error('SSH key file not found');
|
||||
if (!ssh_key) {
|
||||
throw new Error('SSH key not provided');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 10000;
|
||||
let resolved = false;
|
||||
let tempKeyPath = null;
|
||||
|
||||
try {
|
||||
// Create temporary key file
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
||||
tempKeyPath = join(tempDir, 'private_key');
|
||||
|
||||
// Write the private key to temporary file
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = ssh_key.trimEnd() + '\n';
|
||||
writeFileSync(tempKeyPath, normalizedKey);
|
||||
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||
|
||||
// Build SSH command
|
||||
const sshArgs = [
|
||||
'-i', ssh_key_path,
|
||||
'-i', tempKeyPath,
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
@@ -648,82 +662,22 @@ expect {
|
||||
resolved = true;
|
||||
reject(error);
|
||||
}
|
||||
} finally {
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
// Also remove the temp directory
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SSH key pair for a server
|
||||
* @param {number} serverId - Server ID for key file naming
|
||||
* @returns {Promise<{privateKey: string, publicKey: string}>}
|
||||
*/
|
||||
async generateKeyPair(serverId) {
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const sshKeygen = spawn('ssh-keygen', [
|
||||
'-t', 'ed25519',
|
||||
'-f', keyPath,
|
||||
'-N', '', // No passphrase
|
||||
'-C', 'pve-scripts-local'
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let errorOutput = '';
|
||||
|
||||
sshKeygen.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
sshKeygen.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
// Read the generated private key
|
||||
const privateKey = readFileSync(keyPath, 'utf8');
|
||||
|
||||
// Read the generated public key
|
||||
const publicKeyPath = keyPath + '.pub';
|
||||
const publicKey = readFileSync(publicKeyPath, 'utf8');
|
||||
|
||||
// Set proper permissions
|
||||
chmodSync(keyPath, 0o600);
|
||||
chmodSync(publicKeyPath, 0o644);
|
||||
|
||||
resolve({
|
||||
privateKey,
|
||||
publicKey: publicKey.trim()
|
||||
});
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to read generated key files: ${error instanceof Error ? error.message : String(error)}`));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`ssh-keygen failed: ${errorOutput}`));
|
||||
}
|
||||
});
|
||||
|
||||
sshKeygen.on('error', (error) => {
|
||||
reject(new Error(`Failed to run ssh-keygen: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public key from private key file
|
||||
* @param {string} keyPath - Path to private key file
|
||||
* @returns {string} Public key content
|
||||
*/
|
||||
getPublicKey(keyPath) {
|
||||
const publicKeyPath = keyPath + '.pub';
|
||||
|
||||
if (!existsSync(publicKeyPath)) {
|
||||
throw new Error('Public key file not found');
|
||||
}
|
||||
|
||||
return readFileSync(publicKeyPath, 'utf8').trim();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
|
||||
@@ -4,15 +4,13 @@ export interface Server {
|
||||
ip: string;
|
||||
user: string;
|
||||
password?: string;
|
||||
auth_type?: 'password' | 'key';
|
||||
auth_type?: 'password' | 'key' | 'both';
|
||||
ssh_key?: string;
|
||||
ssh_key_passphrase?: string;
|
||||
ssh_key_path?: string;
|
||||
key_generated?: boolean;
|
||||
ssh_port?: number;
|
||||
color?: string;
|
||||
created_at: Date | null;
|
||||
updated_at: Date | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateServerData {
|
||||
@@ -20,11 +18,9 @@ export interface CreateServerData {
|
||||
ip: string;
|
||||
user: string;
|
||||
password?: string;
|
||||
auth_type?: 'password' | 'key';
|
||||
auth_type?: 'password' | 'key' | 'both';
|
||||
ssh_key?: string;
|
||||
ssh_key_passphrase?: string;
|
||||
ssh_key_path?: string;
|
||||
key_generated?: boolean;
|
||||
ssh_port?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
59
update.sh
59
update.sh
@@ -412,36 +412,6 @@ restore_backup_files() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure DATABASE_URL is set in .env file for Prisma
|
||||
ensure_database_url() {
|
||||
log "Ensuring DATABASE_URL is set in .env file..."
|
||||
|
||||
# Check if .env file exists
|
||||
if [ ! -f ".env" ]; then
|
||||
log_warning ".env file not found, creating from .env.example..."
|
||||
if [ -f ".env.example" ]; then
|
||||
cp ".env.example" ".env"
|
||||
else
|
||||
log_error ".env.example not found, cannot create .env file"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if DATABASE_URL is already set
|
||||
if grep -q "^DATABASE_URL=" .env; then
|
||||
log "DATABASE_URL already exists in .env file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Add DATABASE_URL to .env file
|
||||
log "Adding DATABASE_URL to .env file..."
|
||||
echo "" >> .env
|
||||
echo "# Database" >> .env
|
||||
echo "DATABASE_URL=\"file:./data/database.sqlite\"" >> .env
|
||||
|
||||
log_success "DATABASE_URL added to .env file"
|
||||
}
|
||||
|
||||
# Check if systemd service exists
|
||||
check_service() {
|
||||
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
|
||||
@@ -637,32 +607,6 @@ install_and_build() {
|
||||
log_success "Dependencies installed successfully"
|
||||
rm -f "$npm_log"
|
||||
|
||||
# Generate Prisma client
|
||||
log "Generating Prisma client..."
|
||||
if ! npx prisma generate > "$npm_log" 2>&1; then
|
||||
log_error "Failed to generate Prisma client"
|
||||
log_error "Prisma generate output:"
|
||||
cat "$npm_log" | while read -r line; do
|
||||
log_error "PRISMA: $line"
|
||||
done
|
||||
rm -f "$npm_log"
|
||||
return 1
|
||||
fi
|
||||
log_success "Prisma client generated successfully"
|
||||
|
||||
# Run Prisma migrations
|
||||
log "Running Prisma migrations..."
|
||||
if ! npx prisma migrate deploy > "$npm_log" 2>&1; then
|
||||
log_warning "Prisma migrations failed or no migrations to run"
|
||||
log "Prisma migrate output:"
|
||||
cat "$npm_log" | while read -r line; do
|
||||
log "PRISMA: $line"
|
||||
done
|
||||
else
|
||||
log_success "Prisma migrations completed successfully"
|
||||
fi
|
||||
rm -f "$npm_log"
|
||||
|
||||
log "Building application..."
|
||||
# Set NODE_ENV to production for build
|
||||
export NODE_ENV=production
|
||||
@@ -894,9 +838,6 @@ main() {
|
||||
# Restore .env and data directory before building
|
||||
restore_backup_files
|
||||
|
||||
# Ensure DATABASE_URL is set for Prisma
|
||||
ensure_database_url
|
||||
|
||||
# Install dependencies and build
|
||||
if ! install_and_build; then
|
||||
log_error "Install and build failed, rolling back..."
|
||||
|
||||
Reference in New Issue
Block a user