Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
b0e7e94a23 chore: add VERSION v0.4.0 2025-10-13 14:37:11 +00:00
51 changed files with 1630 additions and 7121 deletions

View File

@@ -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=

View File

@@ -1,15 +1,12 @@
# Template for release drafts
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_PATCH_VERSION'
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_MINOR_VERSION'
# Exclude PRs with this label from release notes
exclude-labels:
- automated
categories:
- title: "Breaking Changes"
labels:
- breaking
- title: "🚀 Features"
labels:
- feature

6
.gitignore vendored
View File

@@ -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
View File

@@ -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
```

View File

@@ -1 +1 @@
0.4.4
0.4.0

3078
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.87.4",
"@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",
"next": "^15.5.5",
"lucide-react": "^0.545.0",
"next": "^15.5.3",
"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,22 +61,21 @@
"@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.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@types/react-dom": "^19.0.0",
"@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",
"typescript-eslint": "^8.27.0",
"vitest": "^3.2.4"
},
"ct3aMetadata": {

View File

@@ -1,74 +0,0 @@
-- CreateTable
CREATE TABLE "installed_scripts" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"script_name" TEXT NOT NULL,
"script_path" TEXT NOT NULL,
"container_id" TEXT,
"server_id" INTEGER,
"execution_mode" TEXT NOT NULL,
"installation_date" DATETIME DEFAULT CURRENT_TIMESTAMP,
"status" TEXT NOT NULL,
"output_log" TEXT,
"web_ui_ip" TEXT,
"web_ui_port" INTEGER,
CONSTRAINT "installed_scripts_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "servers" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"user" TEXT NOT NULL,
"password" TEXT,
"auth_type" TEXT DEFAULT 'password',
"ssh_key" TEXT,
"ssh_key_passphrase" TEXT,
"ssh_port" INTEGER DEFAULT 22,
"color" TEXT,
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME,
"ssh_key_path" TEXT,
"key_generated" BOOLEAN DEFAULT false
);
-- CreateTable
CREATE TABLE "lxc_configs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"installed_script_id" INTEGER NOT NULL,
"arch" TEXT,
"cores" INTEGER,
"memory" INTEGER,
"hostname" TEXT,
"swap" INTEGER,
"onboot" INTEGER,
"ostype" TEXT,
"unprivileged" INTEGER,
"net_name" TEXT,
"net_bridge" TEXT,
"net_hwaddr" TEXT,
"net_ip_type" TEXT,
"net_ip" TEXT,
"net_gateway" TEXT,
"net_type" TEXT,
"net_vlan" INTEGER,
"rootfs_storage" TEXT,
"rootfs_size" TEXT,
"feature_keyctl" INTEGER,
"feature_nesting" INTEGER,
"feature_fuse" INTEGER,
"feature_mount" TEXT,
"tags" TEXT,
"advanced_config" TEXT,
"synced_at" DATETIME,
"config_hash" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL,
CONSTRAINT "lxc_configs_installed_script_id_fkey" FOREIGN KEY ("installed_script_id") REFERENCES "installed_scripts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "servers_name_key" ON "servers"("name");
-- CreateIndex
CREATE UNIQUE INDEX "lxc_configs_installed_script_id_key" ON "lxc_configs"("installed_script_id");

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -1,97 +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)
lxc_config LXCConfig?
@@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")
}
model LXCConfig {
id Int @id @default(autoincrement())
installed_script_id Int @unique
installed_script InstalledScript @relation(fields: [installed_script_id], references: [id], onDelete: Cascade)
// Basic settings
arch String?
cores Int?
memory Int?
hostname String?
swap Int?
onboot Int? // 0 or 1
ostype String?
unprivileged Int? // 0 or 1
// Network settings (net0)
net_name String?
net_bridge String?
net_hwaddr String?
net_ip_type String? // 'dhcp' or 'static'
net_ip String? // IP with CIDR for static
net_gateway String?
net_type String? // usually 'veth'
net_vlan Int?
// Storage
rootfs_storage String?
rootfs_size String?
// Features
feature_keyctl Int? // 0 or 1
feature_nesting Int? // 0 or 1
feature_fuse Int? // 0 or 1
feature_mount String? // other mount features
// Tags
tags String?
// Advanced/raw settings (lxc.* entries and other uncommon settings)
advanced_config String? // Text blob for advanced settings
// Metadata
synced_at DateTime?
config_hash String? // Hash of server config for diff detection
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@map("lxc_configs")
}

41
scripts/json/openwrt.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "OpenWrt",
"slug": "openwrt",
"categories": [
4,
2
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"privileged": false,
"interface_port": null,
"documentation": "https://openwrt.org/docs/start",
"website": "https://openwrt.org/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/openwrt.webp",
"config_path": "",
"description": "OpenWrt is a powerful open-source firmware that can transform a wide range of networking devices into highly customizable and feature-rich routers, providing users with greater control and flexibility over their network infrastructure.",
"install_methods": [
{
"type": "default",
"script": "vm/openwrt.sh",
"resources": {
"cpu": 1,
"ram": 256,
"hdd": 0.5,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "If you use VLANs (default LAN is set to VLAN 999), make sure the Proxmox Linux Bridge is configured as VLAN-aware, otherwise the VM may fail to start.",
"type": "info"
}
]
}

35
scripts/json/petio.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "Petio",
"slug": "petio",
"categories": [
13
],
"date_created": "2024-06-12",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 7777,
"documentation": "https://docs.petio.tv/",
"website": "https://petio.tv/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/petio.webp",
"config_path": "",
"description": "Petio is a third party companion app available to Plex server owners to allow their users to request, review and discover content.",
"install_methods": [
{
"type": "default",
"script": "ct/petio.sh",
"resources": {
"cpu": 2,
"ram": 1024,
"hdd": 4,
"os": "ubuntu",
"version": "20.04"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

251
server.js
View File

@@ -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

View File

@@ -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}

View File

@@ -385,7 +385,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
);
}
if (!downloadedScripts?.length) {
if (!downloadedScripts || downloadedScripts.length === 0) {
return (
<div className="text-center py-12">
<div className="text-muted-foreground">

View File

@@ -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>

View File

@@ -12,10 +12,10 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
const { data: versionData } = api.version.getCurrentVersion.useQuery();
return (
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-3 backdrop-blur-sm">
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-6 backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>© 2024 PVE Scripts Local</span>
{versionData?.success && versionData.version && (
<Button
@@ -29,7 +29,7 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"

View File

@@ -10,7 +10,7 @@ interface HelpModalProps {
initialSection?: string;
}
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'update-system';
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
@@ -24,7 +24,6 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
{ id: 'lxc-settings' as HelpSection, label: 'LXC Settings', icon: Settings },
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
];
@@ -56,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">
@@ -327,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>
@@ -344,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>
@@ -502,131 +454,6 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
</div>
);
case 'lxc-settings':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">LXC Settings</h3>
<p className="text-muted-foreground mb-6">
Edit LXC container configuration files directly from the installed scripts interface. This feature allows you to modify container settings without manually accessing the Proxmox VE server.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Overview</h4>
<p className="text-sm text-muted-foreground mb-3">
The LXC Settings modal provides a user-friendly interface to edit container configuration files. It parses common settings into editable fields while preserving advanced configurations.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Common Settings:</strong> Edit basic container parameters like cores, memory, network, and storage</li>
<li> <strong>Advanced Settings:</strong> Raw text editing for lxc.* entries and other advanced configurations</li>
<li> <strong>Database Caching:</strong> Configurations are cached locally for faster access</li>
<li> <strong>Change Detection:</strong> Warns when cached config differs from server version</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Common Settings Tab</h4>
<div className="space-y-3">
<div>
<h5 className="font-medium text-sm text-foreground mb-1">Basic Configuration</h5>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Architecture:</strong> Container architecture (usually amd64)</li>
<li> <strong>Cores:</strong> Number of CPU cores allocated to the container</li>
<li> <strong>Memory:</strong> RAM allocation in megabytes</li>
<li> <strong>Swap:</strong> Swap space allocation in megabytes</li>
<li> <strong>Hostname:</strong> Container hostname</li>
<li> <strong>OS Type:</strong> Operating system type (e.g., debian, ubuntu)</li>
<li> <strong>Start on Boot:</strong> Whether to start container automatically on host boot</li>
<li> <strong>Unprivileged:</strong> Whether the container runs in unprivileged mode</li>
</ul>
</div>
<div>
<h5 className="font-medium text-sm text-foreground mb-1">Network Configuration</h5>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>IP Configuration:</strong> Choose between DHCP or static IP assignment</li>
<li> <strong>IP Address:</strong> Static IP with CIDR notation (e.g., 10.10.10.164/24)</li>
<li> <strong>Gateway:</strong> Network gateway for static IP configuration</li>
<li> <strong>Bridge:</strong> Network bridge interface (usually vmbr0)</li>
<li> <strong>MAC Address:</strong> Hardware address for the network interface</li>
<li> <strong>VLAN Tag:</strong> Optional VLAN tag for network segmentation</li>
</ul>
</div>
<div>
<h5 className="font-medium text-sm text-foreground mb-1">Storage & Features</h5>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Root Filesystem:</strong> Storage location and disk identifier</li>
<li> <strong>Size:</strong> Disk size allocation (e.g., 4G, 8G)</li>
<li> <strong>Features:</strong> Container capabilities (keyctl, nesting, fuse)</li>
<li> <strong>Tags:</strong> Comma-separated tags for organization</li>
</ul>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Advanced Settings Tab</h4>
<p className="text-sm text-muted-foreground mb-3">
The Advanced Settings tab provides raw text editing for configurations not covered in the Common Settings tab.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>lxc.* entries:</strong> Low-level LXC configuration options</li>
<li> <strong>Comments:</strong> Configuration file comments and documentation</li>
<li> <strong>Custom settings:</strong> Any other configuration parameters</li>
<li> <strong>Preservation:</strong> All content is preserved when switching between tabs</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Saving Changes</h4>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
To save configuration changes, you must type the container ID exactly as shown to confirm your changes.
</p>
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<h5 className="font-medium text-yellow-800 dark:text-yellow-200 mb-2"> Important Warnings</h5>
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
<li> Modifying LXC configuration can break your container</li>
<li> Some changes may require container restart to take effect</li>
<li> Always backup your configuration before making changes</li>
<li> Test changes in a non-production environment first</li>
</ul>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Sync from Server</h4>
<p className="text-sm text-muted-foreground mb-3">
The &quot;Sync from Server&quot; button allows you to refresh the configuration from the actual server file, useful when:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Configuration was modified outside of this interface</li>
<li> You want to discard local changes and get the latest server version</li>
<li> The warning banner indicates the cached config differs from server</li>
<li> You want to ensure you&apos;re working with the most current configuration</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Database Caching</h4>
<p className="text-sm text-muted-foreground mb-3">
LXC configurations are cached in the database for improved performance and offline access.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Automatic caching:</strong> Configs are cached during auto-detection and after saves</li>
<li> <strong>Cache expiration:</strong> Cached configs expire after 5 minutes for freshness</li>
<li> <strong>Change detection:</strong> Hash comparison detects external modifications</li>
<li> <strong>Manual sync:</strong> Always available via the &quot;Sync from Server&quot; button</li>
</ul>
</div>
</div>
</div>
);
default:
return null;
}

View File

@@ -8,17 +8,7 @@ import { Button } from './ui/button';
import { ScriptInstallationCard } from './ScriptInstallationCard';
import { ConfirmationModal } from './ConfirmationModal';
import { ErrorModal } from './ErrorModal';
import { LoadingModal } from './LoadingModal';
import { LXCSettingsModal } from './LXCSettingsModal';
import { getContrastColor } from '../../lib/colorUtils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from './ui/dropdown-menu';
import { Settings } from 'lucide-react';
interface InstalledScript {
id: number;
@@ -30,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() {
@@ -51,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);
@@ -76,7 +59,6 @@ export function InstalledScriptsTab() {
} | null>(null);
const [controllingScriptId, setControllingScriptId] = useState<number | null>(null);
const scriptsRef = useRef<InstalledScript[]>([]);
const statusCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Error modal state
const [errorModal, setErrorModal] = useState<{
@@ -87,18 +69,6 @@ export function InstalledScriptsTab() {
type?: 'error' | 'success';
} | null>(null);
// Loading modal state
const [loadingModal, setLoadingModal] = useState<{
isOpen: boolean;
action: string;
} | null>(null);
// LXC Settings modal state
const [lxcSettingsModal, setLxcSettingsModal] = useState<{
isOpen: boolean;
script: InstalledScript | null;
}>({ isOpen: false, script: null });
// Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
@@ -116,7 +86,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}`);
@@ -230,30 +200,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);
}
});
@@ -262,7 +209,6 @@ export function InstalledScriptsTab() {
const controlContainerMutation = api.installedScripts.controlContainer.useMutation({
onSuccess: (data, variables) => {
setLoadingModal(null);
setControllingScriptId(null);
if (data.success) {
@@ -303,7 +249,6 @@ export function InstalledScriptsTab() {
},
onError: (error) => {
console.error('Container control error:', error);
setLoadingModal(null);
setControllingScriptId(null);
// Show detailed error message
@@ -319,7 +264,6 @@ export function InstalledScriptsTab() {
const destroyContainerMutation = api.installedScripts.destroyContainer.useMutation({
onSuccess: (data) => {
setLoadingModal(null);
setControllingScriptId(null);
if (data.success) {
@@ -344,7 +288,6 @@ export function InstalledScriptsTab() {
},
onError: (error) => {
console.error('Container destroy error:', error);
setLoadingModal(null);
setControllingScriptId(null);
// Show detailed error message
@@ -369,34 +312,17 @@ export function InstalledScriptsTab() {
// Function to fetch container statuses - simplified to just check all servers
const fetchContainerStatuses = useCallback(() => {
console.log('fetchContainerStatuses called, isPending:', containerStatusMutation.isPending);
const currentScripts = scriptsRef.current;
// Prevent multiple simultaneous status checks
if (containerStatusMutation.isPending) {
console.log('Status check already pending, skipping');
return;
}
// Clear any existing timeout
if (statusCheckTimeoutRef.current) {
clearTimeout(statusCheckTimeoutRef.current);
}
// Debounce status checks by 500ms
statusCheckTimeoutRef.current = setTimeout(() => {
const currentScripts = scriptsRef.current;
// Get unique server IDs from scripts
const serverIds = [...new Set(currentScripts
.filter(script => script.server_id)
.map(script => script.server_id!))];
// Get unique server IDs from scripts
const serverIds = [...new Set(currentScripts
.filter(script => script.server_id)
.map(script => script.server_id!))];
console.log('Executing status check for server IDs:', serverIds);
if (serverIds.length > 0) {
containerStatusMutation.mutate({ serverIds });
}
}, 500);
}, []);
if (serverIds.length > 0) {
containerStatusMutation.mutate({ serverIds });
}
}, []); // Empty dependency array to prevent infinite loops
// Run cleanup when component mounts and scripts are loaded (only once)
useEffect(() => {
@@ -407,22 +333,17 @@ export function InstalledScriptsTab() {
}, [scripts.length, serversData?.servers, cleanupMutation]);
// Note: Individual status fetching removed - using bulk fetchContainerStatuses instead
// Trigger status check when tab becomes active (component mounts)
useEffect(() => {
if (scripts.length > 0) {
console.log('Status check triggered - scripts length:', scripts.length);
fetchContainerStatuses();
}
}, [scripts.length]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (statusCheckTimeoutRef.current) {
clearTimeout(statusCheckTimeoutRef.current);
}
};
}, []);
}, [scripts.length]); // Only depend on scripts.length to prevent infinite loops
// Update scripts with container statuses
const scriptsWithStatus = scripts.map(script => ({
...script,
container_status: script.container_id ? containerStatuses.get(script.id) ?? 'unknown' : undefined
@@ -534,7 +455,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);
}
@@ -555,7 +475,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);
}
@@ -584,17 +503,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
};
}
@@ -612,108 +527,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: '' });
};
const handleLXCSettings = (script: InstalledScript) => {
setLxcSettingsModal({ isOpen: true, script });
setEditFormData({ script_name: '', container_id: '' });
};
const handleSaveEdit = () => {
@@ -732,13 +556,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
@@ -800,54 +622,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();
@@ -865,7 +639,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}
@@ -877,20 +651,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>
@@ -934,7 +694,7 @@ export function InstalledScriptsTab() {
</Button>
<Button
onClick={fetchContainerStatuses}
disabled={containerStatusMutation.isPending ?? scripts.length === 0}
disabled={containerStatusMutation.isPending || scripts.length === 0}
variant="outline"
size="default"
>
@@ -1139,7 +899,7 @@ export function InstalledScriptsTab() {
</Button>
<Button
onClick={handleAutoDetect}
disabled={autoDetectMutation.isPending ?? !autoDetectServerId}
disabled={autoDetectMutation.isPending || !autoDetectServerId}
variant="default"
size="default"
className="w-full sm:w-auto"
@@ -1212,7 +972,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}
@@ -1220,9 +979,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>
@@ -1258,9 +1014,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')}
@@ -1314,14 +1067,15 @@ export function InstalledScriptsTab() {
>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="flex items-center min-h-[2.5rem]">
<div className="space-y-2">
<input
type="text"
value={editFormData.script_name}
onChange={(e) => handleInputChange('script_name', e.target.value)}
className="w-full px-3 py-2 text-sm font-medium border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Script name"
/>
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
) : (
<div>
@@ -1332,15 +1086,13 @@ export function InstalledScriptsTab() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="flex items-center min-h-[2.5rem]">
<input
type="text"
value={editFormData.container_id}
onChange={(e) => handleInputChange('container_id', e.target.value)}
className="w-full 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="Container ID"
/>
</div>
<input
type="text"
value={editFormData.container_id}
onChange={(e) => handleInputChange('container_id', e.target.value)}
className="w-full 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="Container ID"
/>
) : (
script.container_id ? (
<div className="flex items-center space-x-2">
@@ -1369,58 +1121,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"
@@ -1469,98 +1169,47 @@ 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={() => handleLXCSettings(script)}
className="text-purple-300 hover:text-purple-200 hover:bg-purple-900/20 focus:bg-purple-900/20"
>
<Settings className="mr-2 h-4 w-4" />
LXC Settings
</DropdownMenuItem>
<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"
>
{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"
>
{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>
)}
</>
)}
@@ -1599,25 +1248,6 @@ export function InstalledScriptsTab() {
type={errorModal.type ?? 'error'}
/>
)}
{/* Loading Modal */}
{loadingModal && (
<LoadingModal
isOpen={loadingModal.isOpen}
action={loadingModal.action}
/>
)}
{/* LXC Settings Modal */}
<LXCSettingsModal
isOpen={lxcSettingsModal.isOpen}
script={lxcSettingsModal.script}
onClose={() => setLxcSettingsModal({ isOpen: false, script: null })}
onSave={() => {
setLxcSettingsModal({ isOpen: false, script: null });
void refetchScripts();
}}
/>
</div>
);
}

