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
This commit is contained in:
426
server.js
426
server.js
@@ -75,9 +75,13 @@ const handle = app.getRequestHandler();
|
|||||||
* @property {boolean} [isUpdate]
|
* @property {boolean} [isUpdate]
|
||||||
* @property {boolean} [isShell]
|
* @property {boolean} [isShell]
|
||||||
* @property {boolean} [isBackup]
|
* @property {boolean} [isBackup]
|
||||||
|
* @property {boolean} [isClone]
|
||||||
* @property {string} [containerId]
|
* @property {string} [containerId]
|
||||||
* @property {string} [storage]
|
* @property {string} [storage]
|
||||||
* @property {string} [backupStorage]
|
* @property {string} [backupStorage]
|
||||||
|
* @property {number} [cloneCount]
|
||||||
|
* @property {string[]} [hostnames]
|
||||||
|
* @property {'lxc'|'vm'} [containerType]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class ScriptExecutionHandler {
|
class ScriptExecutionHandler {
|
||||||
@@ -295,12 +299,14 @@ class ScriptExecutionHandler {
|
|||||||
* @param {WebSocketMessage} message
|
* @param {WebSocketMessage} message
|
||||||
*/
|
*/
|
||||||
async handleMessage(ws, 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) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
if (scriptPath && executionId) {
|
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);
|
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
|
||||||
} else if (isUpdate && containerId) {
|
} else if (isUpdate && containerId) {
|
||||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
|
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)
|
* Start update execution (pct enter + update command)
|
||||||
* @param {ExtendedWebSocket} ws
|
* @param {ExtendedWebSocket} ws
|
||||||
|
|||||||
129
src/app/_components/CloneCountInputModal.tsx
Normal file
129
src/app/_components/CloneCountInputModal.tsx
Normal file
@@ -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<number>(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 (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Copy className="h-6 w-6 text-primary" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Clone Count</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
How many clones would you like to create?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{storageName && (
|
||||||
|
<div className="mb-4 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Storage:</p>
|
||||||
|
<p className="text-sm font-medium text-foreground">{storageName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-6">
|
||||||
|
<label htmlFor="cloneCount" className="block text-sm font-medium text-foreground">
|
||||||
|
Number of Clones
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="cloneCount"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={cloneCount}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter a number between 1 and 100
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={cloneCount < 1 || cloneCount > 100}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import { LoadingModal } from "./LoadingModal";
|
|||||||
import { LXCSettingsModal } from "./LXCSettingsModal";
|
import { LXCSettingsModal } from "./LXCSettingsModal";
|
||||||
import { StorageSelectionModal } from "./StorageSelectionModal";
|
import { StorageSelectionModal } from "./StorageSelectionModal";
|
||||||
import { BackupWarningModal } from "./BackupWarningModal";
|
import { BackupWarningModal } from "./BackupWarningModal";
|
||||||
|
import { CloneCountInputModal } from "./CloneCountInputModal";
|
||||||
import type { Storage } from "~/server/services/storageService";
|
import type { Storage } from "~/server/services/storageService";
|
||||||
import { getContrastColor } from "../../lib/colorUtils";
|
import { getContrastColor } from "../../lib/colorUtils";
|
||||||
import {
|
import {
|
||||||
@@ -68,6 +69,12 @@ export function InstalledScriptsTab() {
|
|||||||
server?: any;
|
server?: any;
|
||||||
backupStorage?: string;
|
backupStorage?: string;
|
||||||
isBackupOnly?: boolean;
|
isBackupOnly?: boolean;
|
||||||
|
isClone?: boolean;
|
||||||
|
executionId?: string;
|
||||||
|
cloneCount?: number;
|
||||||
|
hostnames?: string[];
|
||||||
|
containerType?: 'lxc' | 'vm';
|
||||||
|
storage?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [openingShell, setOpeningShell] = useState<{
|
const [openingShell, setOpeningShell] = useState<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -82,6 +89,14 @@ export function InstalledScriptsTab() {
|
|||||||
const [isLoadingStorages, setIsLoadingStorages] = useState(false);
|
const [isLoadingStorages, setIsLoadingStorages] = useState(false);
|
||||||
const [showBackupWarning, setShowBackupWarning] = useState(false);
|
const [showBackupWarning, setShowBackupWarning] = useState(false);
|
||||||
const [isPreUpdateBackup, setIsPreUpdateBackup] = useState(false); // Track if storage selection is for pre-update backup
|
const [isPreUpdateBackup, setIsPreUpdateBackup] = useState(false); // Track if storage selection is for pre-update backup
|
||||||
|
const [pendingCloneScript, setPendingCloneScript] = useState<InstalledScript | null>(null);
|
||||||
|
const [cloneStorages, setCloneStorages] = useState<Storage[]>([]);
|
||||||
|
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<Storage | null>(null);
|
||||||
|
// cloneCount is passed as parameter to handleCloneCountSubmit, no need for state
|
||||||
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
||||||
const [editFormData, setEditFormData] = useState<{
|
const [editFormData, setEditFormData] = useState<{
|
||||||
script_name: string;
|
script_name: string;
|
||||||
@@ -925,6 +940,201 @@ export function InstalledScriptsTab() {
|
|||||||
setShowStorageSelection(true);
|
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) => {
|
const handleOpenShell = (script: InstalledScript) => {
|
||||||
if (!script.container_id) {
|
if (!script.container_id) {
|
||||||
setErrorModal({
|
setErrorModal({
|
||||||
@@ -1216,26 +1426,25 @@ export function InstalledScriptsTab() {
|
|||||||
<div className="mb-8" data-terminal="update">
|
<div className="mb-8" data-terminal="update">
|
||||||
<Terminal
|
<Terminal
|
||||||
scriptPath={
|
scriptPath={
|
||||||
updatingScript.isBackupOnly
|
updatingScript.isClone
|
||||||
|
? `clone-${updatingScript.containerId}`
|
||||||
|
: updatingScript.isBackupOnly
|
||||||
? `backup-${updatingScript.containerId}`
|
? `backup-${updatingScript.containerId}`
|
||||||
: `update-${updatingScript.containerId}`
|
: `update-${updatingScript.containerId}`
|
||||||
}
|
}
|
||||||
onClose={handleCloseUpdateTerminal}
|
onClose={handleCloseUpdateTerminal}
|
||||||
mode={updatingScript.server ? "ssh" : "local"}
|
mode={updatingScript.server ? "ssh" : "local"}
|
||||||
server={updatingScript.server}
|
server={updatingScript.server}
|
||||||
isUpdate={!updatingScript.isBackupOnly}
|
isUpdate={!updatingScript.isBackupOnly && !updatingScript.isClone}
|
||||||
isBackup={updatingScript.isBackupOnly}
|
isBackup={updatingScript.isBackupOnly}
|
||||||
|
isClone={updatingScript.isClone}
|
||||||
containerId={updatingScript.containerId}
|
containerId={updatingScript.containerId}
|
||||||
storage={
|
executionId={updatingScript.executionId}
|
||||||
updatingScript.isBackupOnly
|
cloneCount={updatingScript.cloneCount}
|
||||||
? updatingScript.backupStorage
|
hostnames={updatingScript.hostnames}
|
||||||
: undefined
|
containerType={updatingScript.containerType}
|
||||||
}
|
storage={updatingScript.isClone ? updatingScript.storage : (updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined)}
|
||||||
backupStorage={
|
backupStorage={!updatingScript.isBackupOnly && !updatingScript.isClone ? updatingScript.backupStorage : undefined}
|
||||||
!updatingScript.isBackupOnly
|
|
||||||
? updatingScript.backupStorage
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1716,6 +1925,7 @@ export function InstalledScriptsTab() {
|
|||||||
onCancel={handleCancelEdit}
|
onCancel={handleCancelEdit}
|
||||||
onUpdate={() => handleUpdateScript(script)}
|
onUpdate={() => handleUpdateScript(script)}
|
||||||
onBackup={() => handleBackupScript(script)}
|
onBackup={() => handleBackupScript(script)}
|
||||||
|
onClone={() => handleCloneScript(script)}
|
||||||
onShell={() => handleOpenShell(script)}
|
onShell={() => handleOpenShell(script)}
|
||||||
onDelete={() => handleDeleteScript(Number(script.id))}
|
onDelete={() => handleDeleteScript(Number(script.id))}
|
||||||
isUpdating={updateScriptMutation.isPending}
|
isUpdating={updateScriptMutation.isPending}
|
||||||
@@ -2067,8 +2277,22 @@ export function InstalledScriptsTab() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{script.container_id &&
|
{script.container_id &&
|
||||||
script.execution_mode === "ssh" &&
|
script.execution_mode === "ssh" && (
|
||||||
!script.is_vm && (
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
handleCloneScript(script)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
containerStatuses.get(script.id) ===
|
||||||
|
"stopped"
|
||||||
|
}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||||
|
>
|
||||||
|
Clone
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{script.container_id &&
|
||||||
|
script.execution_mode === "ssh" && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleOpenShell(script)
|
handleOpenShell(script)
|
||||||
@@ -2357,6 +2581,43 @@ export function InstalledScriptsTab() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Clone Storage Selection Modal */}
|
||||||
|
<StorageSelectionModal
|
||||||
|
isOpen={showCloneStorageSelection}
|
||||||
|
onClose={() => {
|
||||||
|
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 */}
|
||||||
|
<CloneCountInputModal
|
||||||
|
isOpen={showCloneCountInput}
|
||||||
|
onClose={() => {
|
||||||
|
setShowCloneCountInput(false);
|
||||||
|
setPendingCloneScript(null);
|
||||||
|
setCloneStorages([]);
|
||||||
|
setSelectedCloneStorage(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleCloneCountSubmit}
|
||||||
|
storageName={selectedCloneStorage?.name ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* LXC Settings Modal */}
|
{/* LXC Settings Modal */}
|
||||||
<LXCSettingsModal
|
<LXCSettingsModal
|
||||||
isOpen={lxcSettingsModal.isOpen}
|
isOpen={lxcSettingsModal.isOpen}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ interface ScriptInstallationCardProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
onBackup?: () => void;
|
onBackup?: () => void;
|
||||||
|
onClone?: () => void;
|
||||||
onShell: () => void;
|
onShell: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
@@ -71,6 +72,7 @@ export function ScriptInstallationCard({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onBackup,
|
onBackup,
|
||||||
|
onClone,
|
||||||
onShell,
|
onShell,
|
||||||
onDelete,
|
onDelete,
|
||||||
isUpdating,
|
isUpdating,
|
||||||
@@ -319,7 +321,16 @@ export function ScriptInstallationCard({
|
|||||||
Backup
|
Backup
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
|
{script.container_id && script.execution_mode === 'ssh' && onClone && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onClone}
|
||||||
|
disabled={containerStatus === 'stopped'}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||||
|
>
|
||||||
|
Clone
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={onShell}
|
onClick={onShell}
|
||||||
disabled={containerStatus === 'stopped'}
|
disabled={containerStatus === 'stopped'}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ interface StorageSelectionModalProps {
|
|||||||
storages: Storage[];
|
storages: Storage[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
filterFn?: (storage: Storage) => boolean;
|
||||||
|
showBackupTag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StorageSelectionModal({
|
export function StorageSelectionModal({
|
||||||
@@ -21,7 +25,11 @@ export function StorageSelectionModal({
|
|||||||
onSelect,
|
onSelect,
|
||||||
storages,
|
storages,
|
||||||
isLoading,
|
isLoading,
|
||||||
onRefresh
|
onRefresh,
|
||||||
|
title = 'Select Storage',
|
||||||
|
description = 'Select a storage to use.',
|
||||||
|
filterFn,
|
||||||
|
showBackupTag = true
|
||||||
}: StorageSelectionModalProps) {
|
}: StorageSelectionModalProps) {
|
||||||
const [selectedStorage, setSelectedStorage] = useState<Storage | null>(null);
|
const [selectedStorage, setSelectedStorage] = useState<Storage | null>(null);
|
||||||
|
|
||||||
@@ -41,8 +49,8 @@ export function StorageSelectionModal({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter to show only backup-capable storages
|
// Filter storages using filterFn if provided, otherwise filter to show only backup-capable storages
|
||||||
const backupStorages = storages.filter(s => s.supportsBackup);
|
const filteredStorages = filterFn ? storages.filter(filterFn) : storages.filter(s => s.supportsBackup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
@@ -51,7 +59,7 @@ export function StorageSelectionModal({
|
|||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Database className="h-6 w-6 text-primary" />
|
<Database className="h-6 w-6 text-primary" />
|
||||||
<h2 className="text-2xl font-bold text-card-foreground">Select Backup Storage</h2>
|
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
@@ -72,7 +80,7 @@ export function StorageSelectionModal({
|
|||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||||
<p className="text-muted-foreground">Loading storages...</p>
|
<p className="text-muted-foreground">Loading storages...</p>
|
||||||
</div>
|
</div>
|
||||||
) : backupStorages.length === 0 ? (
|
) : filteredStorages.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<p className="text-foreground mb-2">No backup-capable storages found</p>
|
<p className="text-foreground mb-2">No backup-capable storages found</p>
|
||||||
@@ -87,12 +95,12 @@ export function StorageSelectionModal({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Select a storage to use for the backup. Only storages that support backups are shown.
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Storage List */}
|
{/* Storage List */}
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
|
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
|
||||||
{backupStorages.map((storage) => (
|
{filteredStorages.map((storage) => (
|
||||||
<div
|
<div
|
||||||
key={storage.name}
|
key={storage.name}
|
||||||
onClick={() => setSelectedStorage(storage)}
|
onClick={() => setSelectedStorage(storage)}
|
||||||
@@ -106,9 +114,11 @@ export function StorageSelectionModal({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
|
{showBackupTag && (
|
||||||
Backup
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
|
||||||
</span>
|
Backup
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
||||||
{storage.type}
|
{storage.type}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ interface TerminalProps {
|
|||||||
isUpdate?: boolean;
|
isUpdate?: boolean;
|
||||||
isShell?: boolean;
|
isShell?: boolean;
|
||||||
isBackup?: boolean;
|
isBackup?: boolean;
|
||||||
|
isClone?: boolean;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
storage?: string;
|
storage?: string;
|
||||||
backupStorage?: string;
|
backupStorage?: string;
|
||||||
|
executionId?: string;
|
||||||
|
cloneCount?: number;
|
||||||
|
hostnames?: string[];
|
||||||
|
containerType?: 'lxc' | 'vm';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TerminalMessage {
|
interface TerminalMessage {
|
||||||
@@ -24,7 +29,7 @@ interface TerminalMessage {
|
|||||||
timestamp: number;
|
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 [isConnected, setIsConnected] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
@@ -39,7 +44,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
const fitAddonRef = useRef<any>(null);
|
const fitAddonRef = useRef<any>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const inputHandlerRef = useRef<((data: string) => void) | null>(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<boolean>(false);
|
const isConnectingRef = useRef<boolean>(false);
|
||||||
const hasConnectedRef = useRef<boolean>(false);
|
const hasConnectedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
@@ -277,7 +291,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
const message = {
|
const message = {
|
||||||
action: 'input',
|
action: 'input',
|
||||||
executionId,
|
executionId: effectiveExecutionId,
|
||||||
input: data
|
input: data
|
||||||
};
|
};
|
||||||
wsRef.current.send(JSON.stringify(message));
|
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
|
// Only auto-start on initial connection, not on reconnections
|
||||||
if (isInitialConnection && !isRunning) {
|
if (isInitialConnection && !isRunning) {
|
||||||
// Generate a new execution ID for the initial run
|
// Use propExecutionId if provided, otherwise generate a new one
|
||||||
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
setExecutionId(newExecutionId);
|
if (!propExecutionId) {
|
||||||
|
setExecutionId(newExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
action: 'start',
|
action: 'start',
|
||||||
@@ -338,9 +354,13 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
isUpdate,
|
isUpdate,
|
||||||
isShell,
|
isShell,
|
||||||
isBackup,
|
isBackup,
|
||||||
|
isClone,
|
||||||
containerId,
|
containerId,
|
||||||
storage,
|
storage,
|
||||||
backupStorage
|
backupStorage,
|
||||||
|
cloneCount,
|
||||||
|
hostnames,
|
||||||
|
containerType
|
||||||
};
|
};
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
@@ -384,9 +404,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
const startScript = () => {
|
const startScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||||
// Generate a new execution ID for each script run
|
// Generate a new execution ID for each script run (unless propExecutionId is provided)
|
||||||
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
setExecutionId(newExecutionId);
|
if (!propExecutionId) {
|
||||||
|
setExecutionId(newExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
setIsStopped(false);
|
setIsStopped(false);
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
@@ -397,7 +419,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
server,
|
server,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
isShell,
|
isShell,
|
||||||
containerId
|
isBackup,
|
||||||
|
isClone,
|
||||||
|
containerId,
|
||||||
|
storage,
|
||||||
|
backupStorage,
|
||||||
|
cloneCount,
|
||||||
|
hostnames,
|
||||||
|
containerType
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -442,22 +442,18 @@ async function isVM(scriptId: number, containerId: string, serverId: number | nu
|
|||||||
return true; // VM config file exists
|
return true; // VM config file exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check LXC config file
|
// Check LXC config file (not needed for return value, but check for completeness)
|
||||||
let lxcConfigExists = false;
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
server as Server,
|
server as Server,
|
||||||
`test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`,
|
`test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`,
|
||||||
(data: string) => {
|
(_data: string) => {
|
||||||
if (data.includes('exists')) {
|
// Data handler not needed - just checking if file exists
|
||||||
lxcConfigExists = true;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
() => resolve(),
|
() => resolve(),
|
||||||
() => resolve()
|
() => resolve()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return false; // Always LXC since VM config doesn't exist
|
return false; // Always LXC since VM config doesn't exist
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -510,7 +506,7 @@ async function batchDetectContainerTypes(server: Server): Promise<Map<string, bo
|
|||||||
|
|
||||||
// Get containers from pct list
|
// Get containers from pct list
|
||||||
let pctOutput = '';
|
let pctOutput = '';
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
server,
|
server,
|
||||||
'pct list',
|
'pct list',
|
||||||
@@ -530,7 +526,7 @@ async function batchDetectContainerTypes(server: Server): Promise<Map<string, bo
|
|||||||
|
|
||||||
// Get VMs from qm list
|
// Get VMs from qm list
|
||||||
let qmOutput = '';
|
let qmOutput = '';
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
server,
|
server,
|
||||||
'qm list',
|
'qm list',
|
||||||
@@ -2651,5 +2647,562 @@ EOFCONFIG`;
|
|||||||
executionId: null
|
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<void>((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<void>((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<void>((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<string>();
|
||||||
|
|
||||||
|
// Check LXC containers
|
||||||
|
let lxcOutput = '';
|
||||||
|
try {
|
||||||
|
await new Promise<void>((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<void>((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<void>((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
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { getSSHExecutionService } from '../ssh-execution-service';
|
||||||
import { getStorageService } from './storageService';
|
import { getStorageService } from './storageService';
|
||||||
import { getDatabase } from '../database-prisma';
|
import { getDatabase } from '../database-prisma';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
|
||||||
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
|
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { env } from '../../env.js';
|
import { env } from '../../env.js';
|
||||||
|
|||||||
@@ -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 { getSSHExecutionService } from '../ssh-execution-service';
|
||||||
import type { Server } from '~/types/server';
|
import type { Server } from '~/types/server';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user