diff --git a/server.js b/server.js index 6977a3e..9cf6d8a 100644 --- a/server.js +++ b/server.js @@ -705,70 +705,112 @@ class ScriptExecutionHandler { * @param {string} executionId * @param {string} storage * @param {ServerInfo} server + * @param {Function} [onComplete] - Optional callback when backup completes */ - async startSSHBackupExecution(ws, containerId, executionId, storage, server) { + startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) { const sshService = getSSHExecutionService(); - try { - const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`; - - const execution = await sshService.executeCommand( - server, - backupCommand, - /** @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) => { - if (code === 0) { + 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( + server, + backupCommand, + /** @param {string} data */ + (data) => { this.sendMessage(ws, { - type: 'end', - data: `Backup completed successfully with exit code: ${code}`, + type: 'output', + data: data, timestamp: Date.now() }); - } else { + }, + /** @param {string} error */ + (error) => { this.sendMessage(ws, { type: 'error', - data: `Backup failed with exit code: ${code}`, + data: error, timestamp: Date.now() }); + }, + /** @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) { + this.sendMessage(ws, { + type: 'error', + data: `Backup failed with exit code: ${code}`, + timestamp: Date.now() + }); + } + + // Send a completion message (but not 'end' type to avoid stopping terminal) this.sendMessage(ws, { - type: 'end', - data: `Backup execution ended with exit code: ${code}`, + type: 'output', + data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`, timestamp: Date.now() }); + + 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); + } catch (resolveError) { + console.error('Error resolving backup promise:', resolveError); + reject(resolveError); + } + }); + } + + this.activeExecutions.delete(executionId); } - - 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, { + type: 'error', + data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`, + timestamp: Date.now() + }); + if (onComplete) onComplete(false); + if (!promiseResolved) { + promiseResolved = true; + reject(error); + } + }); - // Store the execution - this.activeExecutions.set(executionId, { - process: /** @type {any} */ (execution).process, - ws - }); - - } catch (error) { - this.sendMessage(ws, { - type: 'error', - data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`, - timestamp: Date.now() - }); - } + } catch (error) { + console.error('Error in startSSHBackupExecution:', error); + this.sendMessage(ws, { + type: 'error', + data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`, + timestamp: Date.now() + }); + if (onComplete) onComplete(false); + reject(error); + } + }); } /** @@ -792,72 +834,48 @@ class ScriptExecutionHandler { // Create a separate execution ID for backup const backupExecutionId = `backup_${executionId}`; - let backupCompleted = false; - let backupSucceeded = false; // Run backup and wait for it to complete - await new Promise((resolve) => { - // Create a wrapper websocket that forwards messages and tracks completion - const backupWs = { - send: (data) => { - try { - const message = typeof data === 'string' ? JSON.parse(data) : data; - - // Forward all messages to the main websocket - ws.send(JSON.stringify(message)); - - // Check for completion - if (message.type === 'end') { - backupCompleted = true; - backupSucceeded = !message.data.includes('failed') && !message.data.includes('exit code:'); - if (!backupSucceeded) { - // Backup failed, but we'll still allow update (per requirement 1b) - ws.send(JSON.stringify({ - type: 'output', - data: '\n⚠️ Backup failed, but proceeding with update as requested...\n', - timestamp: Date.now() - })); - } - resolve(); - } else if (message.type === 'error' && message.data.includes('Backup failed')) { - backupCompleted = true; - backupSucceeded = false; - ws.send(JSON.stringify({ - type: 'output', - data: '\n⚠️ Backup failed, but proceeding with update as requested...\n', - timestamp: Date.now() - })); - resolve(); - } - } catch (e) { - // If parsing fails, just forward the raw data - ws.send(data); - } - } - }; - - // Start backup execution - this.startSSHBackupExecution(backupWs, containerId, backupExecutionId, backupStorage, server) - .catch((error) => { - // Backup failed to start, but allow update to proceed - if (!backupCompleted) { - backupCompleted = true; - backupSucceeded = false; - ws.send(JSON.stringify({ - type: 'output', - data: `\n⚠️ Backup error: ${error.message}. Proceeding with update...\n`, - timestamp: Date.now() - })); - resolve(); - } + try { + const backupResult = await this.startSSHBackupExecution( + ws, + containerId, + backupExecutionId, + backupStorage, + 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', + timestamp: Date.now() + }); + } + } 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`, + timestamp: Date.now() + }); + } + // Small delay before starting update await new Promise(resolve => setTimeout(resolve, 1000)); } - // Send start message for update + // Send start message for update (only if we're actually starting an update) this.sendMessage(ws, { type: 'start', data: `Starting update for container ${containerId}...`, diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index a634598..ad155b4 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -61,6 +61,7 @@ export function InstalledScriptsTab() { const [backupStorages, setBackupStorages] = useState([]); const [isLoadingStorages, setIsLoadingStorages] = useState(false); const [showBackupWarning, setShowBackupWarning] = useState(false); + const [isPreUpdateBackup, setIsPreUpdateBackup] = useState(false); // Track if storage selection is for pre-update backup const [editingScriptId, setEditingScriptId] = useState(null); const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); const [showAddForm, setShowAddForm] = useState(false); @@ -660,6 +661,7 @@ export function InstalledScriptsTab() { if (wantsBackup) { // User wants backup - fetch storages and show selection if (pendingUpdateScript.server_id) { + setIsPreUpdateBackup(true); // Mark that this is for pre-update backup void fetchStorages(pendingUpdateScript.server_id, false); setShowStorageSelection(true); } else { @@ -682,12 +684,13 @@ export function InstalledScriptsTab() { setShowStorageSelection(false); // Check if this is for a standalone backup or pre-update backup - if (pendingUpdateScript && !showBackupPrompt) { + if (isPreUpdateBackup) { + // Pre-update backup - proceed with update + setIsPreUpdateBackup(false); // Reset flag + proceedWithUpdate(storage.name); + } else if (pendingUpdateScript) { // Standalone backup - execute backup directly executeStandaloneBackup(pendingUpdateScript, storage.name); - } else { - // Pre-update backup - proceed with update - proceedWithUpdate(storage.name); } }; @@ -718,6 +721,7 @@ export function InstalledScriptsTab() { }); // Reset state + setIsPreUpdateBackup(false); // Reset flag setPendingUpdateScript(null); setBackupStorages([]); }; @@ -745,7 +749,8 @@ export function InstalledScriptsTab() { id: pendingUpdateScript.id, containerId: pendingUpdateScript.container_id!, server: server, - backupStorage: backupStorage ?? undefined + backupStorage: backupStorage ?? undefined, + isBackupOnly: false // Explicitly set to false for update operations }); // Reset state @@ -779,6 +784,7 @@ export function InstalledScriptsTab() { } // Store the script and fetch storages + setIsPreUpdateBackup(false); // This is a standalone backup, not pre-update setPendingUpdateScript(script); void fetchStorages(script.server_id, false); setShowStorageSelection(true); diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index e0273d9..927d638 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -4,7 +4,6 @@ import { getDatabase } from "~/server/database-prisma"; import { createHash } from "crypto"; import type { Server } from "~/types/server"; import { getStorageService } from "~/server/services/storageService"; -import { SSHService } from "~/server/ssh-service"; // Helper function to parse raw LXC config into structured data function parseRawConfig(rawConfig: string): any { @@ -2063,6 +2062,7 @@ EOFCONFIG`; } const storageService = getStorageService(); + const { default: SSHService } = await import('~/server/ssh-service'); const sshService = new SSHService(); // Test SSH connection first @@ -2118,6 +2118,7 @@ EOFCONFIG`; }; } + const { default: SSHService } = await import('~/server/ssh-service'); const sshService = new SSHService(); // Test SSH connection first diff --git a/src/server/services/storageService.ts b/src/server/services/storageService.ts index c1c3bfb..4365759 100644 --- a/src/server/services/storageService.ts +++ b/src/server/services/storageService.ts @@ -29,7 +29,12 @@ class StorageService { let currentStorage: Partial | null = null; for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); + const rawLine = lines[i]; + if (!rawLine) continue; + + // Check if line is indented (has leading whitespace/tabs) BEFORE trimming + const isIndented = /^[\s\t]/.test(rawLine); + const line = rawLine.trim(); // Skip empty lines and comments if (!line || line.startsWith('#')) { @@ -37,29 +42,34 @@ class StorageService { } // Check if this is a storage definition line (format: "type: name") - const storageMatch = line.match(/^(\w+):\s*(.+)$/); - if (storageMatch) { - // Save previous storage if exists - if (currentStorage && currentStorage.name) { - storages.push(this.finalizeStorage(currentStorage)); + // Storage definitions are NOT indented + if (!isIndented) { + const storageMatch = line.match(/^(\w+):\s*(.+)$/); + if (storageMatch && storageMatch[1] && storageMatch[2]) { + // Save previous storage if exists + if (currentStorage && currentStorage.name) { + storages.push(this.finalizeStorage(currentStorage)); + } + + // Start new storage + currentStorage = { + type: storageMatch[1], + name: storageMatch[2], + content: [], + supportsBackup: false, + }; + continue; } - - // Start new storage - currentStorage = { - type: storageMatch[1], - name: storageMatch[2], - content: [], - supportsBackup: false, - }; - continue; } - // Parse storage properties (indented lines) - if (currentStorage && /^\s/.test(line)) { - const propertyMatch = line.match(/^\s+(\w+)\s+(.+)$/); - if (propertyMatch) { - const key = propertyMatch[1]; - const value = propertyMatch[2]; + // Parse storage properties (indented lines - can be tabs or spaces) + if (currentStorage && isIndented) { + // Split on first whitespace (space or tab) to separate key and value + const match = line.match(/^(\S+)\s+(.+)$/); + + if (match && match[1] && match[2]) { + const key = match[1]; + const value = match[2].trim(); switch (key) { case 'content': @@ -73,7 +83,9 @@ class StorageService { break; default: // Store other properties - (currentStorage as any)[key] = value; + if (key) { + (currentStorage as any)[key] = value; + } } } }