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.'}
+
+ ) : (
+
+
+
+
+ |
+ Script Name
+ |
+
+ Container ID
+ |
+
+ Server
+ |
+
+ Mode
+ |
+
+ Status
+ |
+
+ Date
+ |
+
+ Actions
+ |
+
+
+
+ {filteredScripts.map((script) => (
+
+ |
+ {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