Compare commits
13 Commits
bugfixing_
...
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6d231e708 | ||
|
|
7c4683012f | ||
|
|
cfcdc1e342 | ||
|
|
07cf03a408 | ||
|
|
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",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/coverage-v8": "^4.0.14",
|
"@vitest/coverage-v8": "^4.0.14",
|
||||||
"@vitest/ui": "^4.0.14",
|
"@vitest/ui": "^4.0.14",
|
||||||
|
"baseline-browser-mapping": "^2.8.32",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "^16.0.5",
|
"eslint-config-next": "^16.0.5",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.2.0",
|
||||||
@@ -5203,9 +5204,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.31",
|
"version": "2.8.32",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
|
||||||
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
|
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-better-sqlite3": "^7.0.1",
|
"@prisma/adapter-better-sqlite3": "^7.0.1",
|
||||||
"@prisma/client": "^7.0.1",
|
"@prisma/client": "^7.0.1",
|
||||||
"better-sqlite3": "^12.4.6",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.8",
|
||||||
@@ -43,6 +42,7 @@
|
|||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.4.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cron-validator": "^1.4.0",
|
"cron-validator": "^1.4.0",
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/coverage-v8": "^4.0.14",
|
"@vitest/coverage-v8": "^4.0.14",
|
||||||
"@vitest/ui": "^4.0.14",
|
"@vitest/ui": "^4.0.14",
|
||||||
|
"baseline-browser-mapping": "^2.8.32",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "^16.0.5",
|
"eslint-config-next": "^16.0.5",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.2.0",
|
||||||
@@ -88,9 +89,9 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"prisma": "^7.0.1",
|
"prisma": "^7.0.1",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"tsx": "^4.19.4",
|
|
||||||
"vitest": "^4.0.14"
|
"vitest": "^4.0.14"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
@@ -103,4 +104,4 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"prismjs": "^1.30.0"
|
"prismjs": "^1.30.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1630,7 +1630,7 @@ export function GeneralSettingsModal({
|
|||||||
https://github.com/owner/repo)
|
https://github.com/owner/repo)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p className="text-foreground text-sm font-medium">
|
<p className="text-foreground text-sm font-medium">
|
||||||
Enable after adding
|
Enable after adding
|
||||||
@@ -1644,6 +1644,7 @@ export function GeneralSettingsModal({
|
|||||||
onCheckedChange={setNewRepoEnabled}
|
onCheckedChange={setNewRepoEnabled}
|
||||||
disabled={isAddingRepo}
|
disabled={isAddingRepo}
|
||||||
label="Enable repository"
|
label="Enable repository"
|
||||||
|
labelPosition="left"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -1739,44 +1740,7 @@ export function GeneralSettingsModal({
|
|||||||
{repo.enabled ? "• Enabled" : "• Disabled"}
|
{repo.enabled ? "• Enabled" : "• Disabled"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<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"}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!repo.is_removable) {
|
if (!repo.is_removable) {
|
||||||
@@ -1837,6 +1801,44 @@ export function GeneralSettingsModal({
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
||||||
</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;
|
||||||
@@ -709,6 +724,8 @@ export function InstalledScriptsTab() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const containerType = script.is_vm ? "VM" : "LXC";
|
||||||
|
|
||||||
setConfirmationModal({
|
setConfirmationModal({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
variant: "simple",
|
variant: "simple",
|
||||||
@@ -718,7 +735,7 @@ export function InstalledScriptsTab() {
|
|||||||
setControllingScriptId(script.id);
|
setControllingScriptId(script.id);
|
||||||
setLoadingModal({
|
setLoadingModal({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
action: `${action === "start" ? "Starting" : "Stopping"} container ${script.container_id}...`,
|
action: `${action === "start" ? "Starting" : "Stopping"} ${containerType}...`,
|
||||||
});
|
});
|
||||||
void controlContainerMutation.mutate({ id: script.id, action });
|
void controlContainerMutation.mutate({ id: script.id, action });
|
||||||
setConfirmationModal(null);
|
setConfirmationModal(null);
|
||||||
@@ -923,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({
|
||||||
@@ -1214,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>
|
||||||
)}
|
)}
|
||||||
@@ -1714,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}
|
||||||
@@ -2065,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)
|
||||||
@@ -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 */}
|
{/* LXC Settings Modal */}
|
||||||
<LXCSettingsModal
|
<LXCSettingsModal
|
||||||
isOpen={lxcSettingsModal.isOpen}
|
isOpen={lxcSettingsModal.isOpen}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface LoadingModalProps {
|
|||||||
|
|
||||||
export function LoadingModal({
|
export function LoadingModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
action: _action,
|
action,
|
||||||
logs = [],
|
logs = [],
|
||||||
isComplete = false,
|
isComplete = false,
|
||||||
title,
|
title,
|
||||||
@@ -64,6 +64,11 @@ export function LoadingModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action text - displayed prominently */}
|
||||||
|
{action && (
|
||||||
|
<p className="text-foreground text-base font-medium">{action}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Static title text */}
|
{/* Static title text */}
|
||||||
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,30 +6,40 @@ export interface ToggleProps
|
|||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
labelPosition?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
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 (
|
return (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
{label && labelPosition === 'left' && (
|
||||||
<input
|
<span className="text-sm font-medium text-foreground">
|
||||||
type="checkbox"
|
{label}
|
||||||
className="sr-only"
|
</span>
|
||||||
checked={checked}
|
)}
|
||||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
{toggleSwitch}
|
||||||
ref={ref}
|
{label && labelPosition === 'right' && (
|
||||||
{...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 && (
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -442,30 +442,130 @@ 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()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// If LXC config exists, it's an LXC container
|
return false; // Always LXC since VM config doesn't exist
|
||||||
return !lxcConfigExists; // Return true if it's a VM (neither config exists defaults to false/LXC)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error determining container type:', error);
|
console.error('Error determining container type:', error);
|
||||||
return false; // Default to LXC on 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({
|
export const installedScriptsRouter = createTRPCRouter({
|
||||||
// Get all installed scripts
|
// Get all installed scripts
|
||||||
@@ -475,13 +575,52 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const scripts = await db.getAllInstalledScripts();
|
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
|
// Transform scripts to flatten server data for frontend compatibility
|
||||||
|
const transformedScripts = scripts.map((script: any) => {
|
||||||
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
|
// Determine if it's a VM or LXC from batch detection map, fall back to isVM() if not found
|
||||||
// Determine if it's a VM or LXC
|
|
||||||
let is_vm = false;
|
let is_vm = false;
|
||||||
if (script.container_id && script.server_id) {
|
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 {
|
return {
|
||||||
@@ -498,7 +637,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
is_vm,
|
is_vm,
|
||||||
server: undefined // Remove nested server object
|
server: undefined // Remove nested server object
|
||||||
};
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -522,13 +661,31 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const scripts = await db.getInstalledScriptsByServer(input.serverId);
|
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
|
// Transform scripts to flatten server data for frontend compatibility
|
||||||
|
const transformedScripts = scripts.map((script: any) => {
|
||||||
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
|
// Determine if it's a VM or LXC from batch detection map, fall back to LXCConfig check if not found
|
||||||
// Determine if it's a VM or LXC
|
|
||||||
let is_vm = false;
|
let is_vm = false;
|
||||||
if (script.container_id && script.server_id) {
|
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 {
|
return {
|
||||||
@@ -545,7 +702,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
is_vm,
|
is_vm,
|
||||||
server: undefined // Remove nested server object
|
server: undefined // Remove nested server object
|
||||||
};
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -2490,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
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -281,7 +281,8 @@ class DatabaseServicePrisma {
|
|||||||
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
|
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
|
||||||
const result = await prisma.installedScript.findMany({
|
const result = await prisma.installedScript.findMany({
|
||||||
include: {
|
include: {
|
||||||
server: true
|
server: true,
|
||||||
|
lxc_config: true
|
||||||
},
|
},
|
||||||
orderBy: { installation_date: 'desc' }
|
orderBy: { installation_date: 'desc' }
|
||||||
});
|
});
|
||||||
@@ -302,7 +303,8 @@ class DatabaseServicePrisma {
|
|||||||
const result = await prisma.installedScript.findMany({
|
const result = await prisma.installedScript.findMany({
|
||||||
where: { server_id },
|
where: { server_id },
|
||||||
include: {
|
include: {
|
||||||
server: true
|
server: true,
|
||||||
|
lxc_config: true
|
||||||
},
|
},
|
||||||
orderBy: { installation_date: 'desc' }
|
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 { 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