From 024ffcbf09d91087c9a36a985742bc69cf8e6b59 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:05:25 +0200 Subject: [PATCH] feat: Add script installation tracking and update functionality (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add script installation tracking with Container ID detection - Add installed_scripts table to database schema - Implement Container ID parsing from terminal output - Add installation tracking for both local and SSH executions - Create InstalledScriptsTab component with filtering and search - Add tab navigation to main page (Scripts | Installed Scripts) - Add tRPC endpoints for installed scripts CRUD operations - Track installation status, server info, and output logs - Support both local and SSH execution modes * fix: Resolve SQL syntax error in database queries - Change table alias from 'is' to 'inst' in SQL queries - 'is' is a reserved keyword in SQLite causing syntax errors - Fixes getAllInstalledScripts, getInstalledScriptById, and getInstalledScriptsByServer methods * feat: Enhance Container ID detection and add manual editing - Add comprehensive Container ID detection patterns for various script formats - Add debug logging to help identify detection issues - Add manual Container ID editing feature in the frontend - Add updateInstalledScript tRPC mutation for updating records - Improve Container ID column with inline editing UI - Test and verify Container ID detection is working (detected 132 from 2fauth script) * fix: Improve Container ID detection with ANSI code handling - Add ANSI color code stripping before pattern matching - Add primary pattern for exact format: 🆔 Container ID: 113 - Test patterns on both original and cleaned output - Add better debug logging to show matched text - This should fix Container ID detection for Proxmox scripts * feat: Add script update functionality with terminal output - Add Update button for each installed script (only shows when container_id exists) - Add WebSocket support for update action (pct enter -- update) - Add updateScript tRPC endpoint for initiating updates - Add startScriptUpdate, startLocalScriptUpdate, startSSHScriptUpdate methods - Modify Terminal component to handle update operations - Display real-time terminal output for update commands - Support both local and SSH execution modes for updates - Show 'Update Container ' in terminal title for update operations * fix: Fix SSH update functionality - Replace sshService.executeScript with direct sshpass command - Use bash -c to execute SSH command: sshpass -p 'password' ssh -o StrictHostKeyChecking=no user@ip 'pct enter -- update' - This fixes the 'Permission denied' and rsync errors - SSH updates now work properly for remote containers * fix: Fix WebSocket update action handling - Add containerId to WebSocketMessage typedef - Extract containerId from message in handleMessage function - Remove debug logging from Terminal component - This fixes the 'containerId is not defined' error - Update action should now work properly without creating script records * feat: Add Update functionality for installed scripts - Add Update button to InstalledScriptsTab for scripts with Container ID - Modify Terminal component to handle update operations with isUpdate and containerId props - Add startUpdateExecution method to WebSocket handler - Implement local update execution using 'pct enter -c update' - Implement SSH update execution for remote servers - Update WebSocket message parsing to handle update parameters - Users can now update installed scripts by entering the LXC container and running update command * fix: Fix SSH update execution by using direct command execution - Add executeCommand method to SSH service for direct command execution - Update startSSHUpdateExecution to use executeCommand instead of executeScript - This fixes the rsync permission denied error when updating scripts via SSH - Update functionality now works properly for both local and SSH installations * fix: Add server credentials fetching for SSH updates - Create servers router with getServerById endpoint - Update handleUpdateScript to fetch full server details including credentials - This fixes the permission denied error by providing user/password for SSH authentication - SSH updates now have access to complete server configuration * fix: Simplify server credentials fetching for SSH updates - Add server_user and server_password to database query - Update InstalledScript interface to include server credentials - Simplify handleUpdateScript to use data already available - Remove complex tRPC server fetching that was causing errors - SSH updates now work with complete server authentication data * fix: Correct pct enter command sequence for updates - Change from 'pct enter -c "update"' to proper sequence - First run 'pct enter ' to enter container shell - Then send 'update' command after entering the container - Apply fix to both local and SSH update execution methods - Add 1-second delay to ensure container shell is ready before sending update command * fix: Increase delay to 4 seconds before sending update command - Change delay from 1 second to 4 seconds for both local and SSH updates - Ensures container shell is fully ready before sending update command - Prevents premature command execution that could fail * cleanup: Remove all debug console.log statements - Remove debug logging from server.js WebSocket handlers - Remove debug logging from Terminal component - Remove debug logging from page.tsx - Remove debug logging from ExecutionModeModal component - Remove debug logging from githubJsonService.ts - Keep only essential server startup messages and error logging - Clean up codebase for production readiness * fix: Resolve all build and linter errors - Fix React Hook useEffect missing dependencies in Terminal.tsx - Fix TypeScript unsafe argument error in installedScripts.ts by properly typing updateData - Add missing isUpdate and containerId properties to WebSocketMessage type definition - Add proper type annotations for callback parameters in server.js - Fix TypeScript errors with execution.process by adding type assertions - Remove duplicate updateInstalledScript method in installedScripts.ts - Build now passes successfully with no errors or warnings --- scripts/core/install.func | 3 +- scripts/ct/2fauth.sh | 81 ++++ scripts/ct/debian.sh | 43 +++ scripts/install/2fauth-install.sh | 104 ++++++ scripts/install/debian-install.sh | 24 ++ server.js | 394 ++++++++++++++++++-- src/app/_components/ExecutionModeModal.tsx | 3 - src/app/_components/InstalledScriptsTab.tsx | 324 ++++++++++++++++ src/app/_components/Terminal.tsx | 15 +- src/app/page.tsx | 41 +- src/server/api/root.ts | 4 + src/server/api/routers/installedScripts.ts | 206 ++++++++++ src/server/api/routers/servers.ts | 41 ++ src/server/database.js | 130 +++++++ src/server/services/githubJsonService.ts | 1 - src/server/ssh-execution-service.js | 54 +++ 16 files changed, 1431 insertions(+), 37 deletions(-) create mode 100644 scripts/ct/2fauth.sh create mode 100644 scripts/ct/debian.sh create mode 100644 scripts/install/2fauth-install.sh create mode 100644 scripts/install/debian-install.sh create mode 100644 src/app/_components/InstalledScriptsTab.tsx create mode 100644 src/server/api/routers/installedScripts.ts create mode 100644 src/server/api/routers/servers.ts diff --git a/scripts/core/install.func b/scripts/core/install.func index 0fd7c59..bfc189b 100755 --- a/scripts/core/install.func +++ b/scripts/core/install.func @@ -195,7 +195,8 @@ EOF systemctl restart $(basename $(dirname $GETTY_OVERRIDE) | sed 's/\.d//') msg_ok "Customized Container" fi - + echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update + chmod +x /usr/bin/update if [[ -n "${SSH_AUTHORIZED_KEY}" ]]; then mkdir -p /root/.ssh diff --git a/scripts/ct/2fauth.sh b/scripts/ct/2fauth.sh new file mode 100644 index 0000000..ea78683 --- /dev/null +++ b/scripts/ct/2fauth.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/../core/build.func" +# Copyright (c) 2021-2025 community-scripts ORG +# Author: jkrgr0 +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://docs.2fauth.app/ + +APP="2FAuth" +var_tags="${var_tags:-2fa;authenticator}" +var_cpu="${var_cpu:-1}" +var_ram="${var_ram:-512}" +var_disk="${var_disk:-2}" +var_os="${var_os:-debian}" +var_version="${var_version:-12}" +var_unprivileged="${var_unprivileged:-1}" + +header_info "$APP" +variables +color +catch_errors + +function update_script() { + header_info + check_container_storage + check_container_resources + + if [[ ! -d "/opt/2fauth" ]]; then + msg_error "No ${APP} Installation Found!" + exit + fi + if check_for_gh_release "2fauth" "Bubka/2FAuth"; then + $STD apt-get update + $STD apt-get -y upgrade + + msg_info "Creating Backup" + mv "/opt/2fauth" "/opt/2fauth-backup" + if ! dpkg -l | grep -q 'php8.3'; then + cp /etc/nginx/conf.d/2fauth.conf /etc/nginx/conf.d/2fauth.conf.bak + fi + msg_ok "Backup Created" + + if ! dpkg -l | grep -q 'php8.3'; then + $STD apt-get install -y \ + lsb-release \ + gnupg2 + PHP_VERSION="8.3" PHP_MODULE="common,ctype,fileinfo,mysql,cli" PHP_FPM="YES" setup_php + sed -i 's/php8.2/php8.3/g' /etc/nginx/conf.d/2fauth.conf + fi + fetch_and_deploy_gh_release "2fauth" "Bubka/2FAuth" + setup_composer + mv "/opt/2fauth-backup/.env" "/opt/2fauth/.env" + mv "/opt/2fauth-backup/storage" "/opt/2fauth/storage" + cd "/opt/2fauth" || return + chown -R www-data: "/opt/2fauth" + chmod -R 755 "/opt/2fauth" + export COMPOSER_ALLOW_SUPERUSER=1 + $STD composer install --no-dev --prefer-source + php artisan 2fauth:install + $STD systemctl restart nginx + + msg_info "Cleaning Up" + if dpkg -l | grep -q 'php8.2'; then + $STD apt-get remove --purge -y php8.2* + fi + $STD apt-get -y autoremove + $STD apt-get -y autoclean + msg_ok "Cleanup Completed" + msg_ok "Updated Successfully" + fi + exit +} + +start +build_container +description + +msg_ok "Completed Successfully!\n" +echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" +echo -e "${INFO}${YW} Access it using the following URL:${CL}" +echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:80${CL}" diff --git a/scripts/ct/debian.sh b/scripts/ct/debian.sh new file mode 100644 index 0000000..54b1748 --- /dev/null +++ b/scripts/ct/debian.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/../core/build.func" +# Copyright (c) 2021-2025 tteck +# Author: tteck (tteckster) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://www.debian.org/ + +APP="Debian" +var_tags="${var_tags:-os}" +var_cpu="${var_cpu:-1}" +var_ram="${var_ram:-512}" +var_disk="${var_disk:-2}" +var_os="${var_os:-debian}" +var_version="${var_version:-13}" +var_unprivileged="${var_unprivileged:-1}" + +header_info "$APP" +variables +color +catch_errors + +function update_script() { + header_info + check_container_storage + check_container_resources + if [[ ! -d /var ]]; then + msg_error "No ${APP} Installation Found!" + exit + fi + msg_info "Updating $APP LXC" + $STD apt update + $STD apt -y upgrade + msg_ok "Updated $APP LXC" + exit +} + +start +build_container +description + +msg_ok "Completed Successfully!\n" +echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" diff --git a/scripts/install/2fauth-install.sh b/scripts/install/2fauth-install.sh new file mode 100644 index 0000000..8b190d7 --- /dev/null +++ b/scripts/install/2fauth-install.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2025 community-scripts ORG +# Author: jkrgr0 +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://docs.2fauth.app/ + +source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" +color +verb_ip6 +catch_errors +setting_up_container +network_check +update_os + +msg_info "Installing Dependencies" +$STD apt-get install -y \ + lsb-release \ + nginx +msg_ok "Installed Dependencies" + +PHP_VERSION="8.3" PHP_MODULE="common,ctype,fileinfo,mysql,cli" PHP_FPM="YES" setup_php +setup_composer +setup_mariadb + +msg_info "Setting up Database" +DB_NAME=2fauth_db +DB_USER=2fauth +DB_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c13) +$STD mariadb -u root -e "CREATE DATABASE $DB_NAME;" +$STD mariadb -u root -e "CREATE USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';" +$STD mariadb -u root -e "GRANT ALL ON $DB_NAME.* TO '$DB_USER'@'localhost'; FLUSH PRIVILEGES;" +{ + echo "2FAuth Credentials" + echo "Database User: $DB_USER" + echo "Database Password: $DB_PASS" + echo "Database Name: $DB_NAME" +} >>~/2FAuth.creds +msg_ok "Set up Database" + +fetch_and_deploy_gh_release "2fauth" "Bubka/2FAuth" + +msg_info "Setup 2FAuth" +cd /opt/2fauth +cp .env.example .env +IPADDRESS=$(hostname -I | awk '{print $1}') +sed -i -e "s|^APP_URL=.*|APP_URL=http://$IPADDRESS|" \ + -e "s|^DB_CONNECTION=$|DB_CONNECTION=mysql|" \ + -e "s|^DB_DATABASE=$|DB_DATABASE=$DB_NAME|" \ + -e "s|^DB_HOST=$|DB_HOST=127.0.0.1|" \ + -e "s|^DB_PORT=$|DB_PORT=3306|" \ + -e "s|^DB_USERNAME=$|DB_USERNAME=$DB_USER|" \ + -e "s|^DB_PASSWORD=$|DB_PASSWORD=$DB_PASS|" .env +export COMPOSER_ALLOW_SUPERUSER=1 +$STD composer update --no-plugins --no-scripts +$STD composer install --no-dev --prefer-source --no-plugins --no-scripts +$STD php artisan key:generate --force +$STD php artisan migrate:refresh +$STD php artisan passport:install -q -n +$STD php artisan storage:link +$STD php artisan config:cache +chown -R www-data: /opt/2fauth +chmod -R 755 /opt/2fauth +msg_ok "Setup 2fauth" + +msg_info "Configure Service" +cat </etc/nginx/conf.d/2fauth.conf +server { + listen 80; + root /opt/2fauth/public; + server_name $IPADDRESS; + index index.php; + charset utf-8; + + location / { + try_files \$uri \$uri/ /index.php?\$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + location ~ \.php\$ { + fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; + fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} +EOF +systemctl reload nginx +msg_ok "Configured Service" + +motd_ssh +customize + +msg_info "Cleaning up" +$STD apt-get -y autoremove +$STD apt-get -y autoclean +msg_ok "Cleaned" diff --git a/scripts/install/debian-install.sh b/scripts/install/debian-install.sh new file mode 100644 index 0000000..7b00eca --- /dev/null +++ b/scripts/install/debian-install.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2025 tteck +# Author: tteck (tteckster) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://www.debian.org/ + +source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" +color +verb_ip6 +catch_errors +setting_up_container +network_check +update_os + +motd_ssh +customize + +msg_info "Cleaning up" +$STD apt -y autoremove +$STD apt -y autoclean +$STD apt -y clean +msg_ok "Cleaned" + diff --git a/server.js b/server.js index e49879b..4daf897 100644 --- a/server.js +++ b/server.js @@ -7,6 +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.js'; const dev = process.env.NODE_ENV !== 'production'; const hostname = '0.0.0.0'; @@ -32,6 +33,7 @@ const handle = app.getRequestHandler(); * @property {string} ip * @property {string} user * @property {string} password + * @property {number} [id] */ /** @@ -48,6 +50,8 @@ const handle = app.getRequestHandler(); * @property {string} [input] * @property {string} [mode] * @property {ServerInfo} [server] + * @property {boolean} [isUpdate] + * @property {string} [containerId] */ class ScriptExecutionHandler { @@ -60,9 +64,111 @@ class ScriptExecutionHandler { path: '/ws/script-execution' }); this.activeExecutions = new Map(); + this.db = getDatabase(); this.setupWebSocket(); } + /** + * Parse Container ID from terminal output + * @param {string} output - Terminal output to parse + * @returns {string|null} - Container ID if found, null otherwise + */ + parseContainerId(output) { + // First, strip ANSI color codes to make pattern matching more reliable + const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); + + // Look for various patterns that Proxmox scripts might use + const patterns = [ + // Primary pattern - the exact format from the output + /🆔\s+Container\s+ID:\s+(\d+)/i, + + // Standard patterns with flexible spacing + /🆔\s*Container\s*ID:\s*(\d+)/i, + /Container\s*ID:\s*(\d+)/i, + /CT\s*ID:\s*(\d+)/i, + /Container\s*(\d+)/i, + + // Alternative patterns + /CT\s*(\d+)/i, + /Container\s*created\s*with\s*ID\s*(\d+)/i, + /Created\s*container\s*(\d+)/i, + /Container\s*(\d+)\s*created/i, + /ID:\s*(\d+)/i, + + // Patterns with different spacing and punctuation + /Container\s*ID\s*:\s*(\d+)/i, + /CT\s*ID\s*:\s*(\d+)/i, + /Container\s*#\s*(\d+)/i, + /CT\s*#\s*(\d+)/i, + + // Patterns that might appear in success messages + /Successfully\s*created\s*container\s*(\d+)/i, + /Container\s*(\d+)\s*is\s*ready/i, + /Container\s*(\d+)\s*started/i, + + // Generic number patterns that might be container IDs (3-4 digits) + /(?:^|\s)(\d{3,4})(?:\s|$)/m, + ]; + + // Try patterns on both original and cleaned output + const outputsToTry = [output, cleanOutput]; + + for (const testOutput of outputsToTry) { + for (const pattern of patterns) { + const match = testOutput.match(pattern); + if (match && match[1]) { + const containerId = match[1]; + // Additional validation: container IDs are typically 3-4 digits + if (containerId.length >= 3 && containerId.length <= 4) { + return containerId; + } + } + } + } + + + 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 {number|null} - Installation record ID + */ + createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) { + try { + const result = this.db.createInstalledScript({ + script_name: scriptName, + script_path: scriptPath, + container_id: undefined, + server_id: serverId ?? undefined, + execution_mode: executionMode, + status: 'in_progress', + output_log: '' + }); + return Number(result.lastInsertRowid); + } catch (error) { + console.error('Error creating installation record:', error); + return null; + } + } + + /** + * Update installation record + * @param {number} installationId - Installation record ID + * @param {Object} updateData - Data to update + */ + updateInstallationRecord(installationId, updateData) { + try { + this.db.updateInstalledScript(installationId, updateData); + } catch (error) { + console.error('Error updating installation record:', error); + } + } + setupWebSocket() { this.wss.on('connection', (ws, request) => { @@ -73,9 +179,7 @@ class ScriptExecutionHandler { ws.on('message', (data) => { try { const rawMessage = data.toString(); - console.log('Raw WebSocket message received:', rawMessage); const message = JSON.parse(rawMessage); - console.log('Parsed WebSocket message:', message); this.handleMessage(/** @type {ExtendedWebSocket} */ (ws), message); } catch (error) { console.error('Error parsing WebSocket message:', error); @@ -103,16 +207,16 @@ class ScriptExecutionHandler { * @param {WebSocketMessage} message */ async handleMessage(ws, message) { - const { action, scriptPath, executionId, input, mode, server } = message; - - // Debug logging - console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null }); - console.log('Full message object:', JSON.stringify(message, null, 2)); + const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message; switch (action) { case 'start': if (scriptPath && executionId) { - await this.startScriptExecution(ws, scriptPath, executionId, mode, server); + if (isUpdate && containerId) { + await this.startUpdateExecution(ws, containerId, executionId, mode, server); + } else { + await this.startScriptExecution(ws, scriptPath, executionId, mode, server); + } } else { this.sendMessage(ws, { type: 'error', @@ -151,10 +255,10 @@ class ScriptExecutionHandler { * @param {ServerInfo|null} server */ async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null) { + /** @type {number|null} */ + let installationId = null; + try { - // Debug logging - console.log('startScriptExecution called with:', { mode, server: server ? { name: server.name, ip: server.ip } : null }); - console.log('Full server object:', JSON.stringify(server, null, 2)); // Check if execution is already running if (this.activeExecutions.has(executionId)) { @@ -166,18 +270,26 @@ class ScriptExecutionHandler { return; } + // Extract script name from path + const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script'; + + // Create installation record + const serverId = server ? (server.id ?? null) : null; + installationId = this.createInstallationRecord(scriptName, scriptPath, mode, serverId); + + if (!installationId) { + console.error('Failed to create installation record'); + } + // Handle SSH execution if (mode === 'ssh' && server) { - console.log('Starting SSH execution...'); - await this.startSSHScriptExecution(ws, scriptPath, executionId, server); + await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId); return; } if (mode === 'ssh' && !server) { - console.log('SSH mode requested but no server provided, falling back to local execution'); + // SSH mode requested but no server provided, falling back to local execution } - - console.log('Starting local execution...'); // Basic validation for local execution const scriptsDir = join(process.cwd(), 'scripts'); @@ -189,6 +301,11 @@ class ScriptExecutionHandler { data: 'Script path is not within the allowed scripts directory', timestamp: Date.now() }); + + // Update installation record with failure + if (installationId) { + this.updateInstallationRecord(installationId, { status: 'failed' }); + } return; } @@ -209,8 +326,13 @@ class ScriptExecutionHandler { // pty handles encoding automatically - // Store the execution - this.activeExecutions.set(executionId, { process: childProcess, ws }); + // Store the execution with installation ID + this.activeExecutions.set(executionId, { + process: childProcess, + ws, + installationId, + outputBuffer: '' + }); // Send start message this.sendMessage(ws, { @@ -221,15 +343,44 @@ class ScriptExecutionHandler { // Handle pty data (both stdout and stderr combined) childProcess.onData((data) => { + const output = data.toString(); + + // Store output in buffer for logging + const execution = this.activeExecutions.get(executionId); + if (execution) { + execution.outputBuffer += output; + // Keep only last 1000 characters to avoid memory issues + if (execution.outputBuffer.length > 1000) { + execution.outputBuffer = execution.outputBuffer.slice(-1000); + } + } + + // Parse for Container ID + const containerId = this.parseContainerId(output); + if (containerId && installationId) { + this.updateInstallationRecord(installationId, { container_id: containerId }); + } + this.sendMessage(ws, { type: 'output', - data: data.toString(), + data: output, timestamp: Date.now() }); }); // Handle process exit childProcess.onExit((e) => { + const execution = this.activeExecutions.get(executionId); + const isSuccess = e.exitCode === 0; + + // Update installation record with final status and output + if (installationId && execution) { + this.updateInstallationRecord(installationId, { + status: isSuccess ? 'success' : 'failed', + output_log: execution.outputBuffer + }); + } + this.sendMessage(ws, { type: 'end', data: `Script execution finished with code: ${e.exitCode}, signal: ${e.signal}`, @@ -246,6 +397,11 @@ class ScriptExecutionHandler { data: `Failed to start script: ${error instanceof Error ? error.message : String(error)}`, timestamp: Date.now() }); + + // Update installation record with failure + if (installationId) { + this.updateInstallationRecord(installationId, { status: 'failed' }); + } } } @@ -255,9 +411,9 @@ class ScriptExecutionHandler { * @param {string} scriptPath * @param {string} executionId * @param {ServerInfo} server + * @param {number|null} installationId */ - async startSSHScriptExecution(ws, scriptPath, executionId, server) { - console.log('startSSHScriptExecution called with server:', server); + async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null) { const sshService = getSSHExecutionService(); // Send start message @@ -272,6 +428,22 @@ class ScriptExecutionHandler { server, scriptPath, /** @param {string} data */ (data) => { + // Store output in buffer for logging + const exec = this.activeExecutions.get(executionId); + if (exec) { + exec.outputBuffer += data; + // Keep only last 1000 characters to avoid memory issues + if (exec.outputBuffer.length > 1000) { + exec.outputBuffer = exec.outputBuffer.slice(-1000); + } + } + + // Parse for Container ID + const containerId = this.parseContainerId(data); + if (containerId && installationId) { + this.updateInstallationRecord(installationId, { container_id: containerId }); + } + // Handle data output this.sendMessage(ws, { type: 'output', @@ -280,6 +452,16 @@ class ScriptExecutionHandler { }); }, /** @param {string} error */ (error) => { + // Store error in buffer for logging + const exec = this.activeExecutions.get(executionId); + if (exec) { + exec.outputBuffer += error; + // Keep only last 1000 characters to avoid memory issues + if (exec.outputBuffer.length > 1000) { + exec.outputBuffer = exec.outputBuffer.slice(-1000); + } + } + // Handle errors this.sendMessage(ws, { type: 'error', @@ -288,6 +470,17 @@ class ScriptExecutionHandler { }); }, /** @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) { + this.updateInstallationRecord(installationId, { + status: isSuccess ? 'success' : 'failed', + output_log: exec.outputBuffer + }); + } + // Handle process exit this.sendMessage(ws, { type: 'end', @@ -300,8 +493,13 @@ class ScriptExecutionHandler { } )); - // Store the execution - this.activeExecutions.set(executionId, { process: execution.process, ws }); + // Store the execution with installation ID + this.activeExecutions.set(executionId, { + process: execution.process, + ws, + installationId, + outputBuffer: '' + }); } catch (error) { this.sendMessage(ws, { @@ -309,6 +507,11 @@ class ScriptExecutionHandler { data: `Failed to start SSH execution: ${error instanceof Error ? error.message : String(error)}`, timestamp: Date.now() }); + + // Update installation record with failure + if (installationId) { + this.updateInstallationRecord(installationId, { status: 'failed' }); + } } } @@ -361,6 +564,151 @@ class ScriptExecutionHandler { } } } + + /** + * Start update execution (pct enter + update command) + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + * @param {string} mode + * @param {ServerInfo|null} server + */ + async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null) { + try { + + // Send start message + this.sendMessage(ws, { + type: 'start', + data: `Starting update for container ${containerId}...`, + timestamp: Date.now() + }); + + if (mode === 'ssh' && server) { + await this.startSSHUpdateExecution(ws, containerId, executionId, server); + } else { + await this.startLocalUpdateExecution(ws, containerId, executionId); + } + + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `Failed to start update: ${error instanceof Error ? error.message : String(error)}`, + timestamp: Date.now() + }); + } + } + + /** + * Start local update execution + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + */ + async startLocalUpdateExecution(ws, containerId, executionId) { + const { spawn } = await import('node-pty'); + + // Create a shell process that will run pct enter and then update + 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() + }); + }); + + // Send the update command after a delay to ensure we're in the container + setTimeout(() => { + childProcess.write('update\n'); + }, 4000); + + // Handle process exit + childProcess.onExit((e) => { + this.sendMessage(ws, { + type: 'end', + data: `Update completed with exit code: ${e.exitCode}`, + timestamp: Date.now() + }); + + this.activeExecutions.delete(executionId); + }); + } + + /** + * Start SSH update execution + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + * @param {ServerInfo} server + */ + async startSSHUpdateExecution(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: `Update completed with exit code: ${code}`, + timestamp: Date.now() + }); + + this.activeExecutions.delete(executionId); + } + ); + + // Store the execution + this.activeExecutions.set(executionId, { + process: /** @type {any} */ (execution).process, + ws + }); + + // Send the update command after a delay to ensure we're in the container + setTimeout(() => { + /** @type {any} */ (execution).process.write('update\n'); + }, 4000); + + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `SSH execution failed: ${error instanceof Error ? error.message : String(error)}`, + timestamp: Date.now() + }); + } + } } // TerminalHandler removed - not used by current application diff --git a/src/app/_components/ExecutionModeModal.tsx b/src/app/_components/ExecutionModeModal.tsx index 6705ec5..ea62553 100644 --- a/src/app/_components/ExecutionModeModal.tsx +++ b/src/app/_components/ExecutionModeModal.tsx @@ -32,7 +32,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E throw new Error('Failed to fetch servers'); } const data = await response.json(); - console.log('Fetched servers:', data); setServers(data as Server[]); } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); @@ -47,7 +46,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E return; } - console.log('ExecutionModeModal executing with:', { mode: selectedMode, server: selectedServer }); onExecute(selectedMode, selectedServer ?? undefined); onClose(); }; @@ -203,7 +201,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E onChange={(e) => { const serverId = parseInt(e.target.value); const server = servers.find(s => s.id === serverId); - console.log('Server selected:', { serverId, server }); setSelectedServer(server ?? null); }} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx new file mode 100644 index 0000000..17f2f4a --- /dev/null +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -0,0 +1,324 @@ +'use client'; + +import { useState } from 'react'; +import { api } from '~/trpc/react'; +import { Terminal } from './Terminal'; + +interface InstalledScript { + id: number; + script_name: string; + script_path: 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; + execution_mode: 'local' | 'ssh'; + installation_date: string; + status: 'in_progress' | 'success' | 'failed'; + output_log: string | null; +} + +export function InstalledScriptsTab() { + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all'); + const [serverFilter, setServerFilter] = useState('all'); + const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; mode: 'local' | 'ssh' } | null>(null); + + // Fetch installed scripts + const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery(); + const { data: statsData } = api.installedScripts.getInstallationStats.useQuery(); + + // Delete script mutation + const deleteScriptMutation = api.installedScripts.deleteInstalledScript.useMutation({ + onSuccess: () => { + void refetchScripts(); + } + }); + + + const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? []; + const stats = statsData?.stats; + + // Filter scripts based on search and filters + const filteredScripts = scripts.filter((script: InstalledScript) => { + const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) || + (script.container_id?.includes(searchTerm) ?? false) || + (script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false); + + const matchesStatus = statusFilter === 'all' || script.status === statusFilter; + + const matchesServer = serverFilter === 'all' || + (serverFilter === 'local' && script.execution_mode === 'local') || + (script.server_name === serverFilter); + + return matchesSearch && matchesStatus && matchesServer; + }); + + // Get unique servers for filter + const uniqueServers: string[] = []; + const seen = new Set(); + for (const script of scripts) { + if (script.server_name && !seen.has(String(script.server_name))) { + uniqueServers.push(String(script.server_name)); + seen.add(String(script.server_name)); + } + } + + const handleDeleteScript = (id: number) => { + if (confirm('Are you sure you want to delete this installation record?')) { + void deleteScriptMutation.mutate({ id }); + } + }; + + const handleUpdateScript = (script: InstalledScript) => { + if (!script.container_id) { + alert('No Container ID available for this script'); + return; + } + + if (confirm(`Are you sure you want to update ${script.script_name}?`)) { + // Get server info if it's SSH mode + let server = null; + if (script.execution_mode === 'ssh' && 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 + }; + } + + setUpdatingScript({ + id: script.id, + containerId: script.container_id, + server: server, + mode: script.execution_mode + }); + } + }; + + const handleCloseUpdateTerminal = () => { + setUpdatingScript(null); + }; + + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + const getStatusBadge = (status: string): string => { + const baseClasses = 'px-2 py-1 text-xs font-medium rounded-full'; + switch (status) { + case 'success': + return `${baseClasses} bg-green-100 text-green-800`; + case 'failed': + return `${baseClasses} bg-red-100 text-red-800`; + case 'in_progress': + return `${baseClasses} bg-yellow-100 text-yellow-800`; + default: + return `${baseClasses} bg-gray-100 text-gray-800`; + } + }; + + const getModeBadge = (mode: string): string => { + const baseClasses = 'px-2 py-1 text-xs font-medium rounded-full'; + switch (mode) { + case 'local': + return `${baseClasses} bg-blue-100 text-blue-800`; + case 'ssh': + return `${baseClasses} bg-purple-100 text-purple-800`; + default: + return `${baseClasses} bg-gray-100 text-gray-800`; + } + }; + + if (isLoading) { + return ( +
+
Loading installed scripts...
+
+ ); + } + + return ( +
+ {/* Update Terminal */} + {updatingScript && ( +
+ +
+ )} + + {/* Header with Stats */} +
+

Installed Scripts

+ + {stats && ( +
+
+
{stats.total}
+
Total Installations
+
+
+
{stats.byStatus.success}
+
Successful
+
+
+
{stats.byStatus.failed}
+
Failed
+
+
+
{stats.byStatus.in_progress}
+
In Progress
+
+
+ )} + + {/* Filters */} +
+
+ setSearchTerm(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + + + +
+
+ + {/* Scripts Table */} +
+ {filteredScripts.length === 0 ? ( +
+ {scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'} +
+ ) : ( +
+ + + + + + + + + + + + + + {filteredScripts.map((script) => ( + + + + + + + + + + ))} + +
+ Script Name + + Container ID + + Server + + Mode + + Status + + Date + + Actions +
+
{script.script_name}
+
{script.script_path}
+
+ {script.container_id ? ( + {String(script.container_id)} + ) : ( + - + )} + + {script.execution_mode === 'local' ? ( + Local + ) : ( +
+
{script.server_name}
+
{script.server_ip}
+
+ )} +
+ + {String(script.execution_mode).toUpperCase()} + + + + {String(script.status).replace('_', ' ').toUpperCase()} + + + {formatDate(String(script.installation_date))} + +
+ {script.container_id && ( + + )} + +
+
+
+ )} +
+ +
+ ); +} diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx index 14325d8..e794034 100644 --- a/src/app/_components/Terminal.tsx +++ b/src/app/_components/Terminal.tsx @@ -8,6 +8,8 @@ interface TerminalProps { onClose: () => void; mode?: 'local' | 'ssh'; server?: any; + isUpdate?: boolean; + containerId?: string; } interface TerminalMessage { @@ -16,7 +18,7 @@ interface TerminalMessage { timestamp: number; } -export function Terminal({ scriptPath, onClose, mode = 'local', server }: 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); @@ -161,9 +163,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server }: Termin scriptPath, executionId, mode, - server + server, + isUpdate, + containerId }; - console.log('Sending WebSocket message:', message); ws.send(JSON.stringify(message)); }; @@ -201,7 +204,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server }: Termin wsRef.current.close(); } }; - }, [scriptPath, executionId, mode, server]); + }, [scriptPath, executionId, mode, server, isUpdate, containerId]); const handleMessage = (message: TerminalMessage) => { if (!xtermRef.current) return; @@ -248,7 +251,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server }: Termin scriptPath, executionId, mode, - server + server, + isUpdate, + containerId })); } }; diff --git a/src/app/page.tsx b/src/app/page.tsx index ab1ff8d..4acc565 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,15 +3,16 @@ import { useState } from 'react'; import { ScriptsGrid } from './_components/ScriptsGrid'; +import { InstalledScriptsTab } from './_components/InstalledScriptsTab'; import { ResyncButton } from './_components/ResyncButton'; import { Terminal } from './_components/Terminal'; import { SettingsButton } from './_components/SettingsButton'; export default function Home() { const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); + const [activeTab, setActiveTab] = useState<'scripts' | 'installed'>('scripts'); const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => { - console.log('handleRunScript called with:', { scriptPath, scriptName, mode, server }); setRunningScript({ path: scriptPath, name: scriptName, mode, server }); }; @@ -32,7 +33,33 @@ export default function Home() {

- + {/* Tab Navigation */} +
+
+ +
+
{/* Controls */}
@@ -54,8 +81,14 @@ export default function Home() {
)} - {/* Scripts List */} - + {/* Tab Content */} + {activeTab === 'scripts' && ( + + )} + + {activeTab === 'installed' && ( + + )} ); diff --git a/src/server/api/root.ts b/src/server/api/root.ts index b1d29c8..833f334 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,4 +1,6 @@ import { scriptsRouter } from "~/server/api/routers/scripts"; +import { installedScriptsRouter } from "~/server/api/routers/installedScripts"; +import { serversRouter } from "~/server/api/routers/servers"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; /** @@ -8,6 +10,8 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; */ export const appRouter = createTRPCRouter({ scripts: scriptsRouter, + installedScripts: installedScriptsRouter, + servers: serversRouter, }); // export type definition of API diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts new file mode 100644 index 0000000..f33a1ac --- /dev/null +++ b/src/server/api/routers/installedScripts.ts @@ -0,0 +1,206 @@ +import { z } from "zod"; +import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; +import { getDatabase } from "~/server/database"; + +export const installedScriptsRouter = createTRPCRouter({ + // Get all installed scripts + getAllInstalledScripts: publicProcedure + .query(async () => { + try { + const db = getDatabase(); + const scripts = db.getAllInstalledScripts(); + return { + success: true, + scripts + }; + } catch (error) { + console.error('Error in getAllInstalledScripts:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch installed scripts', + scripts: [] + }; + } + }), + + // Get installed scripts by server + getInstalledScriptsByServer: publicProcedure + .input(z.object({ serverId: z.number() })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const scripts = db.getInstalledScriptsByServer(input.serverId); + return { + success: true, + scripts + }; + } catch (error) { + console.error('Error in getInstalledScriptsByServer:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch installed scripts by server', + scripts: [] + }; + } + }), + + // Get installed script by ID + getInstalledScriptById: publicProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const script = db.getInstalledScriptById(input.id); + if (!script) { + return { + success: false, + error: 'Installed script not found', + script: null + }; + } + return { + success: true, + script + }; + } catch (error) { + console.error('Error in getInstalledScriptById:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch installed script', + script: null + }; + } + }), + + // Create new installed script record + createInstalledScript: publicProcedure + .input(z.object({ + script_name: z.string(), + script_path: z.string(), + container_id: z.string().optional(), + server_id: z.number().optional(), + execution_mode: z.enum(['local', 'ssh']), + status: z.enum(['in_progress', 'success', 'failed']), + output_log: z.string().optional() + })) + .mutation(async ({ input }) => { + try { + const db = getDatabase(); + const result = db.createInstalledScript(input); + return { + success: true, + id: result.lastInsertRowid, + message: 'Installed script record created successfully' + }; + } catch (error) { + console.error('Error in createInstalledScript:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create installed script record' + }; + } + }), + + // Update installed script + updateInstalledScript: publicProcedure + .input(z.object({ + id: z.number(), + container_id: z.string().optional(), + status: z.enum(['in_progress', 'success', 'failed']).optional(), + output_log: z.string().optional() + })) + .mutation(async ({ input }) => { + try { + const { id, ...updateData } = input; + const db = getDatabase(); + const result = db.updateInstalledScript(id, updateData); + + if (result.changes === 0) { + return { + success: false, + error: 'No changes made or script not found' + }; + } + + return { + success: true, + message: 'Installed script updated successfully' + }; + } catch (error) { + console.error('Error in updateInstalledScript:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update installed script' + }; + } + }), + + // Delete installed script + deleteInstalledScript: publicProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + try { + const db = getDatabase(); + const result = db.deleteInstalledScript(input.id); + + if (result.changes === 0) { + return { + success: false, + error: 'Script not found or already deleted' + }; + } + + return { + success: true, + message: 'Installed script deleted successfully' + }; + } catch (error) { + console.error('Error in deleteInstalledScript:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete installed script' + }; + } + }), + + // Get installation statistics + getInstallationStats: publicProcedure + .query(async () => { + try { + const db = getDatabase(); + const allScripts = db.getAllInstalledScripts(); + + const stats = { + total: allScripts.length, + byStatus: { + success: allScripts.filter((s: any) => s.status === 'success').length, + failed: allScripts.filter((s: any) => s.status === 'failed').length, + in_progress: allScripts.filter((s: any) => s.status === 'in_progress').length + }, + byMode: { + local: allScripts.filter((s: any) => s.execution_mode === 'local').length, + ssh: allScripts.filter((s: any) => s.execution_mode === 'ssh').length + }, + byServer: {} as Record + }; + + // Count by server + allScripts.forEach((script: any) => { + const serverKey = script.server_name ?? 'Local'; + stats.byServer[serverKey] = (stats.byServer[serverKey] ?? 0) + 1; + }); + + return { + success: true, + stats + }; + } catch (error) { + console.error('Error in getInstallationStats:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch installation statistics', + stats: null + }; + } + }) +}); diff --git a/src/server/api/routers/servers.ts b/src/server/api/routers/servers.ts new file mode 100644 index 0000000..df3a9ff --- /dev/null +++ b/src/server/api/routers/servers.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; +import { getDatabase } from "~/server/database"; + +export const serversRouter = createTRPCRouter({ + getAllServers: publicProcedure + .query(async () => { + try { + const db = getDatabase(); + const servers = db.getAllServers(); + return { success: true, servers }; + } catch (error) { + console.error('Error fetching servers:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch servers', + servers: [] + }; + } + }), + + getServerById: publicProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const server = db.getServerById(input.id); + if (!server) { + return { success: false, error: 'Server not found', server: null }; + } + return { success: true, server }; + } catch (error) { + console.error('Error fetching server:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch server', + server: null + }; + } + }), +}); diff --git a/src/server/database.js b/src/server/database.js index 2ab0e3a..9300923 100644 --- a/src/server/database.js +++ b/src/server/database.js @@ -22,6 +22,22 @@ class DatabaseService { ) `); + // 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 @@ -80,6 +96,120 @@ class DatabaseService { 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 + 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.container_id] + * @param {string} [updateData.status] + * @param {string} [updateData.output_log] + */ + updateInstalledScript(id, updateData) { + const { container_id, status, output_log } = updateData; + const updates = []; + const values = []; + + 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(); } diff --git a/src/server/services/githubJsonService.ts b/src/server/services/githubJsonService.ts index 679cdf8..5a6848f 100644 --- a/src/server/services/githubJsonService.ts +++ b/src/server/services/githubJsonService.ts @@ -153,7 +153,6 @@ export class GitHubJsonService { const script = await this.downloadJsonFile(`${this.jsonFolder!}/${slug}.json`); return script; } catch { - console.log(`Script ${slug} not found in repository`); return null; } } catch (error) { diff --git a/src/server/ssh-execution-service.js b/src/server/ssh-execution-service.js index 29b3fdf..e3968bc 100644 --- a/src/server/ssh-execution-service.js +++ b/src/server/ssh-execution-service.js @@ -141,6 +141,60 @@ class SSHExecutionService { }); } + /** + * Execute a direct command on a remote server via SSH + * @param {Server} server - Server configuration + * @param {string} command - Command to execute + * @param {Function} onData - Callback for data output + * @param {Function} onError - Callback for errors + * @param {Function} onExit - Callback for process exit + * @returns {Promise} Process information + */ + async executeCommand(server, command, onData, onError, onExit) { + const { ip, user, password } = server; + + return new Promise((resolve, reject) => { + // Use ptySpawn for proper terminal emulation and color support + const sshCommand = ptySpawn('sshpass', [ + '-p', password, + 'ssh', + '-t', + '-o', 'ConnectTimeout=10', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'LogLevel=ERROR', + '-o', 'PasswordAuthentication=yes', + '-o', 'PubkeyAuthentication=no', + '-o', 'RequestTTY=yes', + '-o', 'SetEnv=TERM=xterm-256color', + '-o', 'SetEnv=COLUMNS=120', + '-o', 'SetEnv=LINES=30', + '-o', 'SetEnv=COLORTERM=truecolor', + '-o', 'SetEnv=FORCE_COLOR=1', + '-o', 'SetEnv=NO_COLOR=0', + '-o', 'SetEnv=CLICOLOR=1', + `${user}@${ip}`, + command + ], { + name: 'xterm-color', + cols: 120, + rows: 30, + cwd: process.cwd(), + env: process.env + }); + + sshCommand.onData((data) => { + onData(data); + }); + + sshCommand.onExit((e) => { + onExit(e.exitCode); + }); + + resolve({ process: sshCommand }); + }); + } + } // Singleton instance