From 5274737ab8564f8b57999306f86eff793c0c15d1 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Fri, 28 Nov 2025 11:44:58 +0100 Subject: [PATCH 1/2] Add VM status check and UI improvements - Add VM status checking using qm status command - Hide update button for VMs (only show for LXC containers) - Hide shell button for VMs (only show for LXC containers) - Hide LXC Settings option for VMs - Display VM/LXC indicator badges in table before script names - Update statistics cards to differentiate between LXC and VMs - Update container control to support both pct (LXC) and qm (VM) commands - Improve status parsing to handle both container types --- src/app/_components/InstalledScriptsTab.tsx | 43 +- .../_components/ScriptInstallationCard.tsx | 5 +- src/server/api/routers/installedScripts.ts | 724 ++++++++++++------ 3 files changed, 546 insertions(+), 226 deletions(-) diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index ad155b4..ef1f115 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -45,6 +45,7 @@ interface InstalledScript { container_status?: 'running' | 'stopped' | 'unknown'; web_ui_ip: string | null; web_ui_port: number | null; + is_vm?: boolean; } export function InstalledScriptsTab() { @@ -1077,23 +1078,35 @@ export function InstalledScriptsTab() {

Installed Scripts

{stats && ( -
+
{stats.total}
Total Installations
- {scriptsWithStatus.filter(script => script.container_status === 'running').length} + {scriptsWithStatus.filter(script => script.container_status === 'running' && !script.is_vm).length}
Running LXC
+
+
+ {scriptsWithStatus.filter(script => script.container_status === 'running' && script.is_vm).length} +
+
Running VMs
+
- {scriptsWithStatus.filter(script => script.container_status === 'stopped').length} + {scriptsWithStatus.filter(script => script.container_status === 'stopped' && !script.is_vm).length}
Stopped LXC
+
+
+ {scriptsWithStatus.filter(script => script.container_status === 'stopped' && script.is_vm).length} +
+
Stopped VMs
+
)} @@ -1527,7 +1540,18 @@ export function InstalledScriptsTab() {
) : (
-
{script.script_name}
+
+ {script.container_id && ( + + {script.is_vm ? 'VM' : 'LXC'} + + )} +
{script.script_name}
+
{script.script_path}
)} @@ -1683,7 +1707,7 @@ export function InstalledScriptsTab() { - {script.container_id && ( + {script.container_id && !script.is_vm && ( handleUpdateScript(script)} disabled={containerStatuses.get(script.id) === 'stopped'} @@ -1701,7 +1725,7 @@ export function InstalledScriptsTab() { Backup )} - {script.container_id && script.execution_mode === 'ssh' && ( + {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && ( handleOpenShell(script)} disabled={containerStatuses.get(script.id) === 'stopped'} @@ -1728,7 +1752,7 @@ export function InstalledScriptsTab() { {autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'} )} - {script.container_id && script.execution_mode === 'ssh' && ( + {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && ( <> + + )} + {script.container_id && script.execution_mode === 'ssh' && ( + <> + {script.is_vm && } handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')} disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'} diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx index 6221839..75bde46 100644 --- a/src/app/_components/ScriptInstallationCard.tsx +++ b/src/app/_components/ScriptInstallationCard.tsx @@ -33,6 +33,7 @@ interface InstalledScript { container_status?: 'running' | 'stopped' | 'unknown'; web_ui_ip: string | null; web_ui_port: number | null; + is_vm?: boolean; } interface ScriptInstallationCardProps { @@ -300,7 +301,7 @@ export function ScriptInstallationCard({ - {script.container_id && ( + {script.container_id && !script.is_vm && ( )} - {script.container_id && script.execution_mode === 'ssh' && ( + {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && ( { + const db = getDatabase(); + + // Method 1: Check if LXCConfig exists (if exists, it's an LXC container) + const lxcConfig = await db.getLXCConfigByScriptId(scriptId); + if (lxcConfig) { + return false; // Has LXCConfig, so it's an LXC container + } + + // Method 2: If no LXCConfig, check config file paths on server + if (!serverId) { + // Can't determine without server, default to false (LXC) for safety + return false; + } + + try { + const server = await db.getServerById(serverId); + if (!server) { + return false; // Default to LXC if server not found + } + + // Import SSH services + const { default: SSHService } = await import('~/server/ssh-service'); + const { default: SSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshService = new SSHService(); + const sshExecutionService = new SSHExecutionService(); + + // Test SSH connection + const connectionTest = await sshService.testSSHConnection(server as Server); + if (!(connectionTest as any).success) { + return false; // Default to LXC if SSH fails + } + + // Check both config file paths + const vmConfigPath = `/etc/pve/qemu-server/${containerId}.conf`; + const lxcConfigPath = `/etc/pve/lxc/${containerId}.conf`; + + // Check VM config file + let vmConfigExists = false; + await new Promise((resolve) => { + void sshExecutionService.executeCommand( + server as Server, + `test -f "${vmConfigPath}" && echo "exists" || echo "not_exists"`, + (data: string) => { + if (data.includes('exists')) { + vmConfigExists = true; + } + }, + () => resolve(), + () => resolve() + ); + }); + + if (vmConfigExists) { + return true; // VM config file exists + } + + // Check LXC config file + let lxcConfigExists = false; + await new Promise((resolve) => { + void sshExecutionService.executeCommand( + server as Server, + `test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`, + (data: string) => { + if (data.includes('exists')) { + lxcConfigExists = true; + } + }, + () => resolve(), + () => resolve() + ); + }); + + // If LXC config exists, it's an LXC container + return !lxcConfigExists; // Return true if it's a VM (neither config exists defaults to false/LXC) + } catch (error) { + console.error('Error determining container type:', error); + return false; // Default to LXC on error + } +} + export const installedScriptsRouter = createTRPCRouter({ // Get all installed scripts @@ -393,18 +475,27 @@ export const installedScriptsRouter = createTRPCRouter({ const scripts = await db.getAllInstalledScripts(); // Transform scripts to flatten server data for frontend compatibility - const transformedScripts = scripts.map(script => ({ - ...script, - server_name: script.server?.name ?? null, - server_ip: script.server?.ip ?? null, - server_user: script.server?.user ?? null, - server_password: script.server?.password ?? null, - server_auth_type: script.server?.auth_type ?? null, - server_ssh_key: script.server?.ssh_key ?? null, - server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null, - server_ssh_port: script.server?.ssh_port ?? null, - server_color: script.server?.color ?? null, - server: undefined // Remove nested server object + const transformedScripts = await Promise.all(scripts.map(async (script) => { + // Determine if it's a VM or LXC + let is_vm = false; + if (script.container_id && script.server_id) { + is_vm = await isVM(script.id, script.container_id, script.server_id); + } + + return { + ...script, + server_name: script.server?.name ?? null, + server_ip: script.server?.ip ?? null, + server_user: script.server?.user ?? null, + server_password: script.server?.password ?? null, + server_auth_type: script.server?.auth_type ?? null, + server_ssh_key: script.server?.ssh_key ?? null, + server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null, + server_ssh_port: script.server?.ssh_port ?? null, + server_color: script.server?.color ?? null, + is_vm, + server: undefined // Remove nested server object + }; })); return { @@ -430,18 +521,27 @@ export const installedScriptsRouter = createTRPCRouter({ const scripts = await db.getInstalledScriptsByServer(input.serverId); // Transform scripts to flatten server data for frontend compatibility - const transformedScripts = scripts.map(script => ({ - ...script, - server_name: script.server?.name ?? null, - server_ip: script.server?.ip ?? null, - server_user: script.server?.user ?? null, - server_password: script.server?.password ?? null, - server_auth_type: script.server?.auth_type ?? null, - server_ssh_key: script.server?.ssh_key ?? null, - server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null, - server_ssh_port: script.server?.ssh_port ?? null, - server_color: script.server?.color ?? null, - server: undefined // Remove nested server object + const transformedScripts = await Promise.all(scripts.map(async (script) => { + // Determine if it's a VM or LXC + let is_vm = false; + if (script.container_id && script.server_id) { + is_vm = await isVM(script.id, script.container_id, script.server_id); + } + + return { + ...script, + server_name: script.server?.name ?? null, + server_ip: script.server?.ip ?? null, + server_user: script.server?.user ?? null, + server_password: script.server?.password ?? null, + server_auth_type: script.server?.auth_type ?? null, + server_ssh_key: script.server?.ssh_key ?? null, + server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null, + server_ssh_port: script.server?.ssh_port ?? null, + server_color: script.server?.color ?? null, + is_vm, + server: undefined // Remove nested server object + }; })); return { @@ -472,6 +572,12 @@ export const installedScriptsRouter = createTRPCRouter({ script: null }; } + // Determine if it's a VM or LXC + let is_vm = false; + if (script.container_id && script.server_id) { + is_vm = await isVM(script.id, script.container_id, script.server_id); + } + // Transform script to flatten server data for frontend compatibility const transformedScript = { ...script, @@ -484,6 +590,7 @@ export const installedScriptsRouter = createTRPCRouter({ server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null, server_ssh_port: script.server?.ssh_port ?? null, server_color: script.server?.color ?? null, + is_vm, server: undefined // Remove nested server object }; @@ -677,113 +784,159 @@ export const installedScriptsRouter = createTRPCRouter({ } - // Use the working approach - manual loop through all config files - const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`; + // Get containers from pct list and VMs from qm list let detectedContainers: any[] = []; + // Helper function to parse list output and extract IDs + const parseListOutput = (output: string, isVM: boolean): string[] => { + const ids: string[] = []; + const lines = output.split('\n').filter(line => line.trim()); + + for (const line of lines) { + // Skip header lines + if (line.includes('VMID') || line.includes('CTID')) continue; + + // Extract first column (ID) + const parts = line.trim().split(/\s+/); + if (parts.length > 0) { + const id = parts[0]?.trim(); + // Validate ID format (3-4 digits typically) + if (id && /^\d{3,4}$/.test(id)) { + ids.push(id); + } + } + } + + return ids; + }; - let commandOutput = ''; - + // Helper function to check config file for community-script tag and extract hostname/name + const checkConfigAndExtractInfo = async (id: string, isVM: boolean): Promise => { + const configPath = isVM + ? `/etc/pve/qemu-server/${id}.conf` + : `/etc/pve/lxc/${id}.conf`; + + const readCommand = `cat "${configPath}" 2>/dev/null`; + + return new Promise((resolve) => { + let configData = ''; + + void sshExecutionService.executeCommand( + server as Server, + readCommand, + (data: string) => { + configData += data; + }, + (_error: string) => { + // Config file doesn't exist or can't be read + resolve(null); + }, + (_exitCode: number) => { + // Check if config contains community-script tag + if (!configData.includes('community-script')) { + resolve(null); + return; + } + + // Extract hostname (for containers) or name (for VMs) + const lines = configData.split('\n'); + let hostname = ''; + let name = ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (trimmedLine.startsWith('hostname:')) { + hostname = trimmedLine.substring(9).trim(); + } else if (trimmedLine.startsWith('name:')) { + name = trimmedLine.substring(5).trim(); + } + } + + // Use hostname for containers, name for VMs + const displayName = isVM ? name : hostname; + + if (displayName) { + // Parse full config and store in database (only for containers) + let parsedConfig = null; + let configHash = null; + + if (!isVM) { + parsedConfig = parseRawConfig(configData); + configHash = calculateConfigHash(configData); + } + + resolve({ + containerId: id, + hostname: displayName, + configPath, + isVM, + serverId: Number((server as any).id), + serverName: (server as any).name, + parsedConfig: parsedConfig ? { + ...parsedConfig, + config_hash: configHash, + synced_at: new Date() + } : null + }); + } else { + resolve(null); + } + } + ); + }); + }; + + // Get containers from pct list + let pctOutput = ''; await new Promise((resolve, reject) => { - void sshExecutionService.executeCommand( - server as Server, - command, + 'pct list', (data: string) => { - commandOutput += data; + pctOutput += data; }, (error: string) => { - console.error('Command error:', error); + console.error('pct list error:', error); + reject(new Error(`pct list failed: ${error}`)); }, (_exitCode: number) => { - - // Parse the complete output to get config file paths that contain community-script tag - const configFiles = commandOutput.split('\n') - .filter((line: string) => line.trim()) - .map((line: string) => line.trim()) - .filter((line: string) => line.endsWith('.conf')); - - - // Process each config file to extract hostname - const processPromises = configFiles.map(async (configPath: string) => { - try { - const containerId = configPath.split('/').pop()?.replace('.conf', ''); - if (!containerId) return null; - - - // Read the config file content - const readCommand = `cat "${configPath}" 2>/dev/null`; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return new Promise((readResolve) => { - - void sshExecutionService.executeCommand( - - server as Server, - readCommand, - (configData: string) => { - // Parse config file for hostname - const lines = configData.split('\n'); - let hostname = ''; - - for (const line of lines) { - const trimmedLine = line.trim(); - if (trimmedLine.startsWith('hostname:')) { - hostname = trimmedLine.substring(9).trim(); - break; - } - } - - if (hostname) { - // Parse full config and store in database - const parsedConfig = parseRawConfig(configData); - const configHash = calculateConfigHash(configData); - - const container = { - containerId, - hostname, - configPath, - serverId: Number((server as any).id), - serverName: (server as any).name, - parsedConfig: { - ...parsedConfig, - config_hash: configHash, - synced_at: new Date() - } - }; - readResolve(container); - } else { - readResolve(null); - } - }, - (readError: string) => { - console.error(`Error reading config file ${configPath}:`, readError); - readResolve(null); - }, - (_exitCode: number) => { - readResolve(null); - } - ); - }); - } catch (error) { - console.error(`Error processing config file ${configPath}:`, error); - return null; - } - }); - - // Wait for all config files to be processed - void Promise.all(processPromises).then((results) => { - detectedContainers = results.filter(result => result !== null); - resolve(); - }).catch((error) => { - console.error('Error processing config files:', error); - reject(new Error(`Error processing config files: ${error}`)); - }); + resolve(); } ); }); + // Get VMs from qm list + let qmOutput = ''; + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + server as Server, + 'qm list', + (data: string) => { + qmOutput += data; + }, + (error: string) => { + console.error('qm list error:', error); + reject(new Error(`qm list failed: ${error}`)); + }, + (_exitCode: number) => { + resolve(); + } + ); + }); + + // Parse IDs from both lists + const containerIds = parseListOutput(pctOutput, false); + const vmIds = parseListOutput(qmOutput, true); + + // Check each container/VM for community-script tag + const checkPromises = [ + ...containerIds.map(id => checkConfigAndExtractInfo(id, false)), + ...vmIds.map(id => checkConfigAndExtractInfo(id, true)) + ]; + + const results = await Promise.all(checkPromises); + detectedContainers = results.filter(result => result !== null); + // Get existing scripts to check for duplicates const existingScripts = await db.getAllInstalledScripts(); @@ -816,11 +969,11 @@ export const installedScriptsRouter = createTRPCRouter({ server_id: container.serverId, execution_mode: 'ssh', status: 'success', - output_log: `Auto-detected from LXC config: ${container.configPath}` + output_log: `Auto-detected from ${container.isVM ? 'VM' : 'LXC'} config: ${container.configPath}` }); - // Store LXC config in database - if (container.parsedConfig) { + // Store LXC config in database (only for containers, not VMs) + if (container.parsedConfig && !container.isVM) { await db.createLXCConfig(result.id, container.parsedConfig); } @@ -836,8 +989,8 @@ export const installedScriptsRouter = createTRPCRouter({ } const message = skippedScripts.length > 0 - ? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.` - : `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`; + ? `Auto-detection completed. Found ${detectedContainers.length} containers/VMs with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.` + : `Auto-detection completed. Found ${detectedContainers.length} containers/VMs with community-script tag. Added ${createdScripts.length} new scripts.`; return { success: true, @@ -920,11 +1073,32 @@ export const installedScriptsRouter = createTRPCRouter({ continue; } - // Get all existing containers from pct list (more reliable than checking config files) - const listCommand = 'pct list'; - let listOutput = ''; - - const existingContainerIds = await new Promise>((resolve, reject) => { + // Helper function to parse list output and extract IDs + const parseListOutput = (output: string): Set => { + const ids = new Set(); + const lines = output.split('\n').filter(line => line.trim()); + + for (const line of lines) { + // Skip header lines + if (line.includes('VMID') || line.includes('CTID')) continue; + + // Extract first column (ID) + const parts = line.trim().split(/\s+/); + if (parts.length > 0) { + const id = parts[0]?.trim(); + // Validate ID format (3-4 digits typically) + if (id && /^\d{3,4}$/.test(id)) { + ids.add(id); + } + } + } + + return ids; + }; + + // Get all existing containers from pct list + let pctOutput = ''; + const existingContainerIds = await new Promise>((resolve) => { const timeout = setTimeout(() => { console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`); resolve(new Set()); // Treat timeout as no containers found @@ -932,9 +1106,9 @@ export const installedScriptsRouter = createTRPCRouter({ void sshExecutionService.executeCommand( server as Server, - listCommand, + 'pct list', (data: string) => { - listOutput += data; + pctOutput += data; }, (error: string) => { console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error); @@ -943,58 +1117,95 @@ export const installedScriptsRouter = createTRPCRouter({ }, (_exitCode: number) => { clearTimeout(timeout); - - // Parse pct list output to extract container IDs - const containerIds = new Set(); - const lines = listOutput.split('\n').filter(line => line.trim()); - - for (const line of lines) { - // pct list format: CTID Status Name - // Skip header line if present - if (line.includes('CTID') || line.includes('VMID')) continue; - - const parts = line.trim().split(/\s+/); - if (parts.length > 0) { - const containerId = parts[0]?.trim(); - if (containerId && /^\d{3,4}$/.test(containerId)) { - containerIds.add(containerId); - } - } - } - - resolve(containerIds); + resolve(parseListOutput(pctOutput)); } ); }); - // Check each script against the list of existing containers + // Get all existing VMs from qm list + let qmOutput = ''; + const existingVMIds = await new Promise>((resolve) => { + const timeout = setTimeout(() => { + console.warn(`cleanupOrphanedScripts: timeout while getting VM list from server ${String((server as any).name)}`); + resolve(new Set()); // Treat timeout as no VMs found + }, 20000); + + void sshExecutionService.executeCommand( + server as Server, + 'qm list', + (data: string) => { + qmOutput += data; + }, + (error: string) => { + console.error(`cleanupOrphanedScripts: error getting VM list from server ${String((server as any).name)}:`, error); + clearTimeout(timeout); + resolve(new Set()); // Treat error as no VMs found + }, + (_exitCode: number) => { + clearTimeout(timeout); + resolve(parseListOutput(qmOutput)); + } + ); + }); + + // Combine both sets - an ID exists if it's in either list + const existingIds = new Set([...existingContainerIds, ...existingVMIds]); + + // Check each script against the list of existing containers and VMs for (const scriptData of serverScripts) { try { const containerId = String(scriptData.container_id).trim(); - // Check if container exists in pct list - if (!existingContainerIds.has(containerId)) { + // Check if ID exists in either pct list (containers) or qm list (VMs) + if (!existingIds.has(containerId)) { // Also verify config file doesn't exist as a double-check - const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`; + // Check both container and VM config paths + const checkContainerCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`; + const checkVMCommand = `test -f "/etc/pve/qemu-server/${containerId}.conf" && echo "exists" || echo "not_found"`; const configExists = await new Promise((resolve) => { let combinedOutput = ''; let resolved = false; + let checksCompleted = 0; const finish = () => { if (resolved) return; - resolved = true; - const out = combinedOutput.trim(); - resolve(out.includes('exists')); + checksCompleted++; + if (checksCompleted === 2) { + resolved = true; + clearTimeout(timer); + const out = combinedOutput.trim(); + resolve(out.includes('exists')); + } }; const timer = setTimeout(() => { - finish(); + if (!resolved) { + resolved = true; + const out = combinedOutput.trim(); + resolve(out.includes('exists')); + } }, 10000); + // Check container config void sshExecutionService.executeCommand( server as Server, - checkCommand, + checkContainerCommand, + (data: string) => { + combinedOutput += data; + }, + (_error: string) => { + // Ignore errors, just check output + }, + (_exitCode: number) => { + finish(); + } + ); + + // Check VM config + void sshExecutionService.executeCommand( + server as Server, + checkVMCommand, (data: string) => { combinedOutput += data; }, @@ -1002,20 +1213,19 @@ export const installedScriptsRouter = createTRPCRouter({ // Ignore errors, just check output }, (_exitCode: number) => { - clearTimeout(timer); finish(); } ); }); - // If container is not in pct list AND config file doesn't exist, it's orphaned + // If ID is not in either list AND config file doesn't exist, it's orphaned if (!configExists) { - console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`); + console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (ID ${containerId}) from server ${String((server as any).name)}`); await db.deleteInstalledScript(Number(scriptData.id)); deletedScripts.push(String(scriptData.script_name)); } else { - // Config exists but not in pct list - might be in a transitional state, log but don't delete - console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`); + // Config exists but not in lists - might be in a transitional state, log but don't delete + console.warn(`cleanupOrphanedScripts: Container/VM ${containerId} (${String(scriptData.script_name)}) config exists but not in pct/qm list - may be in transitional state`); } } } catch (error) { @@ -1080,59 +1290,120 @@ export const installedScriptsRouter = createTRPCRouter({ continue; } - // Run pct list to get all container statuses at once - const listCommand = 'pct list'; - let listOutput = ''; + // Helper function to parse list output and extract statuses + const parseListStatuses = (output: string): Record => { + const statuses: Record = {}; + const lines = output.split('\n').filter(line => line.trim()); + + // Find header line to determine column positions + let statusColumnIndex = 1; // Default to second column + for (const line of lines) { + if (line.includes('STATUS')) { + // Parse header to find STATUS column index + const headerParts = line.trim().split(/\s+/); + const statusIndex = headerParts.findIndex(part => part.includes('STATUS')); + if (statusIndex >= 0) { + statusColumnIndex = statusIndex; + } + break; + } + } + + for (const line of lines) { + // Skip header lines + if (line.includes('VMID') || line.includes('CTID') || line.includes('STATUS')) continue; + + // Parse line + const parts = line.trim().split(/\s+/); + if (parts.length > statusColumnIndex) { + const id = parts[0]?.trim(); + const status = parts[statusColumnIndex]?.trim().toLowerCase(); + + if (id && /^\d+$/.test(id)) { // Validate ID is numeric + // Map status to our status format + let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown'; + if (status === 'running') { + mappedStatus = 'running'; + } else if (status === 'stopped') { + mappedStatus = 'stopped'; + } + // All other statuses (paused, locked, suspended, etc.) map to 'unknown' + + statuses[id] = mappedStatus; + } + } + } + + return statuses; + }; + + // Run pct list to get all container statuses + let pctOutput = ''; // Add timeout to prevent hanging connections const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('SSH command timeout after 30 seconds')), 30000); }); - await Promise.race([ - new Promise((resolve, reject) => { - void sshExecutionService.executeCommand( - - server as Server, - listCommand, - (data: string) => { - listOutput += data; - }, - (error: string) => { - console.error(`pct list error on server ${(server as any).name}:`, error); - reject(new Error(error)); - }, - (_exitCode: number) => { - resolve(); - } - ); - }), - timeoutPromise - ]); - - // Parse pct list output - const lines = listOutput.split('\n').filter(line => line.trim()); - for (const line of lines) { - // pct list format: CTID Status Name - // Example: "100 running my-container" - const parts = line.trim().split(/\s+/); - if (parts.length >= 3) { - const containerId = parts[0]; - const status = parts[1]; - - if (containerId && status) { - // Map pct list status to our status - let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown'; - if (status === 'running') { - mappedStatus = 'running'; - } else if (status === 'stopped') { - mappedStatus = 'stopped'; - } - - statusMap[containerId] = mappedStatus; - } - } + try { + await Promise.race([ + new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + server as Server, + 'pct list', + (data: string) => { + pctOutput += data; + }, + (error: string) => { + console.error(`pct list error on server ${(server as any).name}:`, error); + // Don't reject, just continue with empty output + resolve(); + }, + (_exitCode: number) => { + resolve(); + } + ); + }), + timeoutPromise + ]); + } catch (error) { + console.error(`Timeout or error getting pct list from server ${(server as any).name}:`, error); } + + // Run qm list to get all VM statuses + let qmOutput = ''; + + try { + await Promise.race([ + new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + server as Server, + 'qm list', + (data: string) => { + qmOutput += data; + }, + (error: string) => { + console.error(`qm list error on server ${(server as any).name}:`, error); + // Don't reject, just continue with empty output + resolve(); + }, + (_exitCode: number) => { + resolve(); + } + ); + }), + timeoutPromise + ]); + } catch (error) { + console.error(`Timeout or error getting qm list from server ${(server as any).name}:`, error); + } + + // Parse both outputs and combine into statusMap + const containerStatuses = parseListStatuses(pctOutput); + const vmStatuses = parseListStatuses(qmOutput); + + // Merge both status maps (VMs will overwrite containers if same ID, but that's unlikely) + Object.assign(statusMap, containerStatuses, vmStatuses); } catch (error) { console.error(`Error processing server ${(server as any).name}:`, error); } @@ -1207,8 +1478,13 @@ export const installedScriptsRouter = createTRPCRouter({ }; } - // Check container status - const statusCommand = `pct status ${scriptData.container_id}`; + // Determine if it's a VM or LXC + const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id); + + // Check container status (use qm for VMs, pct for LXC) + const statusCommand = vm + ? `qm status ${scriptData.container_id}` + : `pct status ${scriptData.container_id}`; let statusOutput = ''; await new Promise((resolve, reject) => { @@ -1305,8 +1581,13 @@ export const installedScriptsRouter = createTRPCRouter({ }; } - // Execute control command - const controlCommand = `pct ${input.action} ${scriptData.container_id}`; + // Determine if it's a VM or LXC + const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id); + + // Execute control command (use qm for VMs, pct for LXC) + const controlCommand = vm + ? `qm ${input.action} ${scriptData.container_id}` + : `pct ${input.action} ${scriptData.container_id}`; let commandOutput = ''; let commandError = ''; @@ -1396,8 +1677,13 @@ export const installedScriptsRouter = createTRPCRouter({ }; } + // Determine if it's a VM or LXC + const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id); + // First check if container is running and stop it if necessary - const statusCommand = `pct status ${scriptData.container_id}`; + const statusCommand = vm + ? `qm status ${scriptData.container_id}` + : `pct status ${scriptData.container_id}`; let statusOutput = ''; try { @@ -1420,8 +1706,10 @@ export const installedScriptsRouter = createTRPCRouter({ // Check if container is running if (statusOutput.includes('status: running')) { - // Stop the container first - const stopCommand = `pct stop ${scriptData.container_id}`; + // Stop the container first (use qm for VMs, pct for LXC) + const stopCommand = vm + ? `qm stop ${scriptData.container_id}` + : `pct stop ${scriptData.container_id}`; let stopOutput = ''; let stopError = ''; @@ -1451,8 +1739,10 @@ export const installedScriptsRouter = createTRPCRouter({ } - // Execute destroy command - const destroyCommand = `pct destroy ${scriptData.container_id}`; + // Execute destroy command (use qm for VMs, pct for LXC) + const destroyCommand = vm + ? `qm destroy ${scriptData.container_id}` + : `pct destroy ${scriptData.container_id}`; let commandOutput = ''; let commandError = ''; From 7833d5d408ebc29f02dbd350d20aea23e212f69b Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Fri, 28 Nov 2025 13:21:37 +0100 Subject: [PATCH 2/2] Fix type errors --- eslint.config.js | 17 +- next.config.js | 4 +- package.json | 6 +- server.js | 224 +++-------------- src/app/_components/BackupWarningModal.tsx | 2 +- src/app/_components/BackupsTab.tsx | 76 +++--- src/app/_components/DownloadedScriptsTab.tsx | 2 +- src/app/_components/FilterBar.tsx | 2 +- src/app/_components/GeneralSettingsModal.tsx | 27 ++- src/app/_components/HelpModal.tsx | 4 +- src/app/_components/InstalledScriptsTab.tsx | 10 +- src/app/_components/LXCSettingsModal.tsx | 10 +- src/app/_components/LoadingModal.tsx | 6 +- src/app/_components/PBSCredentialsModal.tsx | 14 +- src/app/_components/ReleaseNotesModal.tsx | 6 +- src/app/_components/ResyncButton.tsx | 54 ++++- src/app/_components/ScriptCard.tsx | 2 +- src/app/_components/ScriptCardList.tsx | 2 +- src/app/_components/ScriptDetailModal.tsx | 12 +- src/app/_components/ScriptVersionModal.tsx | 2 +- src/app/_components/ScriptsGrid.tsx | 2 +- src/app/_components/ServerForm.tsx | 12 +- src/app/_components/ServerStoragesModal.tsx | 2 +- src/app/_components/Terminal.tsx | 78 +++++- src/app/_components/TextViewer.tsx | 7 +- src/app/_components/ThemeProvider.tsx | 10 +- .../_components/UpdateConfirmationModal.tsx | 3 +- src/app/_components/VersionDisplay.tsx | 228 +++++++++++++++--- src/app/api/settings/auto-sync/route.ts | 82 +++++-- src/app/page.tsx | 16 +- src/lib/auth.ts | 2 +- src/server/api/routers/backups.ts | 3 +- src/server/api/routers/installedScripts.ts | 32 +-- src/server/api/routers/scripts.ts | 2 +- src/server/api/routers/version.ts | 21 +- src/server/lib/autoSyncInit.ts | 17 ++ src/server/services/autoSyncService.js | 1 - src/server/services/backupService.ts | 67 ++--- src/server/services/githubJsonService.ts | 10 +- src/server/services/repositoryService.ts | 6 +- src/server/services/restoreService.ts | 91 ++++--- src/server/services/scriptDownloader.js | 150 ++++++++---- src/server/services/storageService.ts | 29 +-- 43 files changed, 829 insertions(+), 524 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 4007220..80d5aba 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,15 +1,20 @@ -import { FlatCompat } from "@eslint/eslintrc"; import tseslint from "typescript-eslint"; +import { createRequire } from "module"; +import { fileURLToPath } from "url"; +import path from "path"; -const compat = new FlatCompat({ - baseDirectory: import.meta.dirname, -}); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); + +// Import Next.js config directly (it's already in flat config format) +const nextConfig = require("eslint-config-next/core-web-vitals"); export default tseslint.config( { - ignores: [".next"], + ignores: [".next", "node_modules"], }, - ...compat.extends("next/core-web-vitals"), + ...nextConfig, { files: ["**/*.ts", "**/*.tsx"], extends: [ diff --git a/next.config.js b/next.config.js index 42fb61a..2cb9c44 100644 --- a/next.config.js +++ b/next.config.js @@ -63,9 +63,9 @@ const config = { } return config; }, - // Ignore TypeScript errors during build (they can be fixed separately) + // TypeScript errors will fail the build typescript: { - ignoreBuildErrors: true, + ignoreBuildErrors: false, }, }; diff --git a/package.json b/package.json index 5626c1f..9454b13 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,14 @@ "type": "module", "scripts": { "build": "next build --webpack", - "check": "next lint && tsc --noEmit", + "check": "npm run lint && tsc --noEmit", "dev": "next dev --webpack", "dev:server": "node server.js", "dev:next": "next dev --webpack", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", - "lint": "next lint", - "lint:fix": "next lint --fix", + "lint": "eslint . --ext .ts,.tsx,.js,.jsx", + "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix", "preview": "next build && next start", "start": "node server.js", "test": "vitest", diff --git a/server.js b/server.js index fbc32fd..9b3b48f 100644 --- a/server.js +++ b/server.js @@ -2,18 +2,14 @@ import { createServer } from 'http'; import { parse } from 'url'; import next from 'next'; import { WebSocketServer } from 'ws'; -import { spawn } from 'child_process'; 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-prisma.js'; import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js'; import dotenv from 'dotenv'; -// Load environment variables from .env file dotenv.config(); -// Fallback minimal global error handlers for Node runtime (avoid TS import) function registerGlobalErrorHandlers() { if (registerGlobalErrorHandlers._registered) return; registerGlobalErrorHandlers._registered = true; @@ -31,11 +27,9 @@ const hostname = '0.0.0.0'; const port = parseInt(process.env.PORT || '3000', 10); const app = next({ dev, hostname, port }); -// Register global handlers once at bootstrap registerGlobalErrorHandlers(); const handle = app.getRequestHandler(); -// WebSocket handler for script execution /** * @typedef {import('ws').WebSocket & {connectionTime?: number, clientIP?: string}} ExtendedWebSocket */ @@ -71,7 +65,10 @@ const handle = app.getRequestHandler(); * @property {ServerInfo} [server] * @property {boolean} [isUpdate] * @property {boolean} [isShell] + * @property {boolean} [isBackup] * @property {string} [containerId] + * @property {string} [storage] + * @property {string} [backupStorage] */ class ScriptExecutionHandler { @@ -79,8 +76,6 @@ class ScriptExecutionHandler { * @param {import('http').Server} server */ constructor(server) { - // Create WebSocketServer without attaching to server - // We'll handle upgrades manually to avoid interfering with Next.js HMR this.wss = new WebSocketServer({ noServer: true }); @@ -90,7 +85,6 @@ class ScriptExecutionHandler { } /** - * Handle WebSocket upgrade for our endpoint * @param {import('http').IncomingMessage} request * @param {import('stream').Duplex} socket * @param {Buffer} head @@ -102,48 +96,33 @@ class ScriptExecutionHandler { } /** - * Parse Container ID from terminal output - * @param {string} output - Terminal output to parse - * @returns {string|null} - Container ID if found, null otherwise + * @param {string} output + * @returns {string|null} */ 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) { @@ -151,7 +130,6 @@ class ScriptExecutionHandler { 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; } @@ -159,34 +137,24 @@ class ScriptExecutionHandler { } } - return null; } /** - * Parse Web UI URL from terminal output - * @param {string} output - Terminal output to parse - * @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise + * @param {string} output + * @returns {{ip: string, port: number}|null} */ parseWebUIUrl(output) { - // First, strip ANSI color codes to make pattern matching more reliable const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); - // Look for URL patterns with any valid IP address (private or public) const patterns = [ - // HTTP/HTTPS URLs with IP and port /https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi, - // URLs without explicit port (assume default ports) /https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi, - // URLs with trailing slash and port /https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi, - // URLs with just IP and port (no protocol) /(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi, - // URLs with just IP (no protocol, no port) /(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi, ]; - // Try patterns on both original and cleaned output const outputsToTry = [output, cleanOutput]; for (const testOutput of outputsToTry) { @@ -197,7 +165,6 @@ class ScriptExecutionHandler { const ip = match[1]; const port = match[2] || (match[0].startsWith('https') ? '443' : '80'); - // Validate IP address format if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) { return { ip: ip, @@ -213,12 +180,11 @@ class ScriptExecutionHandler { } /** - * 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 {Promise} - Installation record ID + * @param {string} scriptName + * @param {string} scriptPath + * @param {string} executionMode + * @param {number|null} serverId + * @returns {Promise} */ async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) { try { @@ -239,9 +205,8 @@ class ScriptExecutionHandler { } /** - * Update installation record - * @param {number} installationId - Installation record ID - * @param {Object} updateData - Data to update + * @param {number} installationId + * @param {Object} updateData */ async updateInstallationRecord(installationId, updateData) { try { @@ -253,8 +218,6 @@ class ScriptExecutionHandler { setupWebSocket() { this.wss.on('connection', (ws, request) => { - - // Set connection metadata /** @type {ExtendedWebSocket} */ (ws).connectionTime = Date.now(); /** @type {ExtendedWebSocket} */ (ws).clientIP = request.socket.remoteAddress || 'unknown'; @@ -345,8 +308,6 @@ class ScriptExecutionHandler { let installationId = null; try { - - // Check if execution is already running if (this.activeExecutions.has(executionId)) { this.sendMessage(ws, { type: 'error', @@ -356,10 +317,7 @@ 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 = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId); @@ -367,17 +325,14 @@ class ScriptExecutionHandler { console.error('Failed to create installation record'); } - // Handle SSH execution if (mode === 'ssh' && server) { await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId); return; } if (mode === 'ssh' && !server) { - // SSH mode requested but no server provided, falling back to local execution } - // Basic validation for local execution const scriptsDir = join(process.cwd(), 'scripts'); const resolvedPath = resolve(scriptPath); @@ -388,14 +343,12 @@ class ScriptExecutionHandler { timestamp: Date.now() }); - // Update installation record with failure if (installationId) { await this.updateInstallationRecord(installationId, { status: 'failed' }); } return; } - // Start script execution with pty for proper TTY support const childProcess = ptySpawn('bash', [resolvedPath], { cwd: scriptsDir, name: 'xterm-256color', @@ -403,16 +356,13 @@ class ScriptExecutionHandler { rows: 24, env: { ...process.env, - TERM: 'xterm-256color', // Enable proper terminal support - FORCE_ANSI: 'true', // Allow ANSI codes for proper display - COLUMNS: '80', // Set terminal width - LINES: '24' // Set terminal height + TERM: 'xterm-256color', + FORCE_ANSI: 'true', + COLUMNS: '80', + LINES: '24' } }); - // pty handles encoding automatically - - // Store the execution with installation ID this.activeExecutions.set(executionId, { process: childProcess, ws, @@ -420,34 +370,28 @@ class ScriptExecutionHandler { outputBuffer: '' }); - // Send start message this.sendMessage(ws, { type: 'start', data: `Starting execution of ${scriptPath}`, timestamp: Date.now() }); - // Handle pty data (both stdout and stderr combined) - childProcess.onData(async (data) => { + childProcess.onData(/** @param {string} data */ async (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) { await this.updateInstallationRecord(installationId, { container_id: containerId }); } - // Parse for Web UI URL const webUIUrl = this.parseWebUIUrl(output); if (webUIUrl && installationId) { const { ip, port } = webUIUrl; @@ -466,12 +410,10 @@ class ScriptExecutionHandler { }); }); - // 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', @@ -485,7 +427,6 @@ class ScriptExecutionHandler { timestamp: Date.now() }); - // Clean up this.activeExecutions.delete(executionId); }); @@ -496,7 +437,6 @@ class ScriptExecutionHandler { timestamp: Date.now() }); - // Update installation record with failure if (installationId) { await this.updateInstallationRecord(installationId, { status: 'failed' }); } @@ -504,7 +444,6 @@ class ScriptExecutionHandler { } /** - * Start SSH script execution * @param {ExtendedWebSocket} ws * @param {string} scriptPath * @param {string} executionId @@ -514,7 +453,6 @@ class ScriptExecutionHandler { async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null) { const sshService = getSSHExecutionService(); - // Send start message this.sendMessage(ws, { type: 'start', data: `Starting SSH execution of ${scriptPath} on ${server.name} (${server.ip})`, @@ -522,27 +460,23 @@ class ScriptExecutionHandler { }); try { - const execution = /** @type {ExecutionResult} */ (await sshService.executeScript( + const execution = await sshService.executeScript( server, scriptPath, /** @param {string} data */ async (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) { await this.updateInstallationRecord(installationId, { container_id: containerId }); } - // Parse for Web UI URL const webUIUrl = this.parseWebUIUrl(data); if (webUIUrl && installationId) { const { ip, port } = webUIUrl; @@ -554,7 +488,6 @@ class ScriptExecutionHandler { } } - // Handle data output this.sendMessage(ws, { type: 'output', data: data, @@ -562,17 +495,14 @@ 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', data: error, @@ -583,7 +513,6 @@ class ScriptExecutionHandler { const exec = this.activeExecutions.get(executionId); const isSuccess = code === 0; - // Update installation record with final status and output if (installationId && exec) { await this.updateInstallationRecord(installationId, { status: isSuccess ? 'success' : 'failed', @@ -591,21 +520,18 @@ class ScriptExecutionHandler { }); } - // Handle process exit this.sendMessage(ws, { type: 'end', data: `SSH script execution finished with code: ${code}`, timestamp: Date.now() }); - // Clean up this.activeExecutions.delete(executionId); } - )); + ); - // Store the execution with installation ID this.activeExecutions.set(executionId, { - process: execution.process, + process: /** @type {ExecutionResult} */ (execution).process, ws, installationId, outputBuffer: '' @@ -618,7 +544,6 @@ class ScriptExecutionHandler { timestamp: Date.now() }); - // Update installation record with failure if (installationId) { await this.updateInstallationRecord(installationId, { status: 'failed' }); } @@ -658,7 +583,7 @@ class ScriptExecutionHandler { * @param {any} message */ sendMessage(ws, message) { - if (ws.readyState === 1) { // WebSocket.OPEN + if (ws.readyState === 1) { ws.send(JSON.stringify(message)); } } @@ -676,7 +601,6 @@ class ScriptExecutionHandler { } /** - * Start backup execution * @param {ExtendedWebSocket} ws * @param {string} containerId * @param {string} executionId @@ -686,7 +610,6 @@ class ScriptExecutionHandler { */ async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) { try { - // Send start message this.sendMessage(ws, { type: 'start', data: `Starting backup for container ${containerId} to storage ${storage}...`, @@ -712,13 +635,12 @@ class ScriptExecutionHandler { } /** - * Start SSH backup execution * @param {ExtendedWebSocket} ws * @param {string} containerId * @param {string} executionId * @param {string} storage * @param {ServerInfo} server - * @param {Function} [onComplete] - Optional callback when backup completes + * @param {Function|null} [onComplete] */ startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) { const sshService = getSSHExecutionService(); @@ -726,8 +648,6 @@ class ScriptExecutionHandler { return new Promise((resolve, reject) => { try { const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`; - - // Wrap the onExit callback to resolve our promise let promiseResolved = false; sshService.executeCommand( @@ -751,8 +671,6 @@ class ScriptExecutionHandler { }, /** @param {number} code */ (code) => { - // Don't send 'end' message here if this is part of a backup+update flow - // The update flow will handle completion messages const success = code === 0; if (!success) { @@ -763,7 +681,6 @@ class ScriptExecutionHandler { }); } - // Send a completion message (but not 'end' type to avoid stopping terminal) this.sendMessage(ws, { type: 'output', data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`, @@ -772,14 +689,10 @@ class ScriptExecutionHandler { if (onComplete) onComplete(success); - // Resolve the promise when backup completes - // Use setImmediate to ensure resolution happens in the right execution context if (!promiseResolved) { promiseResolved = true; const result = { success, code }; - // Use setImmediate to ensure promise resolution happens in the next tick - // This ensures the await in startUpdateExecution can properly resume setImmediate(() => { try { resolve(result); @@ -793,12 +706,10 @@ class ScriptExecutionHandler { this.activeExecutions.delete(executionId); } ).then((execution) => { - // Store the execution this.activeExecutions.set(executionId, { process: /** @type {any} */ (execution).process, ws }); - // Note: Don't resolve here - wait for onExit callback }).catch((error) => { console.error('Error starting backup execution:', error); this.sendMessage(ws, { @@ -827,17 +738,15 @@ 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 - * @param {string} [backupStorage] - Optional storage to backup to before update + * @param {string|null} [backupStorage] */ async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) { try { - // If backup storage is provided, run backup first if (backupStorage && mode === 'ssh' && server) { this.sendMessage(ws, { type: 'start', @@ -845,10 +754,8 @@ class ScriptExecutionHandler { timestamp: Date.now() }); - // Create a separate execution ID for backup const backupExecutionId = `backup_${executionId}`; - // Run backup and wait for it to complete try { const backupResult = await this.startSSHBackupExecution( ws, @@ -858,16 +765,13 @@ class ScriptExecutionHandler { server ); - // Backup completed (successfully or not) if (!backupResult || !backupResult.success) { - // Backup failed, but we'll still allow update (per requirement 1b) this.sendMessage(ws, { type: 'output', data: '\nโš ๏ธ Backup failed, but proceeding with update as requested...\n', timestamp: Date.now() }); } else { - // Backup succeeded this.sendMessage(ws, { type: 'output', data: '\nโœ… Backup completed successfully. Starting update...\n', @@ -876,7 +780,6 @@ class ScriptExecutionHandler { } } catch (error) { console.error('Backup error before update:', error); - // Backup failed to start, but allow update to proceed this.sendMessage(ws, { type: 'output', data: `\nโš ๏ธ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`, @@ -884,11 +787,9 @@ class ScriptExecutionHandler { }); } - // Small delay before starting update await new Promise(resolve => setTimeout(resolve, 1000)); } - // Send start message for update (only if we're actually starting an update) this.sendMessage(ws, { type: 'start', data: `Starting update for container ${containerId}...`, @@ -911,7 +812,6 @@ class ScriptExecutionHandler { } /** - * Start local update execution * @param {ExtendedWebSocket} ws * @param {string} containerId * @param {string} executionId @@ -919,7 +819,6 @@ class ScriptExecutionHandler { 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, @@ -928,13 +827,11 @@ class ScriptExecutionHandler { env: process.env }); - // Store the execution this.activeExecutions.set(executionId, { process: childProcess, ws }); - // Handle pty data childProcess.onData((data) => { this.sendMessage(ws, { type: 'output', @@ -943,12 +840,10 @@ class ScriptExecutionHandler { }); }); - // 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', @@ -961,7 +856,6 @@ class ScriptExecutionHandler { } /** - * Start SSH update execution * @param {ExtendedWebSocket} ws * @param {string} containerId * @param {string} executionId @@ -1002,13 +896,11 @@ class ScriptExecutionHandler { } ); - // 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); @@ -1023,7 +915,6 @@ class ScriptExecutionHandler { } /** - * Start shell execution * @param {ExtendedWebSocket} ws * @param {string} containerId * @param {string} executionId @@ -1032,8 +923,6 @@ class ScriptExecutionHandler { */ async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) { try { - - // Send start message this.sendMessage(ws, { type: 'start', data: `Starting shell session for container ${containerId}...`, @@ -1056,7 +945,6 @@ class ScriptExecutionHandler { } /** - * Start local shell execution * @param {ExtendedWebSocket} ws * @param {string} containerId * @param {string} executionId @@ -1064,7 +952,6 @@ class ScriptExecutionHandler { async startLocalShellExecution(ws, containerId, executionId) { const { spawn } = await import('node-pty'); - // Create a shell process that will run pct enter const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], { name: 'xterm-color', cols: 80, @@ -1073,13 +960,11 @@ class ScriptExecutionHandler { env: process.env }); - // Store the execution this.activeExecutions.set(executionId, { process: childProcess, ws }); - // Handle pty data childProcess.onData((data) => { this.sendMessage(ws, { type: 'output', @@ -1088,9 +973,6 @@ class ScriptExecutionHandler { }); }); - // Note: No automatic command is sent - user can type commands interactively - - // Handle process exit childProcess.onExit((e) => { this.sendMessage(ws, { type: 'end', @@ -1103,7 +985,6 @@ class ScriptExecutionHandler { } /** - * Start SSH shell execution * @param {ExtendedWebSocket} ws * @param {string} containerId * @param {string} executionId @@ -1144,14 +1025,11 @@ class ScriptExecutionHandler { } ); - // Store the execution this.activeExecutions.set(executionId, { process: /** @type {any} */ (execution).process, ws }); - // Note: No automatic command is sent - user can type commands interactively - } catch (error) { this.sendMessage(ws, { type: 'error', @@ -1162,32 +1040,24 @@ class ScriptExecutionHandler { } } -// TerminalHandler removed - not used by current application + app.prepare().then(() => { const httpServer = createServer(async (req, res) => { try { - // Be sure to pass `true` as the second argument to `url.parse`. - // This tells it to parse the query portion of the URL. const parsedUrl = parse(req.url || '', true); const { pathname, query } = parsedUrl; - // Check if this is a WebSocket upgrade request const isWebSocketUpgrade = req.headers.upgrade === 'websocket'; - // Only intercept WebSocket upgrades for /ws/script-execution - // Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests if (isWebSocketUpgrade && pathname === '/ws/script-execution') { - // WebSocket upgrade will be handled by the WebSocket server - // Don't call handle() for this path - let WebSocketServer handle it + return; + } + + if (isWebSocketUpgrade) { return; } - // Let Next.js handle all other requests including: - // - HTTP requests to /ws/script-execution (non-WebSocket) - // - WebSocket upgrades to other paths (like /_next/webpack-hmr) - // - All static assets (_next routes) - // - All other routes await handle(req, res, parsedUrl); } catch (err) { console.error('Error occurred handling', req.url, err); @@ -1196,36 +1066,19 @@ app.prepare().then(() => { } }); - // Create WebSocket handlers const scriptHandler = new ScriptExecutionHandler(httpServer); - // Handle WebSocket upgrades manually to avoid interfering with Next.js HMR - // We need to preserve Next.js's upgrade handlers and call them for non-matching paths - // Save any existing upgrade listeners (Next.js might have set them up) - const existingUpgradeListeners = httpServer.listeners('upgrade').slice(); - httpServer.removeAllListeners('upgrade'); - - // Add our upgrade handler that routes based on path httpServer.on('upgrade', (request, socket, head) => { const parsedUrl = parse(request.url || '', true); const { pathname } = parsedUrl; if (pathname === '/ws/script-execution') { - // Handle our custom WebSocket endpoint scriptHandler.handleUpgrade(request, socket, head); - } else { - // For all other paths (including Next.js HMR), call existing listeners - // This allows Next.js to handle its own WebSocket upgrades - for (const listener of existingUpgradeListeners) { - try { - listener.call(httpServer, request, socket, head); - } catch (err) { - console.error('Error in upgrade listener:', err); - } - } + return; } + + socket.destroy(); }); - // Note: TerminalHandler removed as it's not being used by the current application httpServer .once('error', (err) => { @@ -1236,13 +1089,10 @@ app.prepare().then(() => { console.log(`> Ready on http://${hostname}:${port}`); console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`); - // Initialize default repositories - await initializeRepositories(); - - // Initialize auto-sync service - initializeAutoSync(); - - // Setup graceful shutdown handlers + await initializeRepositories(); + + initializeAutoSync(); + setupGracefulShutdown(); }); }); diff --git a/src/app/_components/BackupWarningModal.tsx b/src/app/_components/BackupWarningModal.tsx index d93f5c9..ca5d851 100644 --- a/src/app/_components/BackupWarningModal.tsx +++ b/src/app/_components/BackupWarningModal.tsx @@ -35,7 +35,7 @@ export function BackupWarningModal({

The backup failed, but you can still proceed with the update if you wish.

- Warning: Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update. + Warning: Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update.

{/* Action Buttons */} diff --git a/src/app/_components/BackupsTab.tsx b/src/app/_components/BackupsTab.tsx index 2e24b7b..d114eee 100644 --- a/src/app/_components/BackupsTab.tsx +++ b/src/app/_components/BackupsTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, startTransition } from 'react'; import { api } from '~/trpc/react'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; @@ -23,16 +23,12 @@ interface Backup { storage_name: string; storage_type: string; discovered_at: Date; - server_id: number; + server_id?: number; server_name: string | null; server_color: string | null; } -interface ContainerBackups { - container_id: string; - hostname: string; - backups: Backup[]; -} + export function BackupsTab() { const [expandedContainers, setExpandedContainers] = useState>(new Set()); @@ -61,21 +57,23 @@ export function BackupsTab() { // Update restore progress when log data changes useEffect(() => { if (restoreLogsData?.success && restoreLogsData.logs) { - setRestoreProgress(restoreLogsData.logs); - - // Stop polling when restore is complete - if (restoreLogsData.isComplete) { - setShouldPollRestore(false); - // Check if restore was successful or failed - const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || ''; - if (lastLog.includes('Restore completed successfully')) { - setRestoreSuccess(true); - setRestoreError(null); - } else if (lastLog.includes('Error:') || lastLog.includes('failed')) { - setRestoreError(lastLog); - setRestoreSuccess(false); + startTransition(() => { + setRestoreProgress(restoreLogsData.logs); + + // Stop polling when restore is complete + if (restoreLogsData.isComplete) { + setShouldPollRestore(false); + // Check if restore was successful or failed + const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? ''; + if (lastLog.includes('Restore completed successfully')) { + setRestoreSuccess(true); + setRestoreError(null); + } else if (lastLog.includes('Error:') || lastLog.includes('failed')) { + setRestoreError(lastLog); + setRestoreSuccess(false); + } } - } + }); } }, [restoreLogsData]); @@ -93,7 +91,7 @@ export function BackupsTab() { if (result.success) { // Update progress with all messages from backend (fallback if polling didn't work) - const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']); + const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) ?? ['Restore completed successfully']); setRestoreProgress(progressMessages); setRestoreSuccess(true); setRestoreError(null); @@ -101,8 +99,8 @@ export function BackupsTab() { setSelectedBackup(null); // Keep success message visible - user can dismiss manually } else { - setRestoreError(result.error || 'Restore failed'); - setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress); + setRestoreError(result.error ?? 'Restore failed'); + setRestoreProgress(result.progress?.map(p => p.message) ?? restoreProgress); setRestoreSuccess(false); setRestoreConfirmOpen(false); setSelectedBackup(null); @@ -112,7 +110,7 @@ export function BackupsTab() { onError: (error) => { // Stop polling on error setShouldPollRestore(false); - setRestoreError(error.message || 'Restore failed'); + setRestoreError(error.message ?? 'Restore failed'); setRestoreConfirmOpen(false); setSelectedBackup(null); setRestoreProgress([]); @@ -124,6 +122,10 @@ export function BackupsTab() { ? restoreProgress[restoreProgress.length - 1] : 'Restoring backup...'; + const handleDiscoverBackups = useCallback(() => { + discoverMutation.mutate(); + }, [discoverMutation]); + // Auto-discover backups when tab is first opened useEffect(() => { if (!hasAutoDiscovered && !isLoading && backupsData) { @@ -131,13 +133,11 @@ export function BackupsTab() { if (!backupsData.backups || backupsData.backups.length === 0) { handleDiscoverBackups(); } - setHasAutoDiscovered(true); + startTransition(() => { + setHasAutoDiscovered(true); + }); } - }, [hasAutoDiscovered, isLoading, backupsData]); - - const handleDiscoverBackups = () => { - discoverMutation.mutate(); - }; + }, [hasAutoDiscovered, isLoading, backupsData, handleDiscoverBackups]); const handleRestoreClick = (backup: Backup, containerId: string) => { setSelectedBackup({ backup, containerId }); @@ -150,6 +150,12 @@ export function BackupsTab() { const handleRestoreConfirm = () => { if (!selectedBackup) return; + // Ensure server_id is available + if (!selectedBackup.backup.server_id) { + setRestoreError('Server ID is required for restore operation'); + return; + } + setRestoreConfirmOpen(false); setRestoreError(null); setRestoreSuccess(false); @@ -247,7 +253,7 @@ export function BackupsTab() {

No backups found

- Click "Discover Backups" to scan for backups on your servers. + Click "Discover Backups" to scan for backups on your servers.

)} - {((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) || - (selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && ( + {((selectedVersion === 'default' && (scriptContent.mainScript ?? scriptContent.installScript)) ?? false) || + (selectedVersion === 'alpine' && (scriptContent.alpineMainScript ?? scriptContent.alpineInstallScript)) && (