Compare commits
9 Commits
bugfixing_
...
feat/clone
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd17d2cbec | ||
|
|
f3d14c6746 | ||
|
|
447332e558 | ||
|
|
9bbc19ae44 | ||
|
|
5564ae0393 | ||
|
|
93d7842f6c | ||
|
|
84c02048bc | ||
|
|
66a3bb3203 | ||
|
|
0da802be42 |
7
package-lock.json
generated
7
package-lock.json
generated
@@ -64,6 +64,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.14",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.5",
|
||||
"jsdom": "^27.2.0",
|
||||
@@ -5203,9 +5204,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
|
||||
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
|
||||
"version": "2.8.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
|
||||
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-better-sqlite3": "^7.0.1",
|
||||
"@prisma/client": "^7.0.1",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
@@ -43,6 +42,7 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cron-validator": "^1.4.0",
|
||||
@@ -80,6 +80,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.14",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.5",
|
||||
"jsdom": "^27.2.0",
|
||||
@@ -88,9 +89,9 @@
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"prisma": "^7.0.1",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"tsx": "^4.19.4",
|
||||
"vitest": "^4.0.14"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
@@ -103,4 +104,4 @@
|
||||
"overrides": {
|
||||
"prismjs": "^1.30.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
426
server.js
426
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
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1630,7 +1630,7 @@ export function GeneralSettingsModal({
|
||||
https://github.com/owner/repo)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="border-border flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
Enable after adding
|
||||
@@ -1644,6 +1644,7 @@ export function GeneralSettingsModal({
|
||||
onCheckedChange={setNewRepoEnabled}
|
||||
disabled={isAddingRepo}
|
||||
label="Enable repository"
|
||||
labelPosition="left"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1739,44 +1740,7 @@ export function GeneralSettingsModal({
|
||||
{repo.enabled ? "• Enabled" : "• Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
checked={repo.enabled}
|
||||
onCheckedChange={async (enabled) => {
|
||||
setMessage(null);
|
||||
try {
|
||||
const result =
|
||||
await updateRepoMutation.mutateAsync({
|
||||
id: repo.id,
|
||||
enabled,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: `Repository ${enabled ? "enabled" : "disabled"} successfully!`,
|
||||
});
|
||||
await refetchRepositories();
|
||||
} else {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
result.error ??
|
||||
"Failed to update repository",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update repository",
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={updateRepoMutation.isPending}
|
||||
label={repo.enabled ? "Disable" : "Enable"}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!repo.is_removable) {
|
||||
@@ -1837,6 +1801,44 @@ export function GeneralSettingsModal({
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Toggle
|
||||
checked={repo.enabled}
|
||||
onCheckedChange={async (enabled) => {
|
||||
setMessage(null);
|
||||
try {
|
||||
const result =
|
||||
await updateRepoMutation.mutateAsync({
|
||||
id: repo.id,
|
||||
enabled,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: `Repository ${enabled ? "enabled" : "disabled"} successfully!`,
|
||||
});
|
||||
await refetchRepositories();
|
||||
} else {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
result.error ??
|
||||
"Failed to update repository",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update repository",
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={updateRepoMutation.isPending}
|
||||
label={repo.enabled ? "Disable" : "Enable"}
|
||||
labelPosition="left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -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<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 [editFormData, setEditFormData] = useState<{
|
||||
script_name: string;
|
||||
@@ -709,6 +724,8 @@ export function InstalledScriptsTab() {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerType = script.is_vm ? "VM" : "LXC";
|
||||
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
variant: "simple",
|
||||
@@ -718,7 +735,7 @@ export function InstalledScriptsTab() {
|
||||
setControllingScriptId(script.id);
|
||||
setLoadingModal({
|
||||
isOpen: true,
|
||||
action: `${action === "start" ? "Starting" : "Stopping"} container ${script.container_id}...`,
|
||||
action: `${action === "start" ? "Starting" : "Stopping"} ${containerType}...`,
|
||||
});
|
||||
void controlContainerMutation.mutate({ id: script.id, action });
|
||||
setConfirmationModal(null);
|
||||
@@ -923,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({
|
||||
@@ -1214,26 +1426,25 @@ export function InstalledScriptsTab() {
|
||||
<div className="mb-8" data-terminal="update">
|
||||
<Terminal
|
||||
scriptPath={
|
||||
updatingScript.isBackupOnly
|
||||
updatingScript.isClone
|
||||
? `clone-${updatingScript.containerId}`
|
||||
: updatingScript.isBackupOnly
|
||||
? `backup-${updatingScript.containerId}`
|
||||
: `update-${updatingScript.containerId}`
|
||||
}
|
||||
onClose={handleCloseUpdateTerminal}
|
||||
mode={updatingScript.server ? "ssh" : "local"}
|
||||
server={updatingScript.server}
|
||||
isUpdate={!updatingScript.isBackupOnly}
|
||||
isUpdate={!updatingScript.isBackupOnly && !updatingScript.isClone}
|
||||
isBackup={updatingScript.isBackupOnly}
|
||||
isClone={updatingScript.isClone}
|
||||
containerId={updatingScript.containerId}
|
||||
storage={
|
||||
updatingScript.isBackupOnly
|
||||
? updatingScript.backupStorage
|
||||
: undefined
|
||||
}
|
||||
backupStorage={
|
||||
!updatingScript.isBackupOnly
|
||||
? updatingScript.backupStorage
|
||||
: undefined
|
||||
}
|
||||
executionId={updatingScript.executionId}
|
||||
cloneCount={updatingScript.cloneCount}
|
||||
hostnames={updatingScript.hostnames}
|
||||
containerType={updatingScript.containerType}
|
||||
storage={updatingScript.isClone ? updatingScript.storage : (updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined)}
|
||||
backupStorage={!updatingScript.isBackupOnly && !updatingScript.isClone ? updatingScript.backupStorage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1714,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}
|
||||
@@ -2065,8 +2277,22 @@ export function InstalledScriptsTab() {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id &&
|
||||
script.execution_mode === "ssh" &&
|
||||
!script.is_vm && (
|
||||
script.execution_mode === "ssh" && (
|
||||
<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
|
||||
onClick={() =>
|
||||
handleOpenShell(script)
|
||||
@@ -2355,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 */}
|
||||
<LXCSettingsModal
|
||||
isOpen={lxcSettingsModal.isOpen}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface LoadingModalProps {
|
||||
|
||||
export function LoadingModal({
|
||||
isOpen,
|
||||
action: _action,
|
||||
action,
|
||||
logs = [],
|
||||
isComplete = false,
|
||||
title,
|
||||
@@ -64,6 +64,11 @@ export function LoadingModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action text - displayed prominently */}
|
||||
{action && (
|
||||
<p className="text-foreground text-base font-medium">{action}</p>
|
||||
)}
|
||||
|
||||
{/* Static title text */}
|
||||
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ interface ScriptInstallationCardProps {
|
||||
onCancel: () => 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
|
||||
</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
|
||||
onClick={onShell}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
|
||||
@@ -13,6 +13,10 @@ interface StorageSelectionModalProps {
|
||||
storages: Storage[];
|
||||
isLoading: boolean;
|
||||
onRefresh: () => 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<Storage | null>(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 (
|
||||
<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 gap-3">
|
||||
<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>
|
||||
<Button
|
||||
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>
|
||||
<p className="text-muted-foreground">Loading storages...</p>
|
||||
</div>
|
||||
) : backupStorages.length === 0 ? (
|
||||
) : filteredStorages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<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>
|
||||
@@ -87,12 +95,12 @@ export function StorageSelectionModal({
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* Storage List */}
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
|
||||
{backupStorages.map((storage) => (
|
||||
{filteredStorages.map((storage) => (
|
||||
<div
|
||||
key={storage.name}
|
||||
onClick={() => setSelectedStorage(storage)}
|
||||
@@ -106,9 +114,11 @@ export function StorageSelectionModal({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<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">
|
||||
Backup
|
||||
</span>
|
||||
{showBackupTag && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
|
||||
Backup
|
||||
</span>
|
||||
)}
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
||||
{storage.type}
|
||||
</span>
|
||||
|
||||
@@ -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<any>(null);
|
||||
const wsRef = useRef<WebSocket | 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 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) {
|
||||
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
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,30 +6,40 @@ export interface ToggleProps
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
label?: string;
|
||||
labelPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
||||
({ className, checked, onCheckedChange, label, ...props }, ref) => {
|
||||
({ className, checked, onCheckedChange, label, labelPosition = 'right', ...props }, ref) => {
|
||||
const toggleSwitch = (
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn(
|
||||
"w-11 h-6 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 rounded-full peer after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out after:shadow-md transition-colors duration-300 ease-in-out border-2 border-gray-300 dark:border-gray-600",
|
||||
checked
|
||||
? "bg-blue-500 dark:bg-blue-600 after:translate-x-full"
|
||||
: "bg-gray-300 dark:bg-gray-700",
|
||||
className
|
||||
)} />
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn(
|
||||
"w-11 h-6 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 rounded-full peer after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out after:shadow-md transition-colors duration-300 ease-in-out border-2 border-gray-300 dark:border-gray-600",
|
||||
checked
|
||||
? "bg-blue-500 dark:bg-blue-600 after:translate-x-full"
|
||||
: "bg-gray-300 dark:bg-gray-700",
|
||||
className
|
||||
)} />
|
||||
</label>
|
||||
{label && (
|
||||
{label && labelPosition === 'left' && (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{toggleSwitch}
|
||||
{label && labelPosition === 'right' && (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -442,30 +442,130 @@ 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<void>((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()
|
||||
);
|
||||
});
|
||||
|
||||
// If LXC config exists, it's an LXC container
|
||||
return !lxcConfigExists; // Return true if it's a VM (neither config exists defaults to false/LXC)
|
||||
|
||||
return false; // Always LXC since VM config doesn't exist
|
||||
} catch (error) {
|
||||
console.error('Error determining container type:', error);
|
||||
return false; // Default to LXC on error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to batch detect container types for all containers on a server
|
||||
// Returns a Map of container_id -> isVM (true for VM, false for LXC)
|
||||
async function batchDetectContainerTypes(server: Server): Promise<Map<string, boolean>> {
|
||||
const containerTypeMap = new Map<string, boolean>();
|
||||
|
||||
try {
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
const connectionTest = await sshService.testSSHConnection(server);
|
||||
if (!(connectionTest as any).success) {
|
||||
console.warn(`SSH connection failed for server ${server.name}, skipping batch detection`);
|
||||
return containerTypeMap; // Return empty map if SSH fails
|
||||
}
|
||||
|
||||
// Helper function to parse list output and extract IDs
|
||||
const parseListOutput = (output: string): string[] => {
|
||||
const ids: string[] = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines
|
||||
if (line.includes('VMID') || line.includes('CTID')) continue;
|
||||
|
||||
// Extract first column (ID)
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 0) {
|
||||
const id = parts[0]?.trim();
|
||||
// Validate ID format (3-4 digits typically)
|
||||
if (id && /^\d{3,4}$/.test(id)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
// Get containers from pct list
|
||||
let pctOutput = '';
|
||||
await new Promise<void>((resolve) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server,
|
||||
'pct list',
|
||||
(data: string) => {
|
||||
pctOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`pct list error for server ${server.name}:`, error);
|
||||
// Don't reject, just continue - might be no containers
|
||||
resolve();
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Get VMs from qm list
|
||||
let qmOutput = '';
|
||||
await new Promise<void>((resolve) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server,
|
||||
'qm list',
|
||||
(data: string) => {
|
||||
qmOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`qm list error for server ${server.name}:`, error);
|
||||
// Don't reject, just continue - might be no VMs
|
||||
resolve();
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Parse IDs from both lists
|
||||
const containerIds = parseListOutput(pctOutput);
|
||||
const vmIds = parseListOutput(qmOutput);
|
||||
|
||||
// Mark all LXC containers as false (not VM)
|
||||
for (const id of containerIds) {
|
||||
containerTypeMap.set(id, false);
|
||||
}
|
||||
|
||||
// Mark all VMs as true (is VM)
|
||||
for (const id of vmIds) {
|
||||
containerTypeMap.set(id, true);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error in batchDetectContainerTypes for server ${server.name}:`, error);
|
||||
// Return empty map on error - individual checks will fall back to isVM()
|
||||
}
|
||||
|
||||
return containerTypeMap;
|
||||
}
|
||||
|
||||
|
||||
export const installedScriptsRouter = createTRPCRouter({
|
||||
// Get all installed scripts
|
||||
@@ -475,13 +575,52 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const db = getDatabase();
|
||||
const scripts = await db.getAllInstalledScripts();
|
||||
|
||||
// Group scripts by server_id for batch detection
|
||||
const scriptsByServer = new Map<number, any[]>();
|
||||
const serversMap = new Map<number, Server>();
|
||||
|
||||
for (const script of scripts) {
|
||||
if (script.server_id && script.server) {
|
||||
if (!scriptsByServer.has(script.server_id)) {
|
||||
scriptsByServer.set(script.server_id, []);
|
||||
serversMap.set(script.server_id, script.server as Server);
|
||||
}
|
||||
scriptsByServer.get(script.server_id)!.push(script);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch detect container types for each server
|
||||
const containerTypeMap = new Map<string, boolean>();
|
||||
const batchDetectionPromises = Array.from(serversMap.entries()).map(async ([serverId, server]) => {
|
||||
try {
|
||||
const serverTypeMap = await batchDetectContainerTypes(server);
|
||||
// Merge into main map with server-specific prefix to avoid collisions
|
||||
// Actually, container IDs are unique across the cluster, so we can use them directly
|
||||
for (const [containerId, isVM] of serverTypeMap.entries()) {
|
||||
containerTypeMap.set(containerId, isVM);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error batch detecting types for server ${serverId}:`, error);
|
||||
// Continue with other servers
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(batchDetectionPromises);
|
||||
|
||||
// Transform scripts to flatten server data for frontend compatibility
|
||||
|
||||
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
|
||||
// Determine if it's a VM or LXC
|
||||
const transformedScripts = scripts.map((script: any) => {
|
||||
// Determine if it's a VM or LXC from batch detection map, fall back to isVM() if not found
|
||||
let is_vm = false;
|
||||
if (script.container_id && script.server_id) {
|
||||
is_vm = await isVM(script.id, script.container_id, script.server_id);
|
||||
// First check if we have it in the batch detection map
|
||||
if (containerTypeMap.has(script.container_id)) {
|
||||
is_vm = containerTypeMap.get(script.container_id) ?? false;
|
||||
} else {
|
||||
// Fall back to checking LXCConfig in database (fast, no SSH needed)
|
||||
// If LXCConfig exists, it's an LXC container
|
||||
const hasLXCConfig = script.lxc_config !== null && script.lxc_config !== undefined;
|
||||
is_vm = !hasLXCConfig; // If no LXCConfig, might be VM, but default to false for safety
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -498,7 +637,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
is_vm,
|
||||
server: undefined // Remove nested server object
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -522,13 +661,31 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const db = getDatabase();
|
||||
const scripts = await db.getInstalledScriptsByServer(input.serverId);
|
||||
|
||||
// Batch detect container types for this server
|
||||
let containerTypeMap = new Map<string, boolean>();
|
||||
if (scripts.length > 0 && scripts[0]?.server) {
|
||||
try {
|
||||
containerTypeMap = await batchDetectContainerTypes(scripts[0].server as Server);
|
||||
} catch (error) {
|
||||
console.error(`Error batch detecting types for server ${input.serverId}:`, error);
|
||||
// Continue with empty map, will fall back to LXCConfig check
|
||||
}
|
||||
}
|
||||
|
||||
// Transform scripts to flatten server data for frontend compatibility
|
||||
|
||||
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
|
||||
// Determine if it's a VM or LXC
|
||||
const transformedScripts = scripts.map((script: any) => {
|
||||
// Determine if it's a VM or LXC from batch detection map, fall back to LXCConfig check if not found
|
||||
let is_vm = false;
|
||||
if (script.container_id && script.server_id) {
|
||||
is_vm = await isVM(script.id, script.container_id, script.server_id);
|
||||
// First check if we have it in the batch detection map
|
||||
if (containerTypeMap.has(script.container_id)) {
|
||||
is_vm = containerTypeMap.get(script.container_id) ?? false;
|
||||
} else {
|
||||
// Fall back to checking LXCConfig in database (fast, no SSH needed)
|
||||
// If LXCConfig exists, it's an LXC container
|
||||
const hasLXCConfig = script.lxc_config !== null && script.lxc_config !== undefined;
|
||||
is_vm = !hasLXCConfig; // If no LXCConfig, might be VM, but default to false for safety
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -545,7 +702,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
is_vm,
|
||||
server: undefined // Remove nested server object
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -2490,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<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
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -281,7 +281,8 @@ class DatabaseServicePrisma {
|
||||
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
|
||||
const result = await prisma.installedScript.findMany({
|
||||
include: {
|
||||
server: true
|
||||
server: true,
|
||||
lxc_config: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
@@ -302,7 +303,8 @@ class DatabaseServicePrisma {
|
||||
const result = await prisma.installedScript.findMany({
|
||||
where: { server_id },
|
||||
include: {
|
||||
server: true
|
||||
server: true,
|
||||
lxc_config: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user