diff --git a/server.js b/server.js index 0ca86bf..6977a3e 100644 --- a/server.js +++ b/server.js @@ -276,13 +276,15 @@ class ScriptExecutionHandler { * @param {WebSocketMessage} message */ async handleMessage(ws, message) { - const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message; + const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message; switch (action) { case 'start': if (scriptPath && executionId) { - if (isUpdate && containerId) { - await this.startUpdateExecution(ws, containerId, executionId, mode, server); + if (isBackup && containerId && storage) { + await this.startBackupExecution(ws, containerId, executionId, storage, mode, server); + } else if (isUpdate && containerId) { + await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage); } else if (isShell && containerId) { await this.startShellExecution(ws, containerId, executionId, mode, server); } else { @@ -660,6 +662,115 @@ class ScriptExecutionHandler { } } + /** + * Start backup execution + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + * @param {string} storage + * @param {string} mode + * @param {ServerInfo|null} server + */ + 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}...`, + timestamp: Date.now() + }); + + if (mode === 'ssh' && server) { + await this.startSSHBackupExecution(ws, containerId, executionId, storage, server); + } else { + this.sendMessage(ws, { + type: 'error', + data: 'Backup is only supported via SSH', + timestamp: Date.now() + }); + } + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `Failed to start backup: ${error instanceof Error ? error.message : String(error)}`, + timestamp: Date.now() + }); + } + } + + /** + * Start SSH backup execution + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + * @param {string} storage + * @param {ServerInfo} server + */ + async startSSHBackupExecution(ws, containerId, executionId, storage, server) { + 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) { + this.sendMessage(ws, { + type: 'end', + data: `Backup completed successfully with exit code: ${code}`, + timestamp: Date.now() + }); + } else { + this.sendMessage(ws, { + type: 'error', + data: `Backup failed with exit code: ${code}`, + timestamp: Date.now() + }); + this.sendMessage(ws, { + type: 'end', + data: `Backup execution ended with exit code: ${code}`, + timestamp: Date.now() + }); + } + + this.activeExecutions.delete(executionId); + } + ); + + // 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() + }); + } + } + /** * Start update execution (pct enter + update command) * @param {ExtendedWebSocket} ws @@ -667,11 +778,86 @@ class ScriptExecutionHandler { * @param {string} executionId * @param {string} mode * @param {ServerInfo|null} server + * @param {string} [backupStorage] - Optional storage to backup to before update */ - async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null) { + 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', + data: `Starting backup before update for container ${containerId}...`, + timestamp: Date.now() + }); + + // 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(); + } + }); + }); + + // Small delay before starting update + await new Promise(resolve => setTimeout(resolve, 1000)); + } - // Send start message + // Send start message for update this.sendMessage(ws, { type: 'start', data: `Starting update for container ${containerId}...`, diff --git a/src/app/_components/BackupWarningModal.tsx b/src/app/_components/BackupWarningModal.tsx new file mode 100644 index 0000000..c7b5fb0 --- /dev/null +++ b/src/app/_components/BackupWarningModal.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { Button } from './ui/button'; +import { AlertTriangle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; + +interface BackupWarningModalProps { + isOpen: boolean; + onClose: () => void; + onProceed: () => void; +} + +export function BackupWarningModal({ + isOpen, + onClose, + onProceed +}: BackupWarningModalProps) { + useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose }); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ +

Backup Failed

+
+
+ + {/* Content */} +
+

+ 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. +

+ + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} + diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index 02155a4..a634598 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -10,6 +10,9 @@ import { ConfirmationModal } from './ConfirmationModal'; import { ErrorModal } from './ErrorModal'; import { LoadingModal } from './LoadingModal'; import { LXCSettingsModal } from './LXCSettingsModal'; +import { StorageSelectionModal } from './StorageSelectionModal'; +import { BackupWarningModal } from './BackupWarningModal'; +import type { Storage } from '~/server/services/storageService'; import { getContrastColor } from '../../lib/colorUtils'; import { DropdownMenu, @@ -50,8 +53,14 @@ export function InstalledScriptsTab() { const [serverFilter, setServerFilter] = useState('all'); const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null); + const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; backupStorage?: string; isBackupOnly?: boolean } | null>(null); const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null); + const [showBackupPrompt, setShowBackupPrompt] = useState(false); + const [showStorageSelection, setShowStorageSelection] = useState(false); + const [pendingUpdateScript, setPendingUpdateScript] = useState(null); + const [backupStorages, setBackupStorages] = useState([]); + const [isLoadingStorages, setIsLoadingStorages] = useState(false); + const [showBackupWarning, setShowBackupWarning] = useState(false); 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); @@ -244,22 +253,54 @@ export function InstalledScriptsTab() { void refetchScripts(); setAutoDetectStatus({ type: 'success', - message: data.message ?? 'Web UI IP detected successfully!' + message: data.success ? `Detected IP: ${data.ip}` : (data.error ?? 'Failed to detect Web UI') }); - // Clear status after 5 seconds setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000); }, onError: (error) => { - console.error('❌ Auto-detect Web UI error:', error); + console.error('❌ Auto-detect WebUI error:', error); setAutoDetectStatus({ type: 'error', - message: error.message ?? 'Auto-detect failed. Please try again.' + message: error.message ?? 'Failed to detect Web UI' }); - // Clear status after 5 seconds - setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000); + setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000); } }); + // Get backup storages query + const getBackupStoragesQuery = api.installedScripts.getBackupStorages.useQuery( + { serverId: pendingUpdateScript?.server_id ?? 0, forceRefresh: false }, + { enabled: false } // Only fetch when explicitly called + ); + + const fetchStorages = async (serverId: number, forceRefresh = false) => { + setIsLoadingStorages(true); + try { + const result = await getBackupStoragesQuery.refetch({ + queryKey: ['installedScripts.getBackupStorages', { serverId, forceRefresh }] + }); + if (result.data?.success) { + setBackupStorages(result.data.storages); + } else { + setErrorModal({ + isOpen: true, + title: 'Failed to Fetch Storages', + message: result.data?.error ?? 'Unknown error occurred', + type: 'error' + }); + } + } catch (error) { + setErrorModal({ + isOpen: true, + title: 'Failed to Fetch Storages', + message: error instanceof Error ? error.message : 'Unknown error occurred', + type: 'error' + }); + } finally { + setIsLoadingStorages(false); + } + }; + // Container control mutations // Note: getStatusMutation removed - using direct API calls instead @@ -600,38 +641,149 @@ export function InstalledScriptsTab() { message: `Are you sure you want to update "${script.script_name}"?\n\n⚠️ WARNING: This will update the script and may affect the container. Consider backing up your data beforehand.`, variant: 'danger', confirmText: script.container_id, - confirmButtonText: 'Update Script', + confirmButtonText: 'Continue', onConfirm: () => { - // Get server info if it's SSH mode - let server = null; - if (script.server_id && script.server_user) { - server = { - id: script.server_id, - name: script.server_name, - ip: script.server_ip, - user: script.server_user, - password: script.server_password, - auth_type: script.server_auth_type ?? 'password', - ssh_key: script.server_ssh_key, - ssh_key_passphrase: script.server_ssh_key_passphrase, - ssh_port: script.server_ssh_port ?? 22 - }; - } - - setUpdatingScript({ - id: script.id, - containerId: script.container_id!, - server: server - }); setConfirmationModal(null); + // Store the script for backup flow + setPendingUpdateScript(script); + // Show backup prompt + setShowBackupPrompt(true); } }); }; + const handleBackupPromptResponse = (wantsBackup: boolean) => { + setShowBackupPrompt(false); + + if (!pendingUpdateScript) return; + + if (wantsBackup) { + // User wants backup - fetch storages and show selection + if (pendingUpdateScript.server_id) { + void fetchStorages(pendingUpdateScript.server_id, false); + setShowStorageSelection(true); + } else { + setErrorModal({ + isOpen: true, + title: 'Backup Not Available', + message: 'Backup is only available for SSH scripts with a configured server.', + type: 'error' + }); + // Proceed without backup + proceedWithUpdate(null); + } + } else { + // User doesn't want backup - proceed directly to update + proceedWithUpdate(null); + } + }; + + const handleStorageSelected = (storage: Storage) => { + setShowStorageSelection(false); + + // Check if this is for a standalone backup or pre-update backup + if (pendingUpdateScript && !showBackupPrompt) { + // Standalone backup - execute backup directly + executeStandaloneBackup(pendingUpdateScript, storage.name); + } else { + // Pre-update backup - proceed with update + proceedWithUpdate(storage.name); + } + }; + + const executeStandaloneBackup = (script: InstalledScript, storageName: string) => { + // Get server info + let server = null; + if (script.server_id && script.server_user) { + server = { + id: script.server_id, + name: script.server_name, + ip: script.server_ip, + user: script.server_user, + password: script.server_password, + auth_type: script.server_auth_type ?? 'password', + ssh_key: script.server_ssh_key, + ssh_key_passphrase: script.server_ssh_key_passphrase, + ssh_port: script.server_ssh_port ?? 22 + }; + } + + // Start backup terminal + setUpdatingScript({ + id: script.id, + containerId: script.container_id!, + server: server, + backupStorage: storageName, + isBackupOnly: true + }); + + // Reset state + setPendingUpdateScript(null); + setBackupStorages([]); + }; + + const proceedWithUpdate = (backupStorage: string | null) => { + if (!pendingUpdateScript) return; + + // Get server info if it's SSH mode + let server = null; + if (pendingUpdateScript.server_id && pendingUpdateScript.server_user) { + server = { + id: pendingUpdateScript.server_id, + name: pendingUpdateScript.server_name, + ip: pendingUpdateScript.server_ip, + user: pendingUpdateScript.server_user, + password: pendingUpdateScript.server_password, + auth_type: pendingUpdateScript.server_auth_type ?? 'password', + ssh_key: pendingUpdateScript.server_ssh_key, + ssh_key_passphrase: pendingUpdateScript.server_ssh_key_passphrase, + ssh_port: pendingUpdateScript.server_ssh_port ?? 22 + }; + } + + setUpdatingScript({ + id: pendingUpdateScript.id, + containerId: pendingUpdateScript.container_id!, + server: server, + backupStorage: backupStorage ?? undefined + }); + + // Reset state + setPendingUpdateScript(null); + setBackupStorages([]); + }; + const handleCloseUpdateTerminal = () => { setUpdatingScript(null); }; + const handleBackupScript = (script: InstalledScript) => { + if (!script.container_id) { + setErrorModal({ + isOpen: true, + title: 'Backup Failed', + message: 'No Container ID available for this script', + details: 'This script does not have a valid container ID and cannot be backed up.' + }); + return; + } + + if (!script.server_id) { + setErrorModal({ + isOpen: true, + title: 'Backup Not Available', + message: 'Backup is only available for SSH scripts with a configured server.', + type: 'error' + }); + return; + } + + // Store the script and fetch storages + setPendingUpdateScript(script); + void fetchStorages(script.server_id, false); + setShowStorageSelection(true); + }; + const handleOpenShell = (script: InstalledScript) => { if (!script.container_id) { setErrorModal({ @@ -887,12 +1039,15 @@ export function InstalledScriptsTab() { {updatingScript && (
)} @@ -1252,6 +1407,7 @@ export function InstalledScriptsTab() { onSave={handleSaveEdit} onCancel={handleCancelEdit} onUpdate={() => handleUpdateScript(script)} + onBackup={() => handleBackupScript(script)} onShell={() => handleOpenShell(script)} onDelete={() => handleDeleteScript(Number(script.id))} isUpdating={updateScriptMutation.isPending} @@ -1530,6 +1686,15 @@ export function InstalledScriptsTab() { Update )} + {script.container_id && script.execution_mode === 'ssh' && ( + handleBackupScript(script)} + disabled={containerStatuses.get(script.id) === 'stopped'} + className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" + > + Backup + + )} {script.container_id && script.execution_mode === 'ssh' && ( handleOpenShell(script)} @@ -1656,6 +1821,79 @@ export function InstalledScriptsTab() { /> )} + {/* Backup Prompt Modal */} + {showBackupPrompt && ( +
+
+
+
+ + + +

Backup Before Update?

+
+
+
+

+ Would you like to create a backup before updating the container? +

+
+ + +
+
+
+
+ )} + + {/* Storage Selection Modal */} + { + setShowStorageSelection(false); + setPendingUpdateScript(null); + setBackupStorages([]); + }} + onSelect={handleStorageSelected} + storages={backupStorages} + isLoading={isLoadingStorages} + onRefresh={() => { + if (pendingUpdateScript?.server_id) { + void fetchStorages(pendingUpdateScript.server_id, true); + } + }} + /> + + {/* Backup Warning Modal */} + setShowBackupWarning(false)} + onProceed={() => { + setShowBackupWarning(false); + // Proceed with update even though backup failed + if (pendingUpdateScript) { + proceedWithUpdate(null); + } + }} + /> + {/* LXC Settings Modal */} void; onCancel: () => void; onUpdate: () => void; + onBackup?: () => void; onShell: () => void; onDelete: () => void; isUpdating: boolean; @@ -68,6 +69,7 @@ export function ScriptInstallationCard({ onSave, onCancel, onUpdate, + onBackup, onShell, onDelete, isUpdating, @@ -307,6 +309,15 @@ export function ScriptInstallationCard({ Update
)} + {script.container_id && script.execution_mode === 'ssh' && onBackup && ( + + Backup + + )} {script.container_id && script.execution_mode === 'ssh' && ( (null); + const [showStoragesModal, setShowStoragesModal] = useState(false); + const [selectedServerForStorages, setSelectedServerForStorages] = useState<{ id: number; name: string } | null>(null); const handleEdit = (server: Server) => { setEditingId(server.id); @@ -251,6 +254,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) { )} +
{/* View Public Key button - only show for generated keys */} {server.key_generated === true && ( @@ -324,6 +340,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) { serverIp={publicKeyData.serverIp} /> )} + + {/* Server Storages Modal */} + {selectedServerForStorages && ( + { + setShowStoragesModal(false); + setSelectedServerForStorages(null); + }} + serverId={selectedServerForStorages.id} + serverName={selectedServerForStorages.name} + /> + )}
); } diff --git a/src/app/_components/ServerStoragesModal.tsx b/src/app/_components/ServerStoragesModal.tsx new file mode 100644 index 0000000..ffc9316 --- /dev/null +++ b/src/app/_components/ServerStoragesModal.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from './ui/button'; +import { Database, RefreshCw, CheckCircle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; +import { api } from '~/trpc/react'; +import type { Storage } from '~/server/services/storageService'; + +interface ServerStoragesModalProps { + isOpen: boolean; + onClose: () => void; + serverId: number; + serverName: string; +} + +export function ServerStoragesModal({ + isOpen, + onClose, + serverId, + serverName +}: ServerStoragesModalProps) { + const [forceRefresh, setForceRefresh] = useState(false); + + const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery( + { serverId, forceRefresh }, + { enabled: isOpen } + ); + + useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose }); + + const handleRefresh = () => { + setForceRefresh(true); + void refetch(); + setTimeout(() => setForceRefresh(false), 1000); + }; + + if (!isOpen) return null; + + const storages = data?.success ? data.storages : []; + const backupStorages = storages.filter(s => s.supportsBackup); + + return ( +
+
+ {/* Header */} +
+
+ +

+ Storages for {serverName} +

+
+
+ + +
+
+ + {/* Content */} +
+ {isLoading ? ( +
+
+

Loading storages...

+
+ ) : !data?.success ? ( +
+ +

Failed to load storages

+

+ {data?.error ?? 'Unknown error occurred'} +

+ +
+ ) : storages.length === 0 ? ( +
+ +

No storages found

+

+ Make sure your server has storages configured. +

+
+ ) : ( + <> + {data.cached && ( +
+ Showing cached data. Click Refresh to fetch latest from server. +
+ )} + +
+ {storages.map((storage) => { + const isBackupCapable = storage.supportsBackup; + + return ( +
+
+
+
+

{storage.name}

+ {isBackupCapable && ( + + + Backup + + )} + + {storage.type} + +
+
+
+ Content: {storage.content.join(', ')} +
+ {storage.nodes && storage.nodes.length > 0 && ( +
+ Nodes: {storage.nodes.join(', ')} +
+ )} + {Object.entries(storage) + .filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key)) + .map(([key, value]) => ( +
+ {key.replace(/_/g, ' ')}: {String(value)} +
+ ))} +
+
+
+
+ ); + })} +
+ + {backupStorages.length > 0 && ( +
+

+ {backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups +

+
+ )} + + )} +
+
+
+ ); +} + diff --git a/src/app/_components/StorageSelectionModal.tsx b/src/app/_components/StorageSelectionModal.tsx new file mode 100644 index 0000000..ee998ce --- /dev/null +++ b/src/app/_components/StorageSelectionModal.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from './ui/button'; +import { Database, RefreshCw, CheckCircle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; +import type { Storage } from '~/server/services/storageService'; + +interface StorageSelectionModalProps { + isOpen: boolean; + onClose: () => void; + onSelect: (storage: Storage) => void; + storages: Storage[]; + isLoading: boolean; + onRefresh: () => void; +} + +export function StorageSelectionModal({ + isOpen, + onClose, + onSelect, + storages, + isLoading, + onRefresh +}: StorageSelectionModalProps) { + const [selectedStorage, setSelectedStorage] = useState(null); + + useRegisterModal(isOpen, { id: 'storage-selection-modal', allowEscape: true, onClose }); + + if (!isOpen) return null; + + const handleSelect = () => { + if (selectedStorage) { + onSelect(selectedStorage); + setSelectedStorage(null); + } + }; + + const handleClose = () => { + setSelectedStorage(null); + onClose(); + }; + + // Filter to show only backup-capable storages + const backupStorages = storages.filter(s => s.supportsBackup); + + return ( +
+
+ {/* Header */} +
+
+ +

Select Backup Storage

+
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+
+

Loading storages...

+
+ ) : backupStorages.length === 0 ? ( +
+ +

No backup-capable storages found

+

+ Make sure your server has storages configured with backup content type. +

+ +
+ ) : ( + <> +

+ Select a storage to use for the backup. Only storages that support backups are shown. +

+ + {/* Storage List */} +
+ {backupStorages.map((storage) => ( +
setSelectedStorage(storage)} + className={`p-4 border rounded-lg cursor-pointer transition-all ${ + selectedStorage?.name === storage.name + ? 'border-primary bg-primary/10' + : 'border-border hover:border-primary/50 hover:bg-accent/50' + }`} + > +
+
+
+

{storage.name}

+ + Backup + + + {storage.type} + +
+
+ Content: {storage.content.join(', ')} + {storage.nodes && storage.nodes.length > 0 && ( + • Nodes: {storage.nodes.join(', ')} + )} +
+
+ {selectedStorage?.name === storage.name && ( + + )} +
+
+ ))} +
+ + {/* Refresh Button */} +
+ +
+ + )} + + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} + diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx index b18e108..84c0ec1 100644 --- a/src/app/_components/Terminal.tsx +++ b/src/app/_components/Terminal.tsx @@ -12,7 +12,10 @@ interface TerminalProps { server?: any; isUpdate?: boolean; isShell?: boolean; + isBackup?: boolean; containerId?: string; + storage?: string; + backupStorage?: string; } interface TerminalMessage { @@ -21,7 +24,7 @@ interface TerminalMessage { timestamp: number; } -export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) { +export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) { const [isConnected, setIsConnected] = useState(false); const [isRunning, setIsRunning] = useState(false); const [isClient, setIsClient] = useState(false); @@ -334,7 +337,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate server, isUpdate, isShell, - containerId + isBackup, + containerId, + storage, + backupStorage }; ws.send(JSON.stringify(message)); } diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index 5bce857..e0273d9 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -3,6 +3,8 @@ import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; 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 { @@ -2038,5 +2040,113 @@ EOFCONFIG`; .getLXCConfig({ scriptId: input.scriptId, forceSync: true }); return result; + }), + + // Get backup-capable storages for a server + getBackupStorages: publicProcedure + .input(z.object({ + serverId: z.number(), + forceRefresh: z.boolean().optional().default(false) + })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const server = await db.getServerById(input.serverId); + + if (!server) { + return { + success: false, + error: 'Server not found', + storages: [], + cached: false + }; + } + + const storageService = getStorageService(); + const sshService = new SSHService(); + + // Test SSH connection first + const connectionTest = await sshService.testSSHConnection(server as Server); + if (!(connectionTest as any).success) { + return { + success: false, + error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`, + storages: [], + cached: false + }; + } + + // Check if we have cached data + const wasCached = !input.forceRefresh; + + // Fetch storages (will use cache if not forcing refresh) + const allStorages = await storageService.getStorages(server as Server, input.forceRefresh); + + return { + success: true, + storages: allStorages, + cached: wasCached && allStorages.length > 0 + }; + } catch (error) { + console.error('Error in getBackupStorages:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch storages', + storages: [], + cached: false + }; + } + }), + + // Execute backup for a container + executeBackup: publicProcedure + .input(z.object({ + containerId: z.string(), + storage: z.string(), + serverId: z.number() + })) + .mutation(async ({ input }) => { + try { + const db = getDatabase(); + const server = await db.getServerById(input.serverId); + + if (!server) { + return { + success: false, + error: 'Server not found', + executionId: null + }; + } + + const sshService = new SSHService(); + + // Test SSH connection first + const connectionTest = await sshService.testSSHConnection(server as Server); + if (!(connectionTest as any).success) { + return { + success: false, + error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`, + executionId: null + }; + } + + // Generate execution ID for websocket tracking + const executionId = `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + return { + success: true, + executionId, + containerId: input.containerId, + storage: input.storage, + server: server as Server + }; + } catch (error) { + console.error('Error in executeBackup:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to execute backup', + executionId: null + }; + } }) }); diff --git a/src/server/services/storageService.ts b/src/server/services/storageService.ts new file mode 100644 index 0000000..c1c3bfb --- /dev/null +++ b/src/server/services/storageService.ts @@ -0,0 +1,197 @@ +import { getSSHExecutionService } from '../ssh-execution-service'; +import type { Server } from '~/types/server'; + +export interface Storage { + name: string; + type: string; + content: string[]; + supportsBackup: boolean; + nodes?: string[]; + [key: string]: any; // For additional storage-specific properties +} + +interface CachedStorageData { + storages: Storage[]; + lastFetched: Date; +} + +class StorageService { + private cache: Map = new Map(); + private readonly CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + + /** + * Parse storage.cfg content and extract storage information + */ + private parseStorageConfig(configContent: string): Storage[] { + const storages: Storage[] = []; + const lines = configContent.split('\n'); + + let currentStorage: Partial | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip empty lines and comments + if (!line || line.startsWith('#')) { + continue; + } + + // 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)); + } + + // 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]; + + switch (key) { + case 'content': + // Content can be comma-separated: "images,rootdir" or "backup" + currentStorage.content = value.split(',').map(c => c.trim()); + currentStorage.supportsBackup = currentStorage.content.includes('backup'); + break; + case 'nodes': + // Nodes can be comma-separated: "prox5" or "prox5,prox6" + currentStorage.nodes = value.split(',').map(n => n.trim()); + break; + default: + // Store other properties + (currentStorage as any)[key] = value; + } + } + } + } + + // Don't forget the last storage + if (currentStorage && currentStorage.name) { + storages.push(this.finalizeStorage(currentStorage)); + } + + return storages; + } + + /** + * Finalize storage object with proper typing + */ + private finalizeStorage(storage: Partial): Storage { + return { + name: storage.name!, + type: storage.type!, + content: storage.content || [], + supportsBackup: storage.supportsBackup || false, + nodes: storage.nodes, + ...Object.fromEntries( + Object.entries(storage).filter(([key]) => + !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key) + ) + ), + }; + } + + /** + * Fetch storage configuration from server via SSH + */ + async fetchStoragesFromServer(server: Server, forceRefresh = false): Promise { + const serverId = server.id; + + // Check cache first (unless force refresh) + if (!forceRefresh && this.cache.has(serverId)) { + const cached = this.cache.get(serverId)!; + const age = Date.now() - cached.lastFetched.getTime(); + + if (age < this.CACHE_TTL_MS) { + return cached.storages; + } + } + + // Fetch from server + const sshService = getSSHExecutionService(); + let configContent = ''; + + await new Promise((resolve, reject) => { + sshService.executeCommand( + server, + 'cat /etc/pve/storage.cfg', + (data: string) => { + configContent += data; + }, + (error: string) => { + reject(new Error(`Failed to read storage config: ${error}`)); + }, + (exitCode: number) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`Command failed with exit code ${exitCode}`)); + } + } + ); + }); + + // Parse and cache + const storages = this.parseStorageConfig(configContent); + this.cache.set(serverId, { + storages, + lastFetched: new Date(), + }); + + return storages; + } + + /** + * Get all storages for a server (cached or fresh) + */ + async getStorages(server: Server, forceRefresh = false): Promise { + return this.fetchStoragesFromServer(server, forceRefresh); + } + + /** + * Get only backup-capable storages + */ + async getBackupStorages(server: Server, forceRefresh = false): Promise { + const allStorages = await this.getStorages(server, forceRefresh); + return allStorages.filter(s => s.supportsBackup); + } + + /** + * Clear cache for a specific server + */ + clearCache(serverId: number): void { + this.cache.delete(serverId); + } + + /** + * Clear all caches + */ + clearAllCaches(): void { + this.cache.clear(); + } +} + +// Singleton instance +let storageServiceInstance: StorageService | null = null; + +export function getStorageService(): StorageService { + if (!storageServiceInstance) { + storageServiceInstance = new StorageService(); + } + return storageServiceInstance; +} +