feat: Add script installation tracking and update functionality (#36)
* 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 <ct-id> -- 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 <ID>' 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 <ct-id> -- 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 <CT ID> -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 <CT ID> -c "update"' to proper sequence
- First run 'pct enter <CT ID>' 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
This commit is contained in:
committed by
GitHub
parent
9d2a1a1b5c
commit
024ffcbf09
@@ -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
|
||||
|
||||
81
scripts/ct/2fauth.sh
Normal file
81
scripts/ct/2fauth.sh
Normal file
@@ -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}"
|
||||
43
scripts/ct/debian.sh
Normal file
43
scripts/ct/debian.sh
Normal file
@@ -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}"
|
||||
104
scripts/install/2fauth-install.sh
Normal file
104
scripts/install/2fauth-install.sh
Normal file
@@ -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 <<EOF >/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"
|
||||
24
scripts/install/debian-install.sh
Normal file
24
scripts/install/debian-install.sh
Normal file
@@ -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"
|
||||
|
||||
394
server.js
394
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
324
src/app/_components/InstalledScriptsTab.tsx
Normal file
324
src/app/_components/InstalledScriptsTab.tsx
Normal file
@@ -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<string>('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<string>();
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading installed scripts...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Update Terminal */}
|
||||
{updatingScript && (
|
||||
<div className="mb-8">
|
||||
<Terminal
|
||||
scriptPath={`update-${updatingScript.containerId}`}
|
||||
onClose={handleCloseUpdateTerminal}
|
||||
mode={updatingScript.mode}
|
||||
server={updatingScript.server}
|
||||
isUpdate={true}
|
||||
containerId={updatingScript.containerId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header with Stats */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Installed Scripts</h2>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.total}</div>
|
||||
<div className="text-sm text-blue-800">Total Installations</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.byStatus.success}</div>
|
||||
<div className="text-sm text-green-800">Successful</div>
|
||||
</div>
|
||||
<div className="bg-red-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.byStatus.failed}</div>
|
||||
<div className="text-sm text-red-800">Failed</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-600">{stats.byStatus.in_progress}</div>
|
||||
<div className="text-sm text-yellow-800">In Progress</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search scripts, container IDs, or servers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={serverFilter}
|
||||
onChange={(e) => setServerFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Servers</option>
|
||||
<option value="local">Local</option>
|
||||
{uniqueServers.map(server => (
|
||||
<option key={server} value={server}>{server}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scripts Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{filteredScripts.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Script Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Container ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Server
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Mode
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredScripts.map((script) => (
|
||||
<tr key={script.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{script.script_name}</div>
|
||||
<div className="text-sm text-gray-500">{script.script_path}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{script.container_id ? (
|
||||
<span className="text-sm font-mono text-gray-900">{String(script.container_id)}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{script.execution_mode === 'local' ? (
|
||||
<span className="text-sm text-gray-900">Local</span>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{script.server_name}</div>
|
||||
<div className="text-sm text-gray-500">{script.server_ip}</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={getModeBadge(String(script.execution_mode))}>
|
||||
{String(script.execution_mode).toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={getStatusBadge(String(script.status))}>
|
||||
{String(script.status).replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(String(script.installation_date))}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
{script.container_id && (
|
||||
<button
|
||||
onClick={() => handleUpdateScript(script)}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteScript(Number(script.id))}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
disabled={deleteScriptMutation.isPending}
|
||||
>
|
||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-8">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('scripts')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'scripts'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
📦 Available Scripts
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('installed')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'installed'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
🗂️ Installed Scripts
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-8">
|
||||
@@ -54,8 +81,14 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scripts List */}
|
||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'scripts' && (
|
||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'installed' && (
|
||||
<InstalledScriptsTab />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
206
src/server/api/routers/installedScripts.ts
Normal file
206
src/server/api/routers/installedScripts.ts
Normal file
@@ -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<string, number>
|
||||
};
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
41
src/server/api/routers/servers.ts
Normal file
41
src/server/api/routers/servers.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Object>} 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
|
||||
|
||||
Reference in New Issue
Block a user