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

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