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:
Michel Roegl-Brunner
2025-10-03 13:05:25 +02:00
committed by GitHub
parent 9d2a1a1b5c
commit 024ffcbf09
16 changed files with 1431 additions and 37 deletions

View File

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

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

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

@@ -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,19 +270,27 @@ 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');
const resolvedPath = resolve(scriptPath);
@@ -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

View File

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

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

View File

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

View File

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

View File

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

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

View 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
};
}
}),
});

View File

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

View File

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

View File

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