From dd17d2cbeca901efc7e5cbd629284f7bc8f2aacb Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Sat, 29 Nov 2025 16:53:58 +0100 Subject: [PATCH] feat: Add VM/LXC cloning functionality - Add CloneCountInputModal component for specifying clone count - Implement clone handlers and state management in InstalledScriptsTab - Add clone menu item to ScriptInstallationCard - Extend StorageSelectionModal to support clone storage selection (rootdir only) - Add clone terminal support to Terminal component - Implement startSSHCloneExecution in server.js with sequential ID retrieval - Add clone-related API endpoints (getClusterNextId, getContainerType, getCloneStorages, generateCloneHostnames, executeClone, addClonedContainerToDatabase) - Integrate with VM/LXC detection from main branch - Fix storage fetching to use correct serverId parameter - Fix clone execution to pass storage parameter correctly - Remove unused eslint-disable comments --- server.js | 426 ++++++++++++- src/app/_components/CloneCountInputModal.tsx | 129 ++++ src/app/_components/InstalledScriptsTab.tsx | 289 ++++++++- .../_components/ScriptInstallationCard.tsx | 13 +- src/app/_components/StorageSelectionModal.tsx | 30 +- src/app/_components/Terminal.tsx | 51 +- src/server/api/routers/installedScripts.ts | 571 +++++++++++++++++- src/server/services/backupService.ts | 1 - src/server/services/githubJsonService.ts | 1 - src/server/services/storageService.ts | 1 - 10 files changed, 1462 insertions(+), 50 deletions(-) create mode 100644 src/app/_components/CloneCountInputModal.tsx diff --git a/server.js b/server.js index 90b586c..c9a4dc5 100644 --- a/server.js +++ b/server.js @@ -75,9 +75,13 @@ const handle = app.getRequestHandler(); * @property {boolean} [isUpdate] * @property {boolean} [isShell] * @property {boolean} [isBackup] + * @property {boolean} [isClone] * @property {string} [containerId] * @property {string} [storage] * @property {string} [backupStorage] + * @property {number} [cloneCount] + * @property {string[]} [hostnames] + * @property {'lxc'|'vm'} [containerType] */ class ScriptExecutionHandler { @@ -295,12 +299,14 @@ class ScriptExecutionHandler { * @param {WebSocketMessage} message */ async handleMessage(ws, message) { - const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message; + const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, isClone, containerId, storage, backupStorage, cloneCount, hostnames, containerType } = message; switch (action) { case 'start': if (scriptPath && executionId) { - if (isBackup && containerId && storage) { + if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) { + await this.startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames); + } else 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); @@ -832,6 +838,422 @@ class ScriptExecutionHandler { }); } + /** + * Start SSH clone execution + * Gets next IDs sequentially: get next ID → clone → get next ID → clone, etc. + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + * @param {string} storage + * @param {ServerInfo} server + * @param {'lxc'|'vm'} containerType + * @param {number} cloneCount + * @param {string[]} hostnames + */ + async startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames) { + const sshService = getSSHExecutionService(); + + this.sendMessage(ws, { + type: 'start', + data: `Starting clone operation: Creating ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}...`, + timestamp: Date.now() + }); + + try { + // Step 1: Stop source container/VM + this.sendMessage(ws, { + type: 'output', + data: `\n[Step 1/${4 + cloneCount}] Stopping source ${containerType.toUpperCase()} ${containerId}...\n`, + timestamp: Date.now() + }); + + const stopCommand = containerType === 'lxc' ? `pct stop ${containerId}` : `qm stop ${containerId}`; + await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => { + sshService.executeCommand( + server, + stopCommand, + /** @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: 'output', + data: `\n[Step 1/${4 + cloneCount}] Source ${containerType.toUpperCase()} stopped successfully.\n`, + timestamp: Date.now() + }); + resolve(); + } else { + // Continue even if stop fails (might already be stopped) + this.sendMessage(ws, { + type: 'output', + data: `\n[Step 1/${4 + cloneCount}] Stop command completed with exit code ${code} (container may already be stopped).\n`, + timestamp: Date.now() + }); + resolve(); + } + } + ); + })); + + // Step 2: Clone for each clone count (get next ID sequentially before each clone) + const clonedIds = []; + for (let i = 0; i < cloneCount; i++) { + const cloneNumber = i + 1; + const hostname = hostnames[i]; + + // Get next ID for this clone + this.sendMessage(ws, { + type: 'output', + data: `\n[Step ${2 + i}/${4 + cloneCount}] Getting next available ID for clone ${cloneNumber}...\n`, + timestamp: Date.now() + }); + + let nextId = ''; + try { + let output = ''; + await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => { + sshService.executeCommand( + server, + 'pvesh get /cluster/nextid', + /** @param {string} data */ + (data) => { + output += data; + }, + /** @param {string} error */ + (error) => { + reject(new Error(`Failed to get next ID: ${error}`)); + }, + /** @param {number} exitCode */ + (exitCode) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`pvesh command failed with exit code ${exitCode}`)); + } + } + ); + })); + + nextId = output.trim(); + if (!nextId || !/^\d+$/.test(nextId)) { + throw new Error('Invalid next ID received'); + } + + this.sendMessage(ws, { + type: 'output', + data: `\n[Step ${2 + i}/${4 + cloneCount}] Got next ID: ${nextId}\n`, + timestamp: Date.now() + }); + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `\n[Step ${2 + i}/${4 + cloneCount}] Failed to get next ID: ${error instanceof Error ? error.message : String(error)}\n`, + timestamp: Date.now() + }); + throw error; + } + + clonedIds.push(nextId); + + // Clone the container/VM + this.sendMessage(ws, { + type: 'output', + data: `\n[Step ${2 + i}/${4 + cloneCount}] Cloning ${containerType.toUpperCase()} ${containerId} to ${nextId} with hostname ${hostname}...\n`, + timestamp: Date.now() + }); + + const cloneCommand = containerType === 'lxc' + ? `pct clone ${containerId} ${nextId} --hostname ${hostname} --storage ${storage}` + : `qm clone ${containerId} ${nextId} --name ${hostname} --storage ${storage}`; + + await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => { + sshService.executeCommand( + server, + cloneCommand, + /** @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: 'output', + data: `\n[Step ${2 + i}/${4 + cloneCount}] Clone ${cloneNumber} created successfully.\n`, + timestamp: Date.now() + }); + resolve(); + } else { + this.sendMessage(ws, { + type: 'error', + data: `\nClone ${cloneNumber} failed with exit code: ${code}\n`, + timestamp: Date.now() + }); + reject(new Error(`Clone ${cloneNumber} failed with exit code ${code}`)); + } + } + ); + })); + } + + // Step 3: Start source container/VM + this.sendMessage(ws, { + type: 'output', + data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Starting source ${containerType.toUpperCase()} ${containerId}...\n`, + timestamp: Date.now() + }); + + const startSourceCommand = containerType === 'lxc' ? `pct start ${containerId}` : `qm start ${containerId}`; + await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => { + sshService.executeCommand( + server, + startSourceCommand, + /** @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: 'output', + data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Source ${containerType.toUpperCase()} started successfully.\n`, + timestamp: Date.now() + }); + } else { + this.sendMessage(ws, { + type: 'output', + data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Start command completed with exit code ${code}.\n`, + timestamp: Date.now() + }); + } + resolve(); + } + ); + })); + + // Step 4: Start target containers/VMs + this.sendMessage(ws, { + type: 'output', + data: `\n[Step ${2 + cloneCount + 2}/${4 + cloneCount}] Starting cloned ${containerType.toUpperCase()}(s)...\n`, + timestamp: Date.now() + }); + + for (let i = 0; i < cloneCount; i++) { + const cloneNumber = i + 1; + const nextId = clonedIds[i]; + + const startTargetCommand = containerType === 'lxc' ? `pct start ${nextId}` : `qm start ${nextId}`; + await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => { + sshService.executeCommand( + server, + startTargetCommand, + /** @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: 'output', + data: `\nClone ${cloneNumber} (ID: ${nextId}) started successfully.\n`, + timestamp: Date.now() + }); + } else { + this.sendMessage(ws, { + type: 'output', + data: `\nClone ${cloneNumber} (ID: ${nextId}) start completed with exit code ${code}.\n`, + timestamp: Date.now() + }); + } + resolve(); + } + ); + })); + } + + // Step 5: Add to database + this.sendMessage(ws, { + type: 'output', + data: `\n[Step ${2 + cloneCount + 3}/${4 + cloneCount}] Adding cloned ${containerType.toUpperCase()}(s) to database...\n`, + timestamp: Date.now() + }); + + for (let i = 0; i < cloneCount; i++) { + const nextId = clonedIds[i]; + const hostname = hostnames[i]; + + try { + // Read config file to get hostname/name + const configPath = containerType === 'lxc' + ? `/etc/pve/lxc/${nextId}.conf` + : `/etc/pve/qemu-server/${nextId}.conf`; + + let configContent = ''; + await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => { + sshService.executeCommand( + server, + `cat "${configPath}" 2>/dev/null || echo ""`, + /** @param {string} data */ + (data) => { + configContent += data; + }, + () => resolve(), + () => resolve() + ); + })); + + // Parse config for hostname/name + let finalHostname = hostname; + if (configContent.trim()) { + const lines = configContent.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (containerType === 'lxc' && trimmed.startsWith('hostname:')) { + finalHostname = trimmed.substring(9).trim(); + break; + } else if (containerType === 'vm' && trimmed.startsWith('name:')) { + finalHostname = trimmed.substring(5).trim(); + break; + } + } + } + + if (!finalHostname) { + finalHostname = `${containerType}-${nextId}`; + } + + // Create installed script record + const script = await this.db.createInstalledScript({ + script_name: finalHostname, + script_path: `cloned/${finalHostname}`, + container_id: nextId, + server_id: server.id, + execution_mode: 'ssh', + status: 'success', + output_log: `Cloned ${containerType.toUpperCase()}` + }); + + // For LXC, store config in database + if (containerType === 'lxc' && configContent.trim()) { + // Simple config parser + /** @type {any} */ + const configData = {}; + const lines = configContent.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const [key, ...valueParts] = trimmed.split(':'); + const value = valueParts.join(':').trim(); + + if (key === 'hostname') configData.hostname = value; + else if (key === 'arch') configData.arch = value; + else if (key === 'cores') configData.cores = parseInt(value) || null; + else if (key === 'memory') configData.memory = parseInt(value) || null; + else if (key === 'swap') configData.swap = parseInt(value) || null; + else if (key === 'onboot') configData.onboot = parseInt(value) || null; + else if (key === 'ostype') configData.ostype = value; + else if (key === 'unprivileged') configData.unprivileged = parseInt(value) || null; + else if (key === 'tags') configData.tags = value; + else if (key === 'rootfs') { + const match = value.match(/^([^:]+):([^,]+)/); + if (match) { + configData.rootfs_storage = match[1]; + const sizeMatch = value.match(/size=([^,]+)/); + if (sizeMatch) { + configData.rootfs_size = sizeMatch[1]; + } + } + } + } + + await this.db.createLXCConfig(script.id, configData); + } + + this.sendMessage(ws, { + type: 'output', + data: `\nClone ${i + 1} (ID: ${nextId}, Hostname: ${finalHostname}) added to database successfully.\n`, + timestamp: Date.now() + }); + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `\nError adding clone ${i + 1} (ID: ${nextId}) to database: ${error instanceof Error ? error.message : String(error)}\n`, + timestamp: Date.now() + }); + } + } + + this.sendMessage(ws, { + type: 'output', + data: `\n\n[Clone operation completed successfully!]\nCreated ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}.\n`, + timestamp: Date.now() + }); + + this.activeExecutions.delete(executionId); + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `\n\n[Clone operation failed!]\nError: ${error instanceof Error ? error.message : String(error)}\n`, + timestamp: Date.now() + }); + this.activeExecutions.delete(executionId); + } + } + /** * Start update execution (pct enter + update command) * @param {ExtendedWebSocket} ws diff --git a/src/app/_components/CloneCountInputModal.tsx b/src/app/_components/CloneCountInputModal.tsx new file mode 100644 index 0000000..eb32ac5 --- /dev/null +++ b/src/app/_components/CloneCountInputModal.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Copy, X } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; + +interface CloneCountInputModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (count: number) => void; + storageName: string; +} + +export function CloneCountInputModal({ + isOpen, + onClose, + onSubmit, + storageName +}: CloneCountInputModalProps) { + const [cloneCount, setCloneCount] = useState(1); + + useRegisterModal(isOpen, { id: 'clone-count-input-modal', allowEscape: true, onClose }); + + useEffect(() => { + if (isOpen) { + setCloneCount(1); // Reset to default when modal opens + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleSubmit = () => { + if (cloneCount >= 1) { + onSubmit(cloneCount); + setCloneCount(1); // Reset after submit + } + }; + + const handleClose = () => { + setCloneCount(1); // Reset on close + onClose(); + }; + + return ( +
+
+ {/* Header */} +
+
+ +

Clone Count

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

+ How many clones would you like to create? +

+ + {storageName && ( +
+

Storage:

+

{storageName}

+
+ )} + +
+ + { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 100) { + setCloneCount(value); + } else if (e.target.value === '') { + setCloneCount(1); + } + }} + className="w-full" + placeholder="1" + /> +

+ Enter a number between 1 and 100 +

+
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} + diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index a10e8cb..6d7e014 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -12,6 +12,7 @@ import { LoadingModal } from "./LoadingModal"; import { LXCSettingsModal } from "./LXCSettingsModal"; import { StorageSelectionModal } from "./StorageSelectionModal"; import { BackupWarningModal } from "./BackupWarningModal"; +import { CloneCountInputModal } from "./CloneCountInputModal"; import type { Storage } from "~/server/services/storageService"; import { getContrastColor } from "../../lib/colorUtils"; import { @@ -68,6 +69,12 @@ export function InstalledScriptsTab() { server?: any; backupStorage?: string; isBackupOnly?: boolean; + isClone?: boolean; + executionId?: string; + cloneCount?: number; + hostnames?: string[]; + containerType?: 'lxc' | 'vm'; + storage?: string; } | null>(null); const [openingShell, setOpeningShell] = useState<{ id: number; @@ -82,6 +89,14 @@ export function InstalledScriptsTab() { 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 [pendingCloneScript, setPendingCloneScript] = useState(null); + const [cloneStorages, setCloneStorages] = useState([]); + const [isLoadingCloneStorages, setIsLoadingCloneStorages] = useState(false); + const [showCloneStorageSelection, setShowCloneStorageSelection] = useState(false); + const [showCloneCountInput, setShowCloneCountInput] = useState(false); + const [cloneContainerType, setCloneContainerType] = useState<'lxc' | 'vm' | null>(null); + const [selectedCloneStorage, setSelectedCloneStorage] = useState(null); + // cloneCount is passed as parameter to handleCloneCountSubmit, no need for state const [editingScriptId, setEditingScriptId] = useState(null); const [editFormData, setEditFormData] = useState<{ script_name: string; @@ -925,6 +940,201 @@ export function InstalledScriptsTab() { setShowStorageSelection(true); }; + // Clone queries + + const getContainerHostnameQuery = api.installedScripts.getContainerHostname.useQuery( + { + containerId: pendingCloneScript?.container_id ?? '', + serverId: pendingCloneScript?.server_id ?? 0, + containerType: cloneContainerType ?? 'lxc' + }, + { enabled: false } + ); + + const executeCloneMutation = api.installedScripts.executeClone.useMutation(); + const utils = api.useUtils(); + + const fetchCloneStorages = async (serverId: number, _forceRefresh = false) => { + setIsLoadingCloneStorages(true); + try { + // Use utils.fetch to call with the correct serverId + const result = await utils.installedScripts.getCloneStorages.fetch({ + serverId, + forceRefresh: _forceRefresh + }); + if (result?.success && result.storages) { + setCloneStorages(result.storages as Storage[]); + } else { + setErrorModal({ + isOpen: true, + title: 'Failed to Fetch Storages', + message: result?.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 { + setIsLoadingCloneStorages(false); + } + }; + + const handleCloneScript = async (script: InstalledScript) => { + if (!script.container_id) { + setErrorModal({ + isOpen: true, + title: 'Clone Failed', + message: 'No Container ID available for this script', + details: 'This script does not have a valid container ID and cannot be cloned.' + }); + return; + } + + if (!script.server_id) { + setErrorModal({ + isOpen: true, + title: 'Clone Not Available', + message: 'Clone is only available for SSH scripts with a configured server.', + type: 'error' + }); + return; + } + + // Store the script and determine container type using is_vm property + setPendingCloneScript(script); + + // Use is_vm property from batch detection (from main branch) + // If not available, default to LXC + const containerType = script.is_vm ? 'vm' : 'lxc'; + setCloneContainerType(containerType); + + // Fetch storages and show selection modal + void fetchCloneStorages(script.server_id, false); + setShowCloneStorageSelection(true); + }; + + const handleCloneStorageSelected = (storage: Storage) => { + setShowCloneStorageSelection(false); + setSelectedCloneStorage(storage); + setShowCloneCountInput(true); + }; + + const handleCloneCountSubmit = async (count: number) => { + setShowCloneCountInput(false); + + if (!pendingCloneScript || !cloneContainerType) { + setErrorModal({ + isOpen: true, + title: 'Clone Failed', + message: 'Missing required information for cloning.', + type: 'error' + }); + return; + } + + try { + // Get original hostname + const hostnameResult = await getContainerHostnameQuery.refetch(); + + if (!hostnameResult.data?.success || !hostnameResult.data.hostname) { + setErrorModal({ + isOpen: true, + title: 'Clone Failed', + message: 'Could not retrieve container hostname.', + type: 'error' + }); + return; + } + + const originalHostname = hostnameResult.data.hostname; + + // Generate clone hostnames using utils to call with originalHostname + const hostnamesResult = await utils.installedScripts.generateCloneHostnames.fetch({ + originalHostname, + containerType: cloneContainerType ?? 'lxc', + serverId: pendingCloneScript.server_id!, + count + }); + + if (!hostnamesResult?.success || !hostnamesResult.hostnames.length) { + setErrorModal({ + isOpen: true, + title: 'Clone Failed', + message: hostnamesResult?.error ?? 'Could not generate clone hostnames.', + type: 'error' + }); + return; + } + + const hostnames = hostnamesResult.hostnames; + + // Execute clone (nextIds will be obtained sequentially in server.js) + const cloneResult = await executeCloneMutation.mutateAsync({ + containerId: pendingCloneScript.container_id!, + serverId: pendingCloneScript.server_id!, + storage: selectedCloneStorage!.name, + cloneCount: count, + hostnames: hostnames, + containerType: cloneContainerType + }); + + if (!cloneResult.success || !cloneResult.executionId) { + setErrorModal({ + isOpen: true, + title: 'Clone Failed', + message: cloneResult.error ?? 'Failed to start clone operation.', + type: 'error' + }); + return; + } + + // Get server info for websocket + const server = pendingCloneScript.server_id && pendingCloneScript.server_user ? { + id: pendingCloneScript.server_id, + name: pendingCloneScript.server_name, + ip: pendingCloneScript.server_ip, + user: pendingCloneScript.server_user, + password: pendingCloneScript.server_password, + auth_type: pendingCloneScript.server_auth_type ?? 'password', + ssh_key: pendingCloneScript.server_ssh_key, + ssh_key_passphrase: pendingCloneScript.server_ssh_key_passphrase, + ssh_port: pendingCloneScript.server_ssh_port ?? 22, + } : null; + + // Set up terminal for clone execution + setUpdatingScript({ + id: pendingCloneScript.id, + containerId: pendingCloneScript.container_id!, + server: server, + isClone: true, + executionId: cloneResult.executionId, + cloneCount: count, + hostnames: hostnames, + containerType: cloneContainerType, + storage: selectedCloneStorage!.name + }); + + // Reset clone state + setPendingCloneScript(null); + setCloneStorages([]); + setSelectedCloneStorage(null); + setCloneContainerType(null); + // Reset clone count (no state variable needed, count is passed as parameter) + } catch (error) { + setErrorModal({ + isOpen: true, + title: 'Clone Failed', + message: error instanceof Error ? error.message : 'Unknown error occurred', + type: 'error' + }); + } + }; + const handleOpenShell = (script: InstalledScript) => { if (!script.container_id) { setErrorModal({ @@ -1216,26 +1426,25 @@ export function InstalledScriptsTab() {
)} @@ -1716,6 +1925,7 @@ export function InstalledScriptsTab() { onCancel={handleCancelEdit} onUpdate={() => handleUpdateScript(script)} onBackup={() => handleBackupScript(script)} + onClone={() => handleCloneScript(script)} onShell={() => handleOpenShell(script)} onDelete={() => handleDeleteScript(Number(script.id))} isUpdating={updateScriptMutation.isPending} @@ -2067,8 +2277,22 @@ export function InstalledScriptsTab() { )} {script.container_id && - script.execution_mode === "ssh" && - !script.is_vm && ( + script.execution_mode === "ssh" && ( + + handleCloneScript(script) + } + disabled={ + containerStatuses.get(script.id) === + "stopped" + } + className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" + > + Clone + + )} + {script.container_id && + script.execution_mode === "ssh" && ( handleOpenShell(script) @@ -2357,6 +2581,43 @@ export function InstalledScriptsTab() { }} /> + {/* Clone Storage Selection Modal */} + { + setShowCloneStorageSelection(false); + setPendingCloneScript(null); + setCloneStorages([]); + }} + onSelect={handleCloneStorageSelected} + storages={cloneStorages} + isLoading={isLoadingCloneStorages} + onRefresh={() => { + if (pendingCloneScript?.server_id) { + void fetchCloneStorages(pendingCloneScript.server_id, true); + } + }} + title="Select Clone Storage" + description="Select a storage to use for cloning. Only storages with rootdir content are shown." + filterFn={(storage) => { + return storage.content.includes('rootdir'); + }} + showBackupTag={false} + /> + + {/* Clone Count Input Modal */} + { + setShowCloneCountInput(false); + setPendingCloneScript(null); + setCloneStorages([]); + setSelectedCloneStorage(null); + }} + onSubmit={handleCloneCountSubmit} + storageName={selectedCloneStorage?.name ?? ''} + /> + {/* LXC Settings Modal */} void; onUpdate: () => void; onBackup?: () => void; + onClone?: () => void; onShell: () => void; onDelete: () => void; isUpdating: boolean; @@ -71,6 +72,7 @@ export function ScriptInstallationCard({ onCancel, onUpdate, onBackup, + onClone, onShell, onDelete, isUpdating, @@ -319,7 +321,16 @@ export function ScriptInstallationCard({ Backup )} - {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && ( + {script.container_id && script.execution_mode === 'ssh' && onClone && ( + + Clone + + )} + {script.container_id && script.execution_mode === 'ssh' && ( void; + title?: string; + description?: string; + filterFn?: (storage: Storage) => boolean; + showBackupTag?: boolean; } export function StorageSelectionModal({ @@ -21,7 +25,11 @@ export function StorageSelectionModal({ onSelect, storages, isLoading, - onRefresh + onRefresh, + title = 'Select Storage', + description = 'Select a storage to use.', + filterFn, + showBackupTag = true }: StorageSelectionModalProps) { const [selectedStorage, setSelectedStorage] = useState(null); @@ -41,8 +49,8 @@ export function StorageSelectionModal({ onClose(); }; - // Filter to show only backup-capable storages - const backupStorages = storages.filter(s => s.supportsBackup); + // Filter storages using filterFn if provided, otherwise filter to show only backup-capable storages + const filteredStorages = filterFn ? storages.filter(filterFn) : storages.filter(s => s.supportsBackup); return (
@@ -51,7 +59,7 @@ export function StorageSelectionModal({
-

Select Backup Storage

+

{title}

Loading storages...

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

No backup-capable storages found

@@ -87,12 +95,12 @@ export function StorageSelectionModal({ ) : ( <>

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

{/* Storage List */}
- {backupStorages.map((storage) => ( + {filteredStorages.map((storage) => (
setSelectedStorage(storage)} @@ -106,9 +114,11 @@ export function StorageSelectionModal({

{storage.name}

- - Backup - + {showBackupTag && ( + + Backup + + )} {storage.type} diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx index 4412760..0aa4d59 100644 --- a/src/app/_components/Terminal.tsx +++ b/src/app/_components/Terminal.tsx @@ -13,9 +13,14 @@ interface TerminalProps { isUpdate?: boolean; isShell?: boolean; isBackup?: boolean; + isClone?: boolean; containerId?: string; storage?: string; backupStorage?: string; + executionId?: string; + cloneCount?: number; + hostnames?: string[]; + containerType?: 'lxc' | 'vm'; } interface TerminalMessage { @@ -24,7 +29,7 @@ interface TerminalMessage { timestamp: number; } -export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) { +export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, isClone = false, containerId, storage, backupStorage, executionId: propExecutionId, cloneCount, hostnames, containerType }: TerminalProps) { const [isConnected, setIsConnected] = useState(false); const [isRunning, setIsRunning] = useState(false); const [isClient, setIsClient] = useState(false); @@ -39,7 +44,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate const fitAddonRef = useRef(null); const wsRef = useRef(null); const inputHandlerRef = useRef<((data: string) => void) | null>(null); - const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + const [executionId, setExecutionId] = useState(() => propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + + // Update executionId when propExecutionId changes + useEffect(() => { + if (propExecutionId) { + setExecutionId(propExecutionId); + } + }, [propExecutionId]); + + const effectiveExecutionId = propExecutionId ?? executionId; const isConnectingRef = useRef(false); const hasConnectedRef = useRef(false); @@ -277,7 +291,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { const message = { action: 'input', - executionId, + executionId: effectiveExecutionId, input: data }; wsRef.current.send(JSON.stringify(message)); @@ -325,9 +339,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate // Only auto-start on initial connection, not on reconnections if (isInitialConnection && !isRunning) { - // Generate a new execution ID for the initial run - const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - setExecutionId(newExecutionId); + // Use propExecutionId if provided, otherwise generate a new one + const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + if (!propExecutionId) { + setExecutionId(newExecutionId); + } const message = { action: 'start', @@ -338,9 +354,13 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate isUpdate, isShell, isBackup, + isClone, containerId, storage, - backupStorage + backupStorage, + cloneCount, + hostnames, + containerType }; ws.send(JSON.stringify(message)); } @@ -384,9 +404,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate const startScript = () => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) { - // Generate a new execution ID for each script run - const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - setExecutionId(newExecutionId); + // Generate a new execution ID for each script run (unless propExecutionId is provided) + const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + if (!propExecutionId) { + setExecutionId(newExecutionId); + } setIsStopped(false); wsRef.current.send(JSON.stringify({ @@ -397,7 +419,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate server, isUpdate, isShell, - containerId + isBackup, + isClone, + containerId, + storage, + backupStorage, + cloneCount, + hostnames, + containerType })); } }; diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index 02f416e..1cd2a20 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -442,22 +442,18 @@ async function isVM(scriptId: number, containerId: string, serverId: number | nu return true; // VM config file exists } - // Check LXC config file - let lxcConfigExists = false; + // Check LXC config file (not needed for return value, but check for completeness) 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; - } + (_data: string) => { + // Data handler not needed - just checking if file exists }, () => resolve(), () => resolve() ); }); - return false; // Always LXC since VM config doesn't exist } catch (error) { @@ -510,7 +506,7 @@ async function batchDetectContainerTypes(server: Server): Promise((resolve, reject) => { + await new Promise((resolve) => { void sshExecutionService.executeCommand( server, 'pct list', @@ -530,7 +526,7 @@ async function batchDetectContainerTypes(server: Server): Promise((resolve, reject) => { + await new Promise((resolve) => { void sshExecutionService.executeCommand( server, 'qm list', @@ -2651,5 +2647,562 @@ EOFCONFIG`; executionId: null }; } + }), + + // Get next free ID from cluster (single ID for sequential cloning) + getClusterNextId: publicProcedure + .input(z.object({ + serverId: z.number() + })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const server = await db.getServerById(input.serverId); + + if (!server) { + return { + success: false, + error: 'Server not found', + nextId: null + }; + } + + const { getSSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshExecutionService = getSSHExecutionService(); + + let output = ''; + await new Promise((resolve, reject) => { + sshExecutionService.executeCommand( + server as Server, + 'pvesh get /cluster/nextid', + (data: string) => { + output += data; + }, + (error: string) => { + reject(new Error(`Failed to get next ID: ${error}`)); + }, + (exitCode: number) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`pvesh command failed with exit code ${exitCode}`)); + } + } + ); + }); + + const nextId = output.trim(); + if (!nextId || !/^\d+$/.test(nextId)) { + return { + success: false, + error: 'Invalid next ID received', + nextId: null + }; + } + + return { + success: true, + nextId + }; + } catch (error) { + console.error('Error in getClusterNextId:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get next ID', + nextId: null + }; + } + }), + + // Get container hostname/name + getContainerHostname: publicProcedure + .input(z.object({ + containerId: z.string(), + serverId: z.number(), + containerType: z.enum(['lxc', 'vm']) + })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const server = await db.getServerById(input.serverId); + + if (!server) { + return { + success: false, + error: 'Server not found', + hostname: null + }; + } + + const { getSSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshExecutionService = getSSHExecutionService(); + + const configPath = input.containerType === 'lxc' + ? `/etc/pve/lxc/${input.containerId}.conf` + : `/etc/pve/qemu-server/${input.containerId}.conf`; + + let configContent = ''; + await new Promise((resolve) => { + sshExecutionService.executeCommand( + server as Server, + `cat "${configPath}" 2>/dev/null || echo ""`, + (data: string) => { + configContent += data; + }, + () => resolve(), // Don't fail on error + () => resolve() // Always resolve + ); + }); + + if (!configContent.trim()) { + return { + success: true, + hostname: null + }; + } + + // Parse config for hostname (LXC) or name (VM) + const lines = configContent.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (input.containerType === 'lxc' && trimmed.startsWith('hostname:')) { + const hostname = trimmed.substring(9).trim(); + return { + success: true, + hostname + }; + } else if (input.containerType === 'vm' && trimmed.startsWith('name:')) { + const name = trimmed.substring(5).trim(); + return { + success: true, + hostname: name + }; + } + } + + return { + success: true, + hostname: null + }; + } catch (error) { + console.error('Error in getContainerHostname:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get container hostname', + hostname: null + }; + } + }), + + // Get clone storages (rootdir or images content) + getCloneStorages: 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 { default: SSHService } = await import('~/server/ssh-service'); + const { getSSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshService = new SSHService(); + const sshExecutionService = getSSHExecutionService(); + + // 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 + }; + } + + // Get server hostname to filter storages + let serverHostname = ''; + try { + await new Promise((resolve, reject) => { + sshExecutionService.executeCommand( + server as Server, + 'hostname', + (data: string) => { + serverHostname += data; + }, + (error: string) => { + reject(new Error(`Failed to get hostname: ${error}`)); + }, + (exitCode: number) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`hostname command failed with exit code ${exitCode}`)); + } + } + ); + }); + } catch (error) { + console.error('Error getting server hostname:', error); + // Continue without filtering if hostname can't be retrieved + } + + const normalizedHostname = serverHostname.trim().toLowerCase(); + + // 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); + + // Filter storages by node hostname matching and content type (only rootdir for cloning) + const applicableStorages = allStorages.filter(storage => { + // Check content type - must have rootdir for cloning + const hasRootdir = storage.content.includes('rootdir'); + if (!hasRootdir) { + return false; + } + + // If storage has no nodes specified, it's available on all nodes + if (!storage.nodes || storage.nodes.length === 0) { + return true; + } + + // If we couldn't get hostname, include all storages (fallback) + if (!normalizedHostname) { + return true; + } + + // Check if server hostname is in the nodes array (case-insensitive, trimmed) + const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase()); + return normalizedNodes.includes(normalizedHostname); + }); + + return { + success: true, + storages: applicableStorages, + cached: wasCached && applicableStorages.length > 0 + }; + } catch (error) { + console.error('Error in getCloneStorages:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch storages', + storages: [], + cached: false + }; + } + }), + + // Generate clone hostnames + generateCloneHostnames: publicProcedure + .input(z.object({ + originalHostname: z.string(), + containerType: z.enum(['lxc', 'vm']), + serverId: z.number(), + count: z.number().min(1).max(100) + })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const server = await db.getServerById(input.serverId); + + if (!server) { + return { + success: false, + error: 'Server not found', + hostnames: [] + }; + } + + const { getSSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshExecutionService = getSSHExecutionService(); + + // Get all existing containers/VMs to find existing clones (check both LXC and VM) + const existingHostnames = new Set(); + + // Check LXC containers + let lxcOutput = ''; + try { + await new Promise((resolve) => { + sshExecutionService.executeCommand( + server as Server, + 'pct list', + (data: string) => { + lxcOutput += data; + }, + (error: string) => { + console.error(`pct list error for server ${server.name}:`, error); + resolve(); + }, + () => resolve() + ); + }); + + const lxcLines = lxcOutput.split('\n').filter(line => line.trim()); + for (const line of lxcLines) { + if (line.includes('CTID') || line.includes('NAME')) continue; + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + const name = parts.slice(2).join(' ').trim(); + if (name) { + existingHostnames.add(name.toLowerCase()); + } + } + } + } catch { + // Continue even if LXC list fails + } + + // Check VMs + let vmOutput = ''; + try { + await new Promise((resolve) => { + sshExecutionService.executeCommand( + server as Server, + 'qm list', + (data: string) => { + vmOutput += data; + }, + (error: string) => { + console.error(`qm list error for server ${server.name}:`, error); + resolve(); + }, + () => resolve() + ); + }); + + const vmLines = vmOutput.split('\n').filter(line => line.trim()); + for (const line of vmLines) { + if (line.includes('VMID') || line.includes('NAME')) continue; + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + const name = parts.slice(2).join(' ').trim(); + if (name) { + existingHostnames.add(name.toLowerCase()); + } + } + } + } catch { + // Continue even if VM list fails + } + + // Find next available clone number + const clonePattern = new RegExp(`^${input.originalHostname.toLowerCase()}-clone-(\\d+)$`); + const existingCloneNumbers: number[] = []; + + for (const hostname of existingHostnames) { + const match = hostname.match(clonePattern); + if (match) { + existingCloneNumbers.push(parseInt(match[1] ?? '0', 10)); + } + } + + // Determine starting number + let nextNumber = 1; + if (existingCloneNumbers.length > 0) { + existingCloneNumbers.sort((a, b) => a - b); + const lastNumber = existingCloneNumbers[existingCloneNumbers.length - 1]; + if (lastNumber !== undefined) { + nextNumber = lastNumber + 1; + } + } + + // Generate hostnames + const hostnames: string[] = []; + for (let i = 0; i < input.count; i++) { + hostnames.push(`${input.originalHostname}-clone-${nextNumber + i}`); + } + + return { + success: true, + hostnames + }; + } catch (error) { + console.error('Error in generateCloneHostnames:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to generate clone hostnames', + hostnames: [] + }; + } + }), + + // Execute clone (prepare for websocket execution) + // Note: nextIds will be obtained sequentially during cloning in server.js + executeClone: publicProcedure + .input(z.object({ + containerId: z.string(), + serverId: z.number(), + storage: z.string(), + cloneCount: z.number().min(1).max(100), + hostnames: z.array(z.string()), + containerType: z.enum(['lxc', 'vm']) + })) + .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 { default: SSHService } = await import('~/server/ssh-service'); + 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 + }; + } + + // Validate inputs + if (input.hostnames.length !== input.cloneCount) { + return { + success: false, + error: 'Hostnames count must match clone count', + executionId: null + }; + } + + // Generate execution ID for websocket tracking + const executionId = `clone_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + return { + success: true, + executionId, + containerId: input.containerId, + storage: input.storage, + cloneCount: input.cloneCount, + hostnames: input.hostnames, + containerType: input.containerType, + server: server as Server + }; + } catch (error) { + console.error('Error in executeClone:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to execute clone', + executionId: null + }; + } + }), + + // Add cloned container to database + addClonedContainerToDatabase: publicProcedure + .input(z.object({ + containerId: z.string(), + serverId: z.number(), + containerType: z.enum(['lxc', 'vm']) + })) + .mutation(async ({ input }) => { + try { + const db = getDatabase(); + const server = await db.getServerById(input.serverId); + + if (!server) { + return { + success: false, + error: 'Server not found', + scriptId: null + }; + } + + const { getSSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshExecutionService = getSSHExecutionService(); + + // Read config file to get hostname/name + const configPath = input.containerType === 'lxc' + ? `/etc/pve/lxc/${input.containerId}.conf` + : `/etc/pve/qemu-server/${input.containerId}.conf`; + + let configContent = ''; + await new Promise((resolve) => { + sshExecutionService.executeCommand( + server as Server, + `cat "${configPath}" 2>/dev/null || echo ""`, + (data: string) => { + configContent += data; + }, + () => resolve(), + () => resolve() + ); + }); + + if (!configContent.trim()) { + return { + success: false, + error: 'Config file not found', + scriptId: null + }; + } + + // Parse config for hostname/name + let hostname = ''; + const lines = configContent.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (input.containerType === 'lxc' && trimmed.startsWith('hostname:')) { + hostname = trimmed.substring(9).trim(); + break; + } else if (input.containerType === 'vm' && trimmed.startsWith('name:')) { + hostname = trimmed.substring(5).trim(); + break; + } + } + + if (!hostname) { + hostname = `${input.containerType}-${input.containerId}`; + } + + // Create installed script record + const script = await db.createInstalledScript({ + script_name: hostname, + script_path: `cloned/${hostname}`, + container_id: input.containerId, + server_id: input.serverId, + execution_mode: 'ssh', + status: 'success', + output_log: `Cloned container/VM` + }); + + // For LXC, store config in database + if (input.containerType === 'lxc') { + const parsedConfig = parseRawConfig(configContent); + await db.createLXCConfig(script.id, parsedConfig); + } + + return { + success: true, + scriptId: script.id + }; + } catch (error) { + console.error('Error in addClonedContainerToDatabase:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to add cloned container to database', + scriptId: null + }; + } }) }); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index cfde3a5..a64678a 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/no-unused-vars, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/prefer-optional-chain */ import { getSSHExecutionService } from '../ssh-execution-service'; import { getStorageService } from './storageService'; import { getDatabase } from '../database-prisma'; diff --git a/src/server/services/githubJsonService.ts b/src/server/services/githubJsonService.ts index f3c6f57..b70b3d6 100644 --- a/src/server/services/githubJsonService.ts +++ b/src/server/services/githubJsonService.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import { writeFile, mkdir, readdir, readFile } from 'fs/promises'; import { join } from 'path'; import { env } from '../../env.js'; diff --git a/src/server/services/storageService.ts b/src/server/services/storageService.ts index 3d5622a..bcd5703 100644 --- a/src/server/services/storageService.ts +++ b/src/server/services/storageService.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-optional-chain, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/prefer-for-of */ import { getSSHExecutionService } from '../ssh-execution-service'; import type { Server } from '~/types/server';