View File

@@ -1,625 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Badge } from './ui/badge';
import { ContextualHelpIcon } from './ContextualHelpIcon';
import { LoadingModal } from './LoadingModal';
import { ConfirmationModal } from './ConfirmationModal';
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
interface InstalledScript {
id: number;
script_name: string;
container_id: string | null;
server_id: number | null;
server_name: string | null;
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 LXCSettingsModalProps {
isOpen: boolean;
script: InstalledScript | null;
onClose: () => void;
onSave: () => void;
}
export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSettingsModalProps) {
const [activeTab, setActiveTab] = useState<string>('common');
const [showConfirmation, setShowConfirmation] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState(false);
const [forceSync] = useState(false);
const [formData, setFormData] = useState<any>({
arch: '',
cores: 0,
memory: 0,
hostname: '',
swap: 0,
onboot: false,
ostype: '',
unprivileged: false,
net_name: '',
net_bridge: '',
net_hwaddr: '',
net_ip_type: 'dhcp',
net_ip: '',
net_gateway: '',
net_type: '',
net_vlan: 0,
rootfs_storage: '',
rootfs_size: '',
feature_keyctl: false,
feature_nesting: false,
feature_fuse: false,
feature_mount: '',
tags: '',
advanced_config: ''
});
// tRPC hooks
const { data: configData, isLoading } = api.installedScripts.getLXCConfig.useQuery(
{ scriptId: script?.id ?? 0, forceSync },
{ enabled: !!script && isOpen }
);
const saveMutation = api.installedScripts.saveLXCConfig.useMutation({
onSuccess: () => {
setSuccessMessage('LXC configuration saved successfully');
setHasChanges(false);
setShowConfirmation(false);
onSave();
},
onError: (err) => {
setError(`Failed to save configuration: ${err.message}`);
}
});
const syncMutation = api.installedScripts.syncLXCConfig.useMutation({
onSuccess: (result) => {
populateFormData(result);
setSuccessMessage('Configuration synced from server successfully');
setHasChanges(false);
},
onError: (err) => {
setError(`Failed to sync configuration: ${err.message}`);
}
});
// Populate form data helper
const populateFormData = (result: any) => {
if (!result?.success) return;
const config = result.config;
setFormData({
arch: config.arch ?? '',
cores: config.cores ?? 0,
memory: config.memory ?? 0,
hostname: config.hostname ?? '',
swap: config.swap ?? 0,
onboot: config.onboot === 1,
ostype: config.ostype ?? '',
unprivileged: config.unprivileged === 1,
net_name: config.net_name ?? '',
net_bridge: config.net_bridge ?? '',
net_hwaddr: config.net_hwaddr ?? '',
net_ip_type: config.net_ip_type ?? 'dhcp',
net_ip: config.net_ip ?? '',
net_gateway: config.net_gateway ?? '',
net_type: config.net_type ?? '',
net_vlan: config.net_vlan ?? 0,
rootfs_storage: config.rootfs_storage ?? '',
rootfs_size: config.rootfs_size ?? '',
feature_keyctl: config.feature_keyctl === 1,
feature_nesting: config.feature_nesting === 1,
feature_fuse: config.feature_fuse === 1,
feature_mount: config.feature_mount ?? '',
tags: config.tags ?? '',
advanced_config: config.advanced_config ?? ''
});
};
// Load config when data arrives
useEffect(() => {
if (configData?.success) {
populateFormData(configData);
setHasChanges(false);
} else if (configData && !configData.success) {
setError(String(configData.error ?? 'Failed to load configuration'));
}
}, [configData]);
const handleInputChange = (field: string, value: any): void => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
setFormData((prev: any) => ({ ...prev, [field]: value }));
setHasChanges(true);
};
const handleSyncFromServer = () => {
if (!script) return;
setError(null);
syncMutation.mutate({ scriptId: script.id });
};
const handleSave = () => {
setShowConfirmation(true);
};
const handleConfirmSave = () => {
if (!script) return;
setError(null);
saveMutation.mutate({
scriptId: script.id,
config: {
...formData,
onboot: formData.onboot ? 1 : 0,
unprivileged: formData.unprivileged ? 1 : 0,
feature_keyctl: formData.feature_keyctl ? 1 : 0,
feature_nesting: formData.feature_nesting ? 1 : 0,
feature_fuse: formData.feature_fuse ? 1 : 0
}
});
};
if (!isOpen || !script) 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-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-foreground">LXC Settings</h2>
<Badge variant="outline">{script.container_id}</Badge>
<ContextualHelpIcon section="lxc-settings" tooltip="Help with LXC Settings" />
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleSyncFromServer}
disabled={syncMutation.isPending ?? isLoading ?? saveMutation.isPending}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
Sync from Server
</Button>
<Button
onClick={onClose}
variant="ghost"
size="sm"
>
</Button>
</div>
</div>
{/* Warning Banner */}
{configData?.has_changes && (
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-800 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Configuration Mismatch Detected
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
The cached configuration differs from the server. Click &quot;Sync from Server&quot; to get the latest version.
</p>
</div>
</div>
</div>
)}
{/* Success Message */}
{successMessage && (
<div className="bg-green-50 dark:bg-green-950/20 border-b border-green-200 dark:border-green-800 p-4">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-green-800 dark:text-green-200">{successMessage}</p>
</div>
<button
onClick={() => setSuccessMessage(null)}
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400"
>
</button>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-950/20 border-b border-red-200 dark:border-red-800 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">Error</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400"
>
</button>
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
{/* Tab Navigation */}
<div className="border-b border-border mb-6">
<nav className="flex space-x-8">
<button
onClick={() => setActiveTab('common')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'common'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-gray-300'
}`}
>
Common Settings
</button>
<button
onClick={() => setActiveTab('advanced')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'advanced'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-gray-300'
}`}
>
Advanced Settings
</button>
</nav>
</div>
{/* Common Settings Tab */}
{activeTab === 'common' && (
<div className="space-y-6">
{/* Basic Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Basic Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="arch" className="block text-sm font-medium text-foreground">Architecture *</label>
<Input
id="arch"
value={formData.arch}
onChange={(e) => handleInputChange('arch', e.target.value)}
placeholder="amd64"
/>
</div>
<div className="space-y-2">
<label htmlFor="cores" className="block text-sm font-medium text-foreground">Cores *</label>
<Input
id="cores"
type="number"
value={formData.cores}
onChange={(e) => handleInputChange('cores', parseInt(e.target.value) || 0)}
min="1"
/>
</div>
<div className="space-y-2">
<label htmlFor="memory" className="block text-sm font-medium text-foreground">Memory (MB) *</label>
<Input
id="memory"
type="number"
value={formData.memory}
onChange={(e) => handleInputChange('memory', parseInt(e.target.value) || 0)}
min="128"
/>
</div>
<div className="space-y-2">
<label htmlFor="swap" className="block text-sm font-medium text-foreground">Swap (MB)</label>
<Input
id="swap"
type="number"
value={formData.swap}
onChange={(e) => handleInputChange('swap', parseInt(e.target.value) || 0)}
min="0"
/>
</div>
<div className="space-y-2">
<label htmlFor="hostname" className="block text-sm font-medium text-foreground">Hostname *</label>
<Input
id="hostname"
value={formData.hostname}
onChange={(e) => handleInputChange('hostname', e.target.value)}
placeholder="container-hostname"
/>
</div>
<div className="space-y-2">
<label htmlFor="ostype" className="block text-sm font-medium text-foreground">OS Type *</label>
<Input
id="ostype"
value={formData.ostype}
onChange={(e) => handleInputChange('ostype', e.target.value)}
placeholder="debian"
/>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="onboot"
checked={formData.onboot}
onChange={(e) => handleInputChange('onboot', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="onboot" className="text-sm font-medium text-foreground">Start on Boot</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="unprivileged"
checked={formData.unprivileged}
onChange={(e) => handleInputChange('unprivileged', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="unprivileged" className="text-sm font-medium text-foreground">Unprivileged Container</label>
</div>
</div>
</div>
{/* Network Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Network Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="net_name" className="block text-sm font-medium text-foreground">Interface Name</label>
<Input
id="net_name"
value={formData.net_name}
onChange={(e) => handleInputChange('net_name', e.target.value)}
placeholder="eth0"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_bridge" className="block text-sm font-medium text-foreground">Bridge</label>
<Input
id="net_bridge"
value={formData.net_bridge}
onChange={(e) => handleInputChange('net_bridge', e.target.value)}
placeholder="vmbr0"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_hwaddr" className="block text-sm font-medium text-foreground">MAC Address</label>
<Input
id="net_hwaddr"
value={formData.net_hwaddr}
onChange={(e) => handleInputChange('net_hwaddr', e.target.value)}
placeholder="BC:24:11:2D:2D:AB"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_type" className="block text-sm font-medium text-foreground">Type</label>
<Input
id="net_type"
value={formData.net_type}
onChange={(e) => handleInputChange('net_type', e.target.value)}
placeholder="veth"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_ip_type" className="block text-sm font-medium text-foreground">IP Configuration</label>
<select
id="net_ip_type"
value={formData.net_ip_type}
onChange={(e) => handleInputChange('net_ip_type', e.target.value)}
className="w-full px-3 py-2 border border-input bg-background rounded-md"
>
<option value="dhcp">DHCP</option>
<option value="static">Static IP</option>
</select>
</div>
{formData.net_ip_type === 'static' && (
<>
<div className="space-y-2">
<label htmlFor="net_ip" className="block text-sm font-medium text-foreground">IP Address with CIDR *</label>
<Input
id="net_ip"
value={formData.net_ip}
onChange={(e) => handleInputChange('net_ip', e.target.value)}
placeholder="10.10.10.164/24"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_gateway" className="block text-sm font-medium text-foreground">Gateway</label>
<Input
id="net_gateway"
value={formData.net_gateway}
onChange={(e) => handleInputChange('net_gateway', e.target.value)}
placeholder="10.10.10.254"
/>
</div>
</>
)}
<div className="space-y-2">
<label htmlFor="net_vlan" className="block text-sm font-medium text-foreground">VLAN Tag</label>
<Input
id="net_vlan"
type="number"
value={formData.net_vlan}
onChange={(e) => handleInputChange('net_vlan', parseInt(e.target.value) || 0)}
placeholder="Optional"
/>
</div>
</div>
</div>
{/* Storage */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Storage</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="rootfs_storage" className="block text-sm font-medium text-foreground">Root Filesystem *</label>
<Input
id="rootfs_storage"
value={formData.rootfs_storage}
onChange={(e) => handleInputChange('rootfs_storage', e.target.value)}
placeholder="PROX2-STORAGE2:vm-109-disk-0"
/>
</div>
<div className="space-y-2">
<label htmlFor="rootfs_size" className="block text-sm font-medium text-foreground">Size</label>
<Input
id="rootfs_size"
value={formData.rootfs_size}
onChange={(e) => handleInputChange('rootfs_size', e.target.value)}
placeholder="4G"
/>
</div>
</div>
</div>
{/* Features */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Features</h3>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="feature_keyctl"
checked={formData.feature_keyctl}
onChange={(e) => handleInputChange('feature_keyctl', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="feature_keyctl" className="text-sm font-medium text-foreground">Keyctl</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="feature_nesting"
checked={formData.feature_nesting}
onChange={(e) => handleInputChange('feature_nesting', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="feature_nesting" className="text-sm font-medium text-foreground">Nesting</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="feature_fuse"
checked={formData.feature_fuse}
onChange={(e) => handleInputChange('feature_fuse', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="feature_fuse" className="text-sm font-medium text-foreground">FUSE</label>
</div>
</div>
<div className="space-y-2">
<label htmlFor="feature_mount" className="block text-sm font-medium text-foreground">Additional Mount Features</label>
<Input
id="feature_mount"
value={formData.feature_mount}
onChange={(e) => handleInputChange('feature_mount', e.target.value)}
placeholder="Additional features (comma-separated)"
/>
</div>
</div>
{/* Tags */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Tags</h3>
<div className="space-y-2">
<label htmlFor="tags" className="block text-sm font-medium text-foreground">Tags</label>
<Input
id="tags"
value={formData.tags}
onChange={(e) => handleInputChange('tags', e.target.value)}
placeholder="community-script;pve-scripts-local"
/>
</div>
</div>
</div>
)}
{/* Advanced Settings Tab */}
{activeTab === 'advanced' && (
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="advanced_config" className="block text-sm font-medium text-foreground">Advanced Configuration</label>
<textarea
id="advanced_config"
value={formData.advanced_config}
onChange={(e) => handleInputChange('advanced_config', e.target.value)}
placeholder="lxc.* entries, comments, and other advanced settings..."
className="w-full min-h-[400px] px-3 py-2 border border-input bg-background rounded-md font-mono text-sm resize-vertical"
/>
<p className="text-xs text-muted-foreground">
This section contains lxc.* entries, comments, and other advanced settings that are not covered in the Common Settings tab.
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end p-4 sm:p-6 border-t border-border bg-muted/30">
<div className="flex gap-3">
<Button
onClick={onClose}
variant="outline"
disabled={saveMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saveMutation.isPending || !hasChanges}
variant="default"
>
{saveMutation.isPending ? 'Saving...' : 'Save Configuration'}
</Button>
</div>
</div>
</div>
</div>
{/* Confirmation Modal */}
<ConfirmationModal
isOpen={showConfirmation}
onClose={() => {
setShowConfirmation(false);
}}
onConfirm={handleConfirmSave}
title="Confirm LXC Configuration Changes"
message="Modifying LXC configuration can break your container and may require manual recovery. Ensure you understand these changes before proceeding. The container may need to be restarted for changes to take effect."
variant="danger"
confirmText={script.container_id ?? ''}
confirmButtonText="Save Configuration"
/>
{/* Loading Modal */}
<LoadingModal
isOpen={isLoading}
action="Loading LXC configuration..."
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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 &quot;&lt;paste-key&gt;&quot; &gt;&gt; ~/.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>
);
}

View File

@@ -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>

View File

@@ -93,22 +93,9 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
);
if (keyLine) {
let keyType = 'Unknown';
// Check for traditional PEM format keys
if (keyLine.includes('RSA')) {
keyType = 'RSA';
} else if (keyLine.includes('ED25519')) {
keyType = 'ED25519';
} else if (keyLine.includes('ECDSA')) {
keyType = 'ECDSA';
} else if (keyLine.includes('OPENSSH PRIVATE KEY')) {
// For OpenSSH format keys, try to detect type from the key content
// 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';
}
const keyType = keyLine.includes('RSA') ? 'RSA' :
keyLine.includes('ED25519') ? 'ED25519' :
keyLine.includes('ECDSA') ? 'ECDSA' : 'Unknown';
return `${keyType} key (${keyContent.length} characters)`;
}
@@ -155,7 +142,7 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
<input
ref={fileInputRef}
type="file"
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa,ed25519,id_rsa,id_ed25519,id_ecdsa,*"
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa"
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
@@ -166,7 +153,7 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
Drag and drop your SSH private key here, or click to browse
</p>
<p className="text-xs text-muted-foreground">
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, ed25519, etc.)
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, etc.)
</p>
</div>
</div>

View File

@@ -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">

View File

@@ -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>
)}
</>
)}

View File

@@ -571,7 +571,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
);
}
if (!scriptsWithStatus?.length) {
if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
return (
<div className="text-center py-12">
<div className="text-muted-foreground">
@@ -626,13 +626,11 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<Button
onClick={handleBatchDownload}
disabled={loadSingleScriptMutation.isPending}
variant="outline"
size="sm"
className="bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/30 text-blue-300 hover:text-blue-200 hover:border-blue-400/50"
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loadSingleScriptMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Downloading...
</>
) : (
@@ -644,7 +642,6 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
onClick={handleDownloadAllFiltered}
disabled={filteredScripts.length === 0 || loadSingleScriptMutation.isPending}
variant="outline"
size="sm"
>
{loadSingleScriptMutation.isPending ? (
<>
@@ -660,8 +657,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{selectedSlugs.size > 0 && (
<Button
onClick={clearSelection}
variant="outline"
size="default"
variant="ghost"
size="sm"
>
Clear Selection
</Button>
@@ -670,8 +667,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{filteredScripts.length > 0 && (
<Button
onClick={selectAllVisible}
variant="outline"
size="default"
variant="ghost"
size="sm"
>
Select All Visible
</Button>

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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
}));
}

View File

@@ -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",

View File

@@ -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,
};

View File

@@ -1,64 +0,0 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma';
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 }
);
}
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../server/database-prisma';
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) {

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma';
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(

View File

@@ -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';
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 }
);
}
}

View File

@@ -1,12 +1,12 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../server/database-prisma';
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 }
);

View File

@@ -15,18 +15,12 @@ import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer';
import { Package, HardDrive, FolderOpen } from 'lucide-react';
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
import { api } from '~/trpc/react';
export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') {
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
return savedTab || 'scripts';
}
return 'scripts';
});
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
const terminalRef = useRef<HTMLDivElement>(null);
@@ -37,13 +31,6 @@ export default function Home() {
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('activeTab', activeTab);
}
}, [activeTab]);
// Auto-show release notes modal after update
useEffect(() => {
if (versionData?.success && versionData.version) {
@@ -70,42 +57,15 @@ export default function Home() {
// Calculate script counts
const scriptCounts = {
available: (() => {
if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
return scriptMap.size;
})(),
available: scriptCardsData?.success ? scriptCardsData.cards?.length ?? 0 : 0,
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
const deduplicatedGithubScripts = Array.from(scriptMap.values());
// Count scripts that are both in GitHub data and have local versions
const githubScripts = scriptCardsData.cards ?? [];
const localScripts = localScriptsData.scripts ?? [];
// Count scripts that are both in deduplicated GitHub data and have local versions
return deduplicatedGithubScripts.filter(script => {
return githubScripts.filter(script => {
if (!script?.name) return false;
return localScripts.some(local => {
if (!local?.name) return false;
@@ -147,7 +107,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">
@@ -171,58 +131,64 @@ export default function Home() {
{/* Tab Navigation */}
<div className="mb-6 sm:mb-8">
<div className="border-b border-border">
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available}
</span>
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 lg:space-x-8">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available}
</span>
</Button>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'downloaded'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'downloaded'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded}
</span>
</Button>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed}
</span>
</Button>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
</Button>
</div>
</nav>
</div>
</div>

View File

@@ -3,7 +3,7 @@
* to ensure optimal readability based on luminance
*/
export function getContrastColor(hexColor: string): 'black' | 'white' {
if (!hexColor?.length || hexColor.length !== 7 || !hexColor.startsWith('#')) {
if (!hexColor || hexColor.length !== 7 || !hexColor.startsWith('#')) {
return 'black'; // Default to black for invalid colors
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database-prisma";
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 };
}

View File

@@ -1,287 +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;
}
// LXC Config CRUD operations
async createLXCConfig(scriptId, configData) {
return await prisma.lXCConfig.create({
data: {
installed_script_id: scriptId,
...configData
}
});
}
async updateLXCConfig(scriptId, configData) {
return await prisma.lXCConfig.upsert({
where: { installed_script_id: scriptId },
update: configData,
create: {
installed_script_id: scriptId,
...configData
}
});
}
async getLXCConfigByScriptId(scriptId) {
return await prisma.lXCConfig.findUnique({
where: { installed_script_id: scriptId }
});
}
async deleteLXCConfig(scriptId) {
return await prisma.lXCConfig.delete({
where: { installed_script_id: scriptId }
});
}
async close() {
await prisma.$disconnect();
}
}
// Singleton instance
let dbInstance = null;
export function getDatabase() {
dbInstance ??= new DatabaseServicePrisma();
return dbInstance;
}
export default DatabaseServicePrisma;

View File

@@ -1,312 +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;
}
// LXC Config CRUD operations
async createLXCConfig(scriptId: number, configData: any) {
return await prisma.lXCConfig.create({
data: {
installed_script_id: scriptId,
...configData
}
});
}
async updateLXCConfig(scriptId: number, configData: any) {
return await prisma.lXCConfig.upsert({
where: { installed_script_id: scriptId },
update: configData,
create: {
installed_script_id: scriptId,
...configData
}
});
}
async getLXCConfigByScriptId(scriptId: number) {
return await prisma.lXCConfig.findUnique({
where: { installed_script_id: scriptId }
});
}
async deleteLXCConfig(scriptId: number) {
return await prisma.lXCConfig.delete({
where: { installed_script_id: scriptId }
});
}
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
View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,41 @@ 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');
writeFileSync(tempKeyPath, ssh_key);
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 +67,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 +84,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 +136,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 +146,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 +191,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 +233,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 +290,17 @@ class SSHExecutionService {
});
rsyncCommand.on('close', (code) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
if (code === 0) {
resolve();
} else {
@@ -211,10 +309,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('/'));
unlinkSync(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('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
});
@@ -230,10 +348,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 +378,16 @@ class SSHExecutionService {
});
sshCommand.onExit((e) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
onExit(e.exitCode);
});
@@ -259,10 +395,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('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
});

View File

@@ -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,29 @@ 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
writeFileSync(tempKeyPath, ssh_key);
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 +660,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

View File

@@ -1,5 +1,4 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@layer base {
:root {

View File

@@ -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;
}

View File

@@ -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..."