Compare commits

...

8 Commits

Author SHA1 Message Date
Michel Roegl-Brunner
dd17d2cbec feat: Add VM/LXC cloning functionality
- Add CloneCountInputModal component for specifying clone count
- Implement clone handlers and state management in InstalledScriptsTab
- Add clone menu item to ScriptInstallationCard
- Extend StorageSelectionModal to support clone storage selection (rootdir only)
- Add clone terminal support to Terminal component
- Implement startSSHCloneExecution in server.js with sequential ID retrieval
- Add clone-related API endpoints (getClusterNextId, getContainerType, getCloneStorages, generateCloneHostnames, executeClone, addClonedContainerToDatabase)
- Integrate with VM/LXC detection from main branch
- Fix storage fetching to use correct serverId parameter
- Fix clone execution to pass storage parameter correctly
- Remove unused eslint-disable comments
2025-11-29 16:53:58 +01:00
Michel Roegl-Brunner
f3d14c6746 Merge pull request #359 from community-scripts/fix/352
fix: align toggle switches in repository settings
2025-11-29 16:14:07 +01:00
Michel Roegl-Brunner
447332e558 fix: align toggle switches in repository settings
- Remove fixed label width from Toggle component
- Move delete button to left of toggle switches
- Add matching border/padding to 'Enable after adding' section to align with repository items
- Ensure all toggles have consistent right-side alignment
2025-11-29 16:12:20 +01:00
Michel Roegl-Brunner
9bbc19ae44 Merge pull request #358 from community-scripts/fix/357_356
fix: Add dynamic text to container control loading modal
2025-11-29 16:00:49 +01:00
Michel Roegl-Brunner
5564ae0393 fix: add dynamic text to container control loading modal
- Update LoadingModal to display action text (Starting/Stopping LXC/VM)
- Update handleStartStop to include container type (LXC/VM) in action text
- Show clear feedback when starting or stopping containers
2025-11-29 15:58:30 +01:00
Michel Roegl-Brunner
93d7842f6c feat: implement batch container type detection for performance optimization
- Add batchDetectContainerTypes() helper function that uses pct list and qm list to detect all container types in 2 SSH calls per server
- Update getAllInstalledScripts to use batch detection instead of individual isVM() calls per script
- Update getInstalledScriptsByServer to use batch detection for single server
- Update database queries to include lxc_config relation for fallback detection
- Fix isVM() function to properly default to LXC when VM config doesn't exist
- Significantly improves performance: reduces from N SSH calls per script to 2 SSH calls per server
2025-11-29 15:55:43 +01:00
Michel Roegl-Brunner
84c02048bc Fix a false detection as a VM when it is a LXC 2025-11-29 15:41:49 +01:00
github-actions[bot]
66a3bb3203 chore: add VERSION v0.5.0 (#355)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-28 14:04:05 +00:00
17 changed files with 1725 additions and 129 deletions

View File

@@ -1 +1 @@
0.4.13
0.5.0

7
package-lock.json generated
View File

@@ -64,6 +64,7 @@
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.5",
"jsdom": "^27.2.0",
@@ -5203,9 +5204,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -27,7 +27,6 @@
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.0.1",
"@prisma/client": "^7.0.1",
"better-sqlite3": "^12.4.6",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@t3-oss/env-nextjs": "^0.13.8",
@@ -43,6 +42,7 @@
"@xterm/xterm": "^5.5.0",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cron-validator": "^1.4.0",
@@ -80,6 +80,7 @@
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.5",
"jsdom": "^27.2.0",
@@ -88,9 +89,9 @@
"prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^7.0.1",
"tailwindcss": "^4.1.17",
"tsx": "^4.19.4",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"tsx": "^4.19.4",
"vitest": "^4.0.14"
},
"ct3aMetadata": {
@@ -103,4 +104,4 @@
"overrides": {
"prismjs": "^1.30.0"
}
}
}

426
server.js
View File

@@ -75,9 +75,13 @@ const handle = app.getRequestHandler();
* @property {boolean} [isUpdate]
* @property {boolean} [isShell]
* @property {boolean} [isBackup]
* @property {boolean} [isClone]
* @property {string} [containerId]
* @property {string} [storage]
* @property {string} [backupStorage]
* @property {number} [cloneCount]
* @property {string[]} [hostnames]
* @property {'lxc'|'vm'} [containerType]
*/
class ScriptExecutionHandler {
@@ -295,12 +299,14 @@ class ScriptExecutionHandler {
* @param {WebSocketMessage} message
*/
async handleMessage(ws, message) {
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message;
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, isClone, containerId, storage, backupStorage, cloneCount, hostnames, containerType } = message;
switch (action) {
case 'start':
if (scriptPath && executionId) {
if (isBackup && containerId && storage) {
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) {
await this.startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames);
} else if (isBackup && containerId && storage) {
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
} else if (isUpdate && containerId) {
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
@@ -832,6 +838,422 @@ class ScriptExecutionHandler {
});
}
/**
* Start SSH clone execution
* Gets next IDs sequentially: get next ID → clone → get next ID → clone, etc.
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {string} storage
* @param {ServerInfo} server
* @param {'lxc'|'vm'} containerType
* @param {number} cloneCount
* @param {string[]} hostnames
*/
async startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames) {
const sshService = getSSHExecutionService();
this.sendMessage(ws, {
type: 'start',
data: `Starting clone operation: Creating ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}...`,
timestamp: Date.now()
});
try {
// Step 1: Stop source container/VM
this.sendMessage(ws, {
type: 'output',
data: `\n[Step 1/${4 + cloneCount}] Stopping source ${containerType.toUpperCase()} ${containerId}...\n`,
timestamp: Date.now()
});
const stopCommand = containerType === 'lxc' ? `pct stop ${containerId}` : `qm stop ${containerId}`;
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
sshService.executeCommand(
server,
stopCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, {
type: 'output',
data: `\n[Step 1/${4 + cloneCount}] Source ${containerType.toUpperCase()} stopped successfully.\n`,
timestamp: Date.now()
});
resolve();
} else {
// Continue even if stop fails (might already be stopped)
this.sendMessage(ws, {
type: 'output',
data: `\n[Step 1/${4 + cloneCount}] Stop command completed with exit code ${code} (container may already be stopped).\n`,
timestamp: Date.now()
});
resolve();
}
}
);
}));
// Step 2: Clone for each clone count (get next ID sequentially before each clone)
const clonedIds = [];
for (let i = 0; i < cloneCount; i++) {
const cloneNumber = i + 1;
const hostname = hostnames[i];
// Get next ID for this clone
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Getting next available ID for clone ${cloneNumber}...\n`,
timestamp: Date.now()
});
let nextId = '';
try {
let output = '';
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
sshService.executeCommand(
server,
'pvesh get /cluster/nextid',
/** @param {string} data */
(data) => {
output += data;
},
/** @param {string} error */
(error) => {
reject(new Error(`Failed to get next ID: ${error}`));
},
/** @param {number} exitCode */
(exitCode) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`pvesh command failed with exit code ${exitCode}`));
}
}
);
}));
nextId = output.trim();
if (!nextId || !/^\d+$/.test(nextId)) {
throw new Error('Invalid next ID received');
}
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Got next ID: ${nextId}\n`,
timestamp: Date.now()
});
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Failed to get next ID: ${error instanceof Error ? error.message : String(error)}\n`,
timestamp: Date.now()
});
throw error;
}
clonedIds.push(nextId);
// Clone the container/VM
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Cloning ${containerType.toUpperCase()} ${containerId} to ${nextId} with hostname ${hostname}...\n`,
timestamp: Date.now()
});
const cloneCommand = containerType === 'lxc'
? `pct clone ${containerId} ${nextId} --hostname ${hostname} --storage ${storage}`
: `qm clone ${containerId} ${nextId} --name ${hostname} --storage ${storage}`;
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
sshService.executeCommand(
server,
cloneCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Clone ${cloneNumber} created successfully.\n`,
timestamp: Date.now()
});
resolve();
} else {
this.sendMessage(ws, {
type: 'error',
data: `\nClone ${cloneNumber} failed with exit code: ${code}\n`,
timestamp: Date.now()
});
reject(new Error(`Clone ${cloneNumber} failed with exit code ${code}`));
}
}
);
}));
}
// Step 3: Start source container/VM
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Starting source ${containerType.toUpperCase()} ${containerId}...\n`,
timestamp: Date.now()
});
const startSourceCommand = containerType === 'lxc' ? `pct start ${containerId}` : `qm start ${containerId}`;
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
sshService.executeCommand(
server,
startSourceCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Source ${containerType.toUpperCase()} started successfully.\n`,
timestamp: Date.now()
});
} else {
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Start command completed with exit code ${code}.\n`,
timestamp: Date.now()
});
}
resolve();
}
);
}));
// Step 4: Start target containers/VMs
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 2}/${4 + cloneCount}] Starting cloned ${containerType.toUpperCase()}(s)...\n`,
timestamp: Date.now()
});
for (let i = 0; i < cloneCount; i++) {
const cloneNumber = i + 1;
const nextId = clonedIds[i];
const startTargetCommand = containerType === 'lxc' ? `pct start ${nextId}` : `qm start ${nextId}`;
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
sshService.executeCommand(
server,
startTargetCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, {
type: 'output',
data: `\nClone ${cloneNumber} (ID: ${nextId}) started successfully.\n`,
timestamp: Date.now()
});
} else {
this.sendMessage(ws, {
type: 'output',
data: `\nClone ${cloneNumber} (ID: ${nextId}) start completed with exit code ${code}.\n`,
timestamp: Date.now()
});
}
resolve();
}
);
}));
}
// Step 5: Add to database
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 3}/${4 + cloneCount}] Adding cloned ${containerType.toUpperCase()}(s) to database...\n`,
timestamp: Date.now()
});
for (let i = 0; i < cloneCount; i++) {
const nextId = clonedIds[i];
const hostname = hostnames[i];
try {
// Read config file to get hostname/name
const configPath = containerType === 'lxc'
? `/etc/pve/lxc/${nextId}.conf`
: `/etc/pve/qemu-server/${nextId}.conf`;
let configContent = '';
await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => {
sshService.executeCommand(
server,
`cat "${configPath}" 2>/dev/null || echo ""`,
/** @param {string} data */
(data) => {
configContent += data;
},
() => resolve(),
() => resolve()
);
}));
// Parse config for hostname/name
let finalHostname = hostname;
if (configContent.trim()) {
const lines = configContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (containerType === 'lxc' && trimmed.startsWith('hostname:')) {
finalHostname = trimmed.substring(9).trim();
break;
} else if (containerType === 'vm' && trimmed.startsWith('name:')) {
finalHostname = trimmed.substring(5).trim();
break;
}
}
}
if (!finalHostname) {
finalHostname = `${containerType}-${nextId}`;
}
// Create installed script record
const script = await this.db.createInstalledScript({
script_name: finalHostname,
script_path: `cloned/${finalHostname}`,
container_id: nextId,
server_id: server.id,
execution_mode: 'ssh',
status: 'success',
output_log: `Cloned ${containerType.toUpperCase()}`
});
// For LXC, store config in database
if (containerType === 'lxc' && configContent.trim()) {
// Simple config parser
/** @type {any} */
const configData = {};
const lines = configContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, ...valueParts] = trimmed.split(':');
const value = valueParts.join(':').trim();
if (key === 'hostname') configData.hostname = value;
else if (key === 'arch') configData.arch = value;
else if (key === 'cores') configData.cores = parseInt(value) || null;
else if (key === 'memory') configData.memory = parseInt(value) || null;
else if (key === 'swap') configData.swap = parseInt(value) || null;
else if (key === 'onboot') configData.onboot = parseInt(value) || null;
else if (key === 'ostype') configData.ostype = value;
else if (key === 'unprivileged') configData.unprivileged = parseInt(value) || null;
else if (key === 'tags') configData.tags = value;
else if (key === 'rootfs') {
const match = value.match(/^([^:]+):([^,]+)/);
if (match) {
configData.rootfs_storage = match[1];
const sizeMatch = value.match(/size=([^,]+)/);
if (sizeMatch) {
configData.rootfs_size = sizeMatch[1];
}
}
}
}
await this.db.createLXCConfig(script.id, configData);
}
this.sendMessage(ws, {
type: 'output',
data: `\nClone ${i + 1} (ID: ${nextId}, Hostname: ${finalHostname}) added to database successfully.\n`,
timestamp: Date.now()
});
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `\nError adding clone ${i + 1} (ID: ${nextId}) to database: ${error instanceof Error ? error.message : String(error)}\n`,
timestamp: Date.now()
});
}
}
this.sendMessage(ws, {
type: 'output',
data: `\n\n[Clone operation completed successfully!]\nCreated ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}.\n`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `\n\n[Clone operation failed!]\nError: ${error instanceof Error ? error.message : String(error)}\n`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
}
}
/**
* Start update execution (pct enter + update command)
* @param {ExtendedWebSocket} ws

View 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>
);
}

View File

@@ -1630,7 +1630,7 @@ export function GeneralSettingsModal({
https://github.com/owner/repo)
</p>
</div>
<div className="flex items-center justify-between">
<div className="border-border flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<p className="text-foreground text-sm font-medium">
Enable after adding
@@ -1644,6 +1644,7 @@ export function GeneralSettingsModal({
onCheckedChange={setNewRepoEnabled}
disabled={isAddingRepo}
label="Enable repository"
labelPosition="left"
/>
</div>
<Button
@@ -1739,44 +1740,7 @@ export function GeneralSettingsModal({
{repo.enabled ? "• Enabled" : "• Disabled"}
</p>
</div>
<div className="flex items-center gap-2">
<Toggle
checked={repo.enabled}
onCheckedChange={async (enabled) => {
setMessage(null);
try {
const result =
await updateRepoMutation.mutateAsync({
id: repo.id,
enabled,
});
if (result.success) {
setMessage({
type: "success",
text: `Repository ${enabled ? "enabled" : "disabled"} successfully!`,
});
await refetchRepositories();
} else {
setMessage({
type: "error",
text:
result.error ??
"Failed to update repository",
});
}
} catch (error) {
setMessage({
type: "error",
text:
error instanceof Error
? error.message
: "Failed to update repository",
});
}
}}
disabled={updateRepoMutation.isPending}
label={repo.enabled ? "Disable" : "Enable"}
/>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
onClick={async () => {
if (!repo.is_removable) {
@@ -1837,6 +1801,44 @@ export function GeneralSettingsModal({
>
<Trash2 className="h-4 w-4" />
</Button>
<Toggle
checked={repo.enabled}
onCheckedChange={async (enabled) => {
setMessage(null);
try {
const result =
await updateRepoMutation.mutateAsync({
id: repo.id,
enabled,
});
if (result.success) {
setMessage({
type: "success",
text: `Repository ${enabled ? "enabled" : "disabled"} successfully!`,
});
await refetchRepositories();
} else {
setMessage({
type: "error",
text:
result.error ??
"Failed to update repository",
});
}
} catch (error) {
setMessage({
type: "error",
text:
error instanceof Error
? error.message
: "Failed to update repository",
});
}
}}
disabled={updateRepoMutation.isPending}
label={repo.enabled ? "Disable" : "Enable"}
labelPosition="left"
/>
</div>
</div>
),

View File

@@ -12,6 +12,7 @@ import { LoadingModal } from "./LoadingModal";
import { LXCSettingsModal } from "./LXCSettingsModal";
import { StorageSelectionModal } from "./StorageSelectionModal";
import { BackupWarningModal } from "./BackupWarningModal";
import { CloneCountInputModal } from "./CloneCountInputModal";
import type { Storage } from "~/server/services/storageService";
import { getContrastColor } from "../../lib/colorUtils";
import {
@@ -68,6 +69,12 @@ export function InstalledScriptsTab() {
server?: any;
backupStorage?: string;
isBackupOnly?: boolean;
isClone?: boolean;
executionId?: string;
cloneCount?: number;
hostnames?: string[];
containerType?: 'lxc' | 'vm';
storage?: string;
} | null>(null);
const [openingShell, setOpeningShell] = useState<{
id: number;
@@ -82,6 +89,14 @@ export function InstalledScriptsTab() {
const [isLoadingStorages, setIsLoadingStorages] = useState(false);
const [showBackupWarning, setShowBackupWarning] = useState(false);
const [isPreUpdateBackup, setIsPreUpdateBackup] = useState(false); // Track if storage selection is for pre-update backup
const [pendingCloneScript, setPendingCloneScript] = useState<InstalledScript | null>(null);
const [cloneStorages, setCloneStorages] = useState<Storage[]>([]);
const [isLoadingCloneStorages, setIsLoadingCloneStorages] = useState(false);
const [showCloneStorageSelection, setShowCloneStorageSelection] = useState(false);
const [showCloneCountInput, setShowCloneCountInput] = useState(false);
const [cloneContainerType, setCloneContainerType] = useState<'lxc' | 'vm' | null>(null);
const [selectedCloneStorage, setSelectedCloneStorage] = useState<Storage | null>(null);
// cloneCount is passed as parameter to handleCloneCountSubmit, no need for state
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{
script_name: string;
@@ -709,6 +724,8 @@ export function InstalledScriptsTab() {
return;
}
const containerType = script.is_vm ? "VM" : "LXC";
setConfirmationModal({
isOpen: true,
variant: "simple",
@@ -718,7 +735,7 @@ export function InstalledScriptsTab() {
setControllingScriptId(script.id);
setLoadingModal({
isOpen: true,
action: `${action === "start" ? "Starting" : "Stopping"} container ${script.container_id}...`,
action: `${action === "start" ? "Starting" : "Stopping"} ${containerType}...`,
});
void controlContainerMutation.mutate({ id: script.id, action });
setConfirmationModal(null);
@@ -923,6 +940,201 @@ export function InstalledScriptsTab() {
setShowStorageSelection(true);
};
// Clone queries
const getContainerHostnameQuery = api.installedScripts.getContainerHostname.useQuery(
{
containerId: pendingCloneScript?.container_id ?? '',
serverId: pendingCloneScript?.server_id ?? 0,
containerType: cloneContainerType ?? 'lxc'
},
{ enabled: false }
);
const executeCloneMutation = api.installedScripts.executeClone.useMutation();
const utils = api.useUtils();
const fetchCloneStorages = async (serverId: number, _forceRefresh = false) => {
setIsLoadingCloneStorages(true);
try {
// Use utils.fetch to call with the correct serverId
const result = await utils.installedScripts.getCloneStorages.fetch({
serverId,
forceRefresh: _forceRefresh
});
if (result?.success && result.storages) {
setCloneStorages(result.storages as Storage[]);
} else {
setErrorModal({
isOpen: true,
title: 'Failed to Fetch Storages',
message: result?.error ?? 'Unknown error occurred',
type: 'error'
});
}
} catch (error) {
setErrorModal({
isOpen: true,
title: 'Failed to Fetch Storages',
message: error instanceof Error ? error.message : 'Unknown error occurred',
type: 'error'
});
} finally {
setIsLoadingCloneStorages(false);
}
};
const handleCloneScript = async (script: InstalledScript) => {
if (!script.container_id) {
setErrorModal({
isOpen: true,
title: 'Clone Failed',
message: 'No Container ID available for this script',
details: 'This script does not have a valid container ID and cannot be cloned.'
});
return;
}
if (!script.server_id) {
setErrorModal({
isOpen: true,
title: 'Clone Not Available',
message: 'Clone is only available for SSH scripts with a configured server.',
type: 'error'
});
return;
}
// Store the script and determine container type using is_vm property
setPendingCloneScript(script);
// Use is_vm property from batch detection (from main branch)
// If not available, default to LXC
const containerType = script.is_vm ? 'vm' : 'lxc';
setCloneContainerType(containerType);
// Fetch storages and show selection modal
void fetchCloneStorages(script.server_id, false);
setShowCloneStorageSelection(true);
};
const handleCloneStorageSelected = (storage: Storage) => {
setShowCloneStorageSelection(false);
setSelectedCloneStorage(storage);
setShowCloneCountInput(true);
};
const handleCloneCountSubmit = async (count: number) => {
setShowCloneCountInput(false);
if (!pendingCloneScript || !cloneContainerType) {
setErrorModal({
isOpen: true,
title: 'Clone Failed',
message: 'Missing required information for cloning.',
type: 'error'
});
return;
}
try {
// Get original hostname
const hostnameResult = await getContainerHostnameQuery.refetch();
if (!hostnameResult.data?.success || !hostnameResult.data.hostname) {
setErrorModal({
isOpen: true,
title: 'Clone Failed',
message: 'Could not retrieve container hostname.',
type: 'error'
});
return;
}
const originalHostname = hostnameResult.data.hostname;
// Generate clone hostnames using utils to call with originalHostname
const hostnamesResult = await utils.installedScripts.generateCloneHostnames.fetch({
originalHostname,
containerType: cloneContainerType ?? 'lxc',
serverId: pendingCloneScript.server_id!,
count
});
if (!hostnamesResult?.success || !hostnamesResult.hostnames.length) {
setErrorModal({
isOpen: true,
title: 'Clone Failed',
message: hostnamesResult?.error ?? 'Could not generate clone hostnames.',
type: 'error'
});
return;
}
const hostnames = hostnamesResult.hostnames;
// Execute clone (nextIds will be obtained sequentially in server.js)
const cloneResult = await executeCloneMutation.mutateAsync({
containerId: pendingCloneScript.container_id!,
serverId: pendingCloneScript.server_id!,
storage: selectedCloneStorage!.name,
cloneCount: count,
hostnames: hostnames,
containerType: cloneContainerType
});
if (!cloneResult.success || !cloneResult.executionId) {
setErrorModal({
isOpen: true,
title: 'Clone Failed',
message: cloneResult.error ?? 'Failed to start clone operation.',
type: 'error'
});
return;
}
// Get server info for websocket
const server = pendingCloneScript.server_id && pendingCloneScript.server_user ? {
id: pendingCloneScript.server_id,
name: pendingCloneScript.server_name,
ip: pendingCloneScript.server_ip,
user: pendingCloneScript.server_user,
password: pendingCloneScript.server_password,
auth_type: pendingCloneScript.server_auth_type ?? 'password',
ssh_key: pendingCloneScript.server_ssh_key,
ssh_key_passphrase: pendingCloneScript.server_ssh_key_passphrase,
ssh_port: pendingCloneScript.server_ssh_port ?? 22,
} : null;
// Set up terminal for clone execution
setUpdatingScript({
id: pendingCloneScript.id,
containerId: pendingCloneScript.container_id!,
server: server,
isClone: true,
executionId: cloneResult.executionId,
cloneCount: count,
hostnames: hostnames,
containerType: cloneContainerType,
storage: selectedCloneStorage!.name
});
// Reset clone state
setPendingCloneScript(null);
setCloneStorages([]);
setSelectedCloneStorage(null);
setCloneContainerType(null);
// Reset clone count (no state variable needed, count is passed as parameter)
} catch (error) {
setErrorModal({
isOpen: true,
title: 'Clone Failed',
message: error instanceof Error ? error.message : 'Unknown error occurred',
type: 'error'
});
}
};
const handleOpenShell = (script: InstalledScript) => {
if (!script.container_id) {
setErrorModal({
@@ -1214,26 +1426,25 @@ export function InstalledScriptsTab() {
<div className="mb-8" data-terminal="update">
<Terminal
scriptPath={
updatingScript.isBackupOnly
updatingScript.isClone
? `clone-${updatingScript.containerId}`
: updatingScript.isBackupOnly
? `backup-${updatingScript.containerId}`
: `update-${updatingScript.containerId}`
}
onClose={handleCloseUpdateTerminal}
mode={updatingScript.server ? "ssh" : "local"}
server={updatingScript.server}
isUpdate={!updatingScript.isBackupOnly}
isUpdate={!updatingScript.isBackupOnly && !updatingScript.isClone}
isBackup={updatingScript.isBackupOnly}
isClone={updatingScript.isClone}
containerId={updatingScript.containerId}
storage={
updatingScript.isBackupOnly
? updatingScript.backupStorage
: undefined
}
backupStorage={
!updatingScript.isBackupOnly
? updatingScript.backupStorage
: undefined
}
executionId={updatingScript.executionId}
cloneCount={updatingScript.cloneCount}
hostnames={updatingScript.hostnames}
containerType={updatingScript.containerType}
storage={updatingScript.isClone ? updatingScript.storage : (updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined)}
backupStorage={!updatingScript.isBackupOnly && !updatingScript.isClone ? updatingScript.backupStorage : undefined}
/>
</div>
)}
@@ -1714,6 +1925,7 @@ export function InstalledScriptsTab() {
onCancel={handleCancelEdit}
onUpdate={() => handleUpdateScript(script)}
onBackup={() => handleBackupScript(script)}
onClone={() => handleCloneScript(script)}
onShell={() => handleOpenShell(script)}
onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending}
@@ -2065,8 +2277,22 @@ export function InstalledScriptsTab() {
</DropdownMenuItem>
)}
{script.container_id &&
script.execution_mode === "ssh" &&
!script.is_vm && (
script.execution_mode === "ssh" && (
<DropdownMenuItem
onClick={() =>
handleCloneScript(script)
}
disabled={
containerStatuses.get(script.id) ===
"stopped"
}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Clone
</DropdownMenuItem>
)}
{script.container_id &&
script.execution_mode === "ssh" && (
<DropdownMenuItem
onClick={() =>
handleOpenShell(script)
@@ -2355,6 +2581,43 @@ export function InstalledScriptsTab() {
}}
/>
{/* Clone Storage Selection Modal */}
<StorageSelectionModal
isOpen={showCloneStorageSelection}
onClose={() => {
setShowCloneStorageSelection(false);
setPendingCloneScript(null);
setCloneStorages([]);
}}
onSelect={handleCloneStorageSelected}
storages={cloneStorages}
isLoading={isLoadingCloneStorages}
onRefresh={() => {
if (pendingCloneScript?.server_id) {
void fetchCloneStorages(pendingCloneScript.server_id, true);
}
}}
title="Select Clone Storage"
description="Select a storage to use for cloning. Only storages with rootdir content are shown."
filterFn={(storage) => {
return storage.content.includes('rootdir');
}}
showBackupTag={false}
/>
{/* Clone Count Input Modal */}
<CloneCountInputModal
isOpen={showCloneCountInput}
onClose={() => {
setShowCloneCountInput(false);
setPendingCloneScript(null);
setCloneStorages([]);
setSelectedCloneStorage(null);
}}
onSubmit={handleCloneCountSubmit}
storageName={selectedCloneStorage?.name ?? ''}
/>
{/* LXC Settings Modal */}
<LXCSettingsModal
isOpen={lxcSettingsModal.isOpen}

View File

@@ -16,7 +16,7 @@ interface LoadingModalProps {
export function LoadingModal({
isOpen,
action: _action,
action,
logs = [],
isComplete = false,
title,
@@ -64,6 +64,11 @@ export function LoadingModal({
)}
</div>
{/* Action text - displayed prominently */}
{action && (
<p className="text-foreground text-base font-medium">{action}</p>
)}
{/* Static title text */}
{title && <p className="text-muted-foreground text-sm">{title}</p>}

View File

@@ -46,6 +46,7 @@ interface ScriptInstallationCardProps {
onCancel: () => void;
onUpdate: () => void;
onBackup?: () => void;
onClone?: () => void;
onShell: () => void;
onDelete: () => void;
isUpdating: boolean;
@@ -71,6 +72,7 @@ export function ScriptInstallationCard({
onCancel,
onUpdate,
onBackup,
onClone,
onShell,
onDelete,
isUpdating,
@@ -319,7 +321,16 @@ export function ScriptInstallationCard({
Backup
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
{script.container_id && script.execution_mode === 'ssh' && onClone && (
<DropdownMenuItem
onClick={onClone}
disabled={containerStatus === 'stopped'}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Clone
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<DropdownMenuItem
onClick={onShell}
disabled={containerStatus === 'stopped'}

View File

@@ -13,6 +13,10 @@ interface StorageSelectionModalProps {
storages: Storage[];
isLoading: boolean;
onRefresh: () => void;
title?: string;
description?: string;
filterFn?: (storage: Storage) => boolean;
showBackupTag?: boolean;
}
export function StorageSelectionModal({
@@ -21,7 +25,11 @@ export function StorageSelectionModal({
onSelect,
storages,
isLoading,
onRefresh
onRefresh,
title = 'Select Storage',
description = 'Select a storage to use.',
filterFn,
showBackupTag = true
}: StorageSelectionModalProps) {
const [selectedStorage, setSelectedStorage] = useState<Storage | null>(null);
@@ -41,8 +49,8 @@ export function StorageSelectionModal({
onClose();
};
// Filter to show only backup-capable storages
const backupStorages = storages.filter(s => s.supportsBackup);
// Filter storages using filterFn if provided, otherwise filter to show only backup-capable storages
const filteredStorages = filterFn ? storages.filter(filterFn) : storages.filter(s => s.supportsBackup);
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
@@ -51,7 +59,7 @@ export function StorageSelectionModal({
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<Database className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">Select Backup Storage</h2>
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
</div>
<Button
onClick={handleClose}
@@ -72,7 +80,7 @@ export function StorageSelectionModal({
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
<p className="text-muted-foreground">Loading storages...</p>
</div>
) : backupStorages.length === 0 ? (
) : filteredStorages.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">No backup-capable storages found</p>
@@ -87,12 +95,12 @@ export function StorageSelectionModal({
) : (
<>
<p className="text-sm text-muted-foreground mb-4">
Select a storage to use for the backup. Only storages that support backups are shown.
{description}
</p>
{/* Storage List */}
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
{backupStorages.map((storage) => (
{filteredStorages.map((storage) => (
<div
key={storage.name}
onClick={() => setSelectedStorage(storage)}
@@ -106,9 +114,11 @@ export function StorageSelectionModal({
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{storage.name}</h3>
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
Backup
</span>
{showBackupTag && (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
Backup
</span>
)}
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
{storage.type}
</span>

View File

@@ -13,9 +13,14 @@ interface TerminalProps {
isUpdate?: boolean;
isShell?: boolean;
isBackup?: boolean;
isClone?: boolean;
containerId?: string;
storage?: string;
backupStorage?: string;
executionId?: string;
cloneCount?: number;
hostnames?: string[];
containerType?: 'lxc' | 'vm';
}
interface TerminalMessage {
@@ -24,7 +29,7 @@ interface TerminalMessage {
timestamp: number;
}
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) {
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, isClone = false, containerId, storage, backupStorage, executionId: propExecutionId, cloneCount, hostnames, containerType }: TerminalProps) {
const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false);
@@ -39,7 +44,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const fitAddonRef = useRef<any>(null);
const wsRef = useRef<WebSocket | null>(null);
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
const [executionId, setExecutionId] = useState(() => propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
// Update executionId when propExecutionId changes
useEffect(() => {
if (propExecutionId) {
setExecutionId(propExecutionId);
}
}, [propExecutionId]);
const effectiveExecutionId = propExecutionId ?? executionId;
const isConnectingRef = useRef<boolean>(false);
const hasConnectedRef = useRef<boolean>(false);
@@ -277,7 +291,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
executionId: effectiveExecutionId,
input: data
};
wsRef.current.send(JSON.stringify(message));
@@ -325,9 +339,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Only auto-start on initial connection, not on reconnections
if (isInitialConnection && !isRunning) {
// Generate a new execution ID for the initial run
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionId(newExecutionId);
// Use propExecutionId if provided, otherwise generate a new one
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!propExecutionId) {
setExecutionId(newExecutionId);
}
const message = {
action: 'start',
@@ -338,9 +354,13 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
isUpdate,
isShell,
isBackup,
isClone,
containerId,
storage,
backupStorage
backupStorage,
cloneCount,
hostnames,
containerType
};
ws.send(JSON.stringify(message));
}
@@ -384,9 +404,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
// Generate a new execution ID for each script run
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionId(newExecutionId);
// Generate a new execution ID for each script run (unless propExecutionId is provided)
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!propExecutionId) {
setExecutionId(newExecutionId);
}
setIsStopped(false);
wsRef.current.send(JSON.stringify({
@@ -397,7 +419,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
server,
isUpdate,
isShell,
containerId
isBackup,
isClone,
containerId,
storage,
backupStorage,
cloneCount,
hostnames,
containerType
}));
}
};

View File

@@ -6,30 +6,40 @@ export interface ToggleProps
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
label?: string;
labelPosition?: 'left' | 'right';
}
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
({ className, checked, onCheckedChange, label, ...props }, ref) => {
({ className, checked, onCheckedChange, label, labelPosition = 'right', ...props }, ref) => {
const toggleSwitch = (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
ref={ref}
{...props}
/>
<div className={cn(
"w-11 h-6 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 rounded-full peer after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out after:shadow-md transition-colors duration-300 ease-in-out border-2 border-gray-300 dark:border-gray-600",
checked
? "bg-blue-500 dark:bg-blue-600 after:translate-x-full"
: "bg-gray-300 dark:bg-gray-700",
className
)} />
</label>
);
return (
<div className="flex items-center space-x-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
ref={ref}
{...props}
/>
<div className={cn(
"w-11 h-6 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 rounded-full peer after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out after:shadow-md transition-colors duration-300 ease-in-out border-2 border-gray-300 dark:border-gray-600",
checked
? "bg-blue-500 dark:bg-blue-600 after:translate-x-full"
: "bg-gray-300 dark:bg-gray-700",
className
)} />
</label>
{label && (
{label && labelPosition === 'left' && (
<span className="text-sm font-medium text-foreground">
{label}
</span>
)}
{toggleSwitch}
{label && labelPosition === 'right' && (
<span className="text-sm font-medium text-foreground">
{label}
</span>

View File

@@ -442,30 +442,130 @@ async function isVM(scriptId: number, containerId: string, serverId: number | nu
return true; // VM config file exists
}
// Check LXC config file
let lxcConfigExists = false;
// Check LXC config file (not needed for return value, but check for completeness)
await new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server as Server,
`test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`,
(data: string) => {
if (data.includes('exists')) {
lxcConfigExists = true;
}
(_data: string) => {
// Data handler not needed - just checking if file exists
},
() => resolve(),
() => resolve()
);
});
// If LXC config exists, it's an LXC container
return !lxcConfigExists; // Return true if it's a VM (neither config exists defaults to false/LXC)
return false; // Always LXC since VM config doesn't exist
} catch (error) {
console.error('Error determining container type:', error);
return false; // Default to LXC on error
}
}
// Helper function to batch detect container types for all containers on a server
// Returns a Map of container_id -> isVM (true for VM, false for LXC)
async function batchDetectContainerTypes(server: Server): Promise<Map<string, boolean>> {
const containerTypeMap = new Map<string, boolean>();
try {
// Import SSH services
const { default: SSHService } = await import('~/server/ssh-service');
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
const sshService = new SSHService();
const sshExecutionService = new SSHExecutionService();
// Test SSH connection first
const connectionTest = await sshService.testSSHConnection(server);
if (!(connectionTest as any).success) {
console.warn(`SSH connection failed for server ${server.name}, skipping batch detection`);
return containerTypeMap; // Return empty map if SSH fails
}
// Helper function to parse list output and extract IDs
const parseListOutput = (output: string): string[] => {
const ids: string[] = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
// Skip header lines
if (line.includes('VMID') || line.includes('CTID')) continue;
// Extract first column (ID)
const parts = line.trim().split(/\s+/);
if (parts.length > 0) {
const id = parts[0]?.trim();
// Validate ID format (3-4 digits typically)
if (id && /^\d{3,4}$/.test(id)) {
ids.push(id);
}
}
}
return ids;
};
// Get containers from pct list
let pctOutput = '';
await new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server,
'pct list',
(data: string) => {
pctOutput += data;
},
(error: string) => {
console.error(`pct list error for server ${server.name}:`, error);
// Don't reject, just continue - might be no containers
resolve();
},
(_exitCode: number) => {
resolve();
}
);
});
// Get VMs from qm list
let qmOutput = '';
await new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server,
'qm list',
(data: string) => {
qmOutput += data;
},
(error: string) => {
console.error(`qm list error for server ${server.name}:`, error);
// Don't reject, just continue - might be no VMs
resolve();
},
(_exitCode: number) => {
resolve();
}
);
});
// Parse IDs from both lists
const containerIds = parseListOutput(pctOutput);
const vmIds = parseListOutput(qmOutput);
// Mark all LXC containers as false (not VM)
for (const id of containerIds) {
containerTypeMap.set(id, false);
}
// Mark all VMs as true (is VM)
for (const id of vmIds) {
containerTypeMap.set(id, true);
}
} catch (error) {
console.error(`Error in batchDetectContainerTypes for server ${server.name}:`, error);
// Return empty map on error - individual checks will fall back to isVM()
}
return containerTypeMap;
}
export const installedScriptsRouter = createTRPCRouter({
// Get all installed scripts
@@ -475,13 +575,52 @@ export const installedScriptsRouter = createTRPCRouter({
const db = getDatabase();
const scripts = await db.getAllInstalledScripts();
// Group scripts by server_id for batch detection
const scriptsByServer = new Map<number, any[]>();
const serversMap = new Map<number, Server>();
for (const script of scripts) {
if (script.server_id && script.server) {
if (!scriptsByServer.has(script.server_id)) {
scriptsByServer.set(script.server_id, []);
serversMap.set(script.server_id, script.server as Server);
}
scriptsByServer.get(script.server_id)!.push(script);
}
}
// Batch detect container types for each server
const containerTypeMap = new Map<string, boolean>();
const batchDetectionPromises = Array.from(serversMap.entries()).map(async ([serverId, server]) => {
try {
const serverTypeMap = await batchDetectContainerTypes(server);
// Merge into main map with server-specific prefix to avoid collisions
// Actually, container IDs are unique across the cluster, so we can use them directly
for (const [containerId, isVM] of serverTypeMap.entries()) {
containerTypeMap.set(containerId, isVM);
}
} catch (error) {
console.error(`Error batch detecting types for server ${serverId}:`, error);
// Continue with other servers
}
});
await Promise.all(batchDetectionPromises);
// Transform scripts to flatten server data for frontend compatibility
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
// Determine if it's a VM or LXC
const transformedScripts = scripts.map((script: any) => {
// Determine if it's a VM or LXC from batch detection map, fall back to isVM() if not found
let is_vm = false;
if (script.container_id && script.server_id) {
is_vm = await isVM(script.id, script.container_id, script.server_id);
// First check if we have it in the batch detection map
if (containerTypeMap.has(script.container_id)) {
is_vm = containerTypeMap.get(script.container_id) ?? false;
} else {
// Fall back to checking LXCConfig in database (fast, no SSH needed)
// If LXCConfig exists, it's an LXC container
const hasLXCConfig = script.lxc_config !== null && script.lxc_config !== undefined;
is_vm = !hasLXCConfig; // If no LXCConfig, might be VM, but default to false for safety
}
}
return {
@@ -498,7 +637,7 @@ export const installedScriptsRouter = createTRPCRouter({
is_vm,
server: undefined // Remove nested server object
};
}));
});
return {
success: true,
@@ -522,13 +661,31 @@ export const installedScriptsRouter = createTRPCRouter({
const db = getDatabase();
const scripts = await db.getInstalledScriptsByServer(input.serverId);
// Batch detect container types for this server
let containerTypeMap = new Map<string, boolean>();
if (scripts.length > 0 && scripts[0]?.server) {
try {
containerTypeMap = await batchDetectContainerTypes(scripts[0].server as Server);
} catch (error) {
console.error(`Error batch detecting types for server ${input.serverId}:`, error);
// Continue with empty map, will fall back to LXCConfig check
}
}
// Transform scripts to flatten server data for frontend compatibility
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
// Determine if it's a VM or LXC
const transformedScripts = scripts.map((script: any) => {
// Determine if it's a VM or LXC from batch detection map, fall back to LXCConfig check if not found
let is_vm = false;
if (script.container_id && script.server_id) {
is_vm = await isVM(script.id, script.container_id, script.server_id);
// First check if we have it in the batch detection map
if (containerTypeMap.has(script.container_id)) {
is_vm = containerTypeMap.get(script.container_id) ?? false;
} else {
// Fall back to checking LXCConfig in database (fast, no SSH needed)
// If LXCConfig exists, it's an LXC container
const hasLXCConfig = script.lxc_config !== null && script.lxc_config !== undefined;
is_vm = !hasLXCConfig; // If no LXCConfig, might be VM, but default to false for safety
}
}
return {
@@ -545,7 +702,7 @@ export const installedScriptsRouter = createTRPCRouter({
is_vm,
server: undefined // Remove nested server object
};
}));
});
return {
success: true,
@@ -2490,5 +2647,562 @@ EOFCONFIG`;
executionId: null
};
}
}),
// Get next free ID from cluster (single ID for sequential cloning)
getClusterNextId: publicProcedure
.input(z.object({
serverId: z.number()
}))
.query(async ({ input }) => {
try {
const db = getDatabase();
const server = await db.getServerById(input.serverId);
if (!server) {
return {
success: false,
error: 'Server not found',
nextId: null
};
}
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = getSSHExecutionService();
let output = '';
await new Promise<void>((resolve, reject) => {
sshExecutionService.executeCommand(
server as Server,
'pvesh get /cluster/nextid',
(data: string) => {
output += data;
},
(error: string) => {
reject(new Error(`Failed to get next ID: ${error}`));
},
(exitCode: number) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`pvesh command failed with exit code ${exitCode}`));
}
}
);
});
const nextId = output.trim();
if (!nextId || !/^\d+$/.test(nextId)) {
return {
success: false,
error: 'Invalid next ID received',
nextId: null
};
}
return {
success: true,
nextId
};
} catch (error) {
console.error('Error in getClusterNextId:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get next ID',
nextId: null
};
}
}),
// Get container hostname/name
getContainerHostname: publicProcedure
.input(z.object({
containerId: z.string(),
serverId: z.number(),
containerType: z.enum(['lxc', 'vm'])
}))
.query(async ({ input }) => {
try {
const db = getDatabase();
const server = await db.getServerById(input.serverId);
if (!server) {
return {
success: false,
error: 'Server not found',
hostname: null
};
}
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = getSSHExecutionService();
const configPath = input.containerType === 'lxc'
? `/etc/pve/lxc/${input.containerId}.conf`
: `/etc/pve/qemu-server/${input.containerId}.conf`;
let configContent = '';
await new Promise<void>((resolve) => {
sshExecutionService.executeCommand(
server as Server,
`cat "${configPath}" 2>/dev/null || echo ""`,
(data: string) => {
configContent += data;
},
() => resolve(), // Don't fail on error
() => resolve() // Always resolve
);
});
if (!configContent.trim()) {
return {
success: true,
hostname: null
};
}
// Parse config for hostname (LXC) or name (VM)
const lines = configContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (input.containerType === 'lxc' && trimmed.startsWith('hostname:')) {
const hostname = trimmed.substring(9).trim();
return {
success: true,
hostname
};
} else if (input.containerType === 'vm' && trimmed.startsWith('name:')) {
const name = trimmed.substring(5).trim();
return {
success: true,
hostname: name
};
}
}
return {
success: true,
hostname: null
};
} catch (error) {
console.error('Error in getContainerHostname:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get container hostname',
hostname: null
};
}
}),
// Get clone storages (rootdir or images content)
getCloneStorages: publicProcedure
.input(z.object({
serverId: z.number(),
forceRefresh: z.boolean().optional().default(false)
}))
.query(async ({ input }) => {
try {
const db = getDatabase();
const server = await db.getServerById(input.serverId);
if (!server) {
return {
success: false,
error: 'Server not found',
storages: [],
cached: false
};
}
const storageService = getStorageService();
const { default: SSHService } = await import('~/server/ssh-service');
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshService = new SSHService();
const sshExecutionService = getSSHExecutionService();
// Test SSH connection first
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
storages: [],
cached: false
};
}
// Get server hostname to filter storages
let serverHostname = '';
try {
await new Promise<void>((resolve, reject) => {
sshExecutionService.executeCommand(
server as Server,
'hostname',
(data: string) => {
serverHostname += data;
},
(error: string) => {
reject(new Error(`Failed to get hostname: ${error}`));
},
(exitCode: number) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`hostname command failed with exit code ${exitCode}`));
}
}
);
});
} catch (error) {
console.error('Error getting server hostname:', error);
// Continue without filtering if hostname can't be retrieved
}
const normalizedHostname = serverHostname.trim().toLowerCase();
// Check if we have cached data
const wasCached = !input.forceRefresh;
// Fetch storages (will use cache if not forcing refresh)
const allStorages = await storageService.getStorages(server as Server, input.forceRefresh);
// Filter storages by node hostname matching and content type (only rootdir for cloning)
const applicableStorages = allStorages.filter(storage => {
// Check content type - must have rootdir for cloning
const hasRootdir = storage.content.includes('rootdir');
if (!hasRootdir) {
return false;
}
// If storage has no nodes specified, it's available on all nodes
if (!storage.nodes || storage.nodes.length === 0) {
return true;
}
// If we couldn't get hostname, include all storages (fallback)
if (!normalizedHostname) {
return true;
}
// Check if server hostname is in the nodes array (case-insensitive, trimmed)
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
return normalizedNodes.includes(normalizedHostname);
});
return {
success: true,
storages: applicableStorages,
cached: wasCached && applicableStorages.length > 0
};
} catch (error) {
console.error('Error in getCloneStorages:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch storages',
storages: [],
cached: false
};
}
}),
// Generate clone hostnames
generateCloneHostnames: publicProcedure
.input(z.object({
originalHostname: z.string(),
containerType: z.enum(['lxc', 'vm']),
serverId: z.number(),
count: z.number().min(1).max(100)
}))
.query(async ({ input }) => {
try {
const db = getDatabase();
const server = await db.getServerById(input.serverId);
if (!server) {
return {
success: false,
error: 'Server not found',
hostnames: []
};
}
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = getSSHExecutionService();
// Get all existing containers/VMs to find existing clones (check both LXC and VM)
const existingHostnames = new Set<string>();
// Check LXC containers
let lxcOutput = '';
try {
await new Promise<void>((resolve) => {
sshExecutionService.executeCommand(
server as Server,
'pct list',
(data: string) => {
lxcOutput += data;
},
(error: string) => {
console.error(`pct list error for server ${server.name}:`, error);
resolve();
},
() => resolve()
);
});
const lxcLines = lxcOutput.split('\n').filter(line => line.trim());
for (const line of lxcLines) {
if (line.includes('CTID') || line.includes('NAME')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length >= 3) {
const name = parts.slice(2).join(' ').trim();
if (name) {
existingHostnames.add(name.toLowerCase());
}
}
}
} catch {
// Continue even if LXC list fails
}
// Check VMs
let vmOutput = '';
try {
await new Promise<void>((resolve) => {
sshExecutionService.executeCommand(
server as Server,
'qm list',
(data: string) => {
vmOutput += data;
},
(error: string) => {
console.error(`qm list error for server ${server.name}:`, error);
resolve();
},
() => resolve()
);
});
const vmLines = vmOutput.split('\n').filter(line => line.trim());
for (const line of vmLines) {
if (line.includes('VMID') || line.includes('NAME')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length >= 3) {
const name = parts.slice(2).join(' ').trim();
if (name) {
existingHostnames.add(name.toLowerCase());
}
}
}
} catch {
// Continue even if VM list fails
}
// Find next available clone number
const clonePattern = new RegExp(`^${input.originalHostname.toLowerCase()}-clone-(\\d+)$`);
const existingCloneNumbers: number[] = [];
for (const hostname of existingHostnames) {
const match = hostname.match(clonePattern);
if (match) {
existingCloneNumbers.push(parseInt(match[1] ?? '0', 10));
}
}
// Determine starting number
let nextNumber = 1;
if (existingCloneNumbers.length > 0) {
existingCloneNumbers.sort((a, b) => a - b);
const lastNumber = existingCloneNumbers[existingCloneNumbers.length - 1];
if (lastNumber !== undefined) {
nextNumber = lastNumber + 1;
}
}
// Generate hostnames
const hostnames: string[] = [];
for (let i = 0; i < input.count; i++) {
hostnames.push(`${input.originalHostname}-clone-${nextNumber + i}`);
}
return {
success: true,
hostnames
};
} catch (error) {
console.error('Error in generateCloneHostnames:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to generate clone hostnames',
hostnames: []
};
}
}),
// Execute clone (prepare for websocket execution)
// Note: nextIds will be obtained sequentially during cloning in server.js
executeClone: publicProcedure
.input(z.object({
containerId: z.string(),
serverId: z.number(),
storage: z.string(),
cloneCount: z.number().min(1).max(100),
hostnames: z.array(z.string()),
containerType: z.enum(['lxc', 'vm'])
}))
.mutation(async ({ input }) => {
try {
const db = getDatabase();
const server = await db.getServerById(input.serverId);
if (!server) {
return {
success: false,
error: 'Server not found',
executionId: null
};
}
const { default: SSHService } = await import('~/server/ssh-service');
const sshService = new SSHService();
// Test SSH connection first
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
executionId: null
};
}
// Validate inputs
if (input.hostnames.length !== input.cloneCount) {
return {
success: false,
error: 'Hostnames count must match clone count',
executionId: null
};
}
// Generate execution ID for websocket tracking
const executionId = `clone_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return {
success: true,
executionId,
containerId: input.containerId,
storage: input.storage,
cloneCount: input.cloneCount,
hostnames: input.hostnames,
containerType: input.containerType,
server: server as Server
};
} catch (error) {
console.error('Error in executeClone:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to execute clone',
executionId: null
};
}
}),
// Add cloned container to database
addClonedContainerToDatabase: publicProcedure
.input(z.object({
containerId: z.string(),
serverId: z.number(),
containerType: z.enum(['lxc', 'vm'])
}))
.mutation(async ({ input }) => {
try {
const db = getDatabase();
const server = await db.getServerById(input.serverId);
if (!server) {
return {
success: false,
error: 'Server not found',
scriptId: null
};
}
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = getSSHExecutionService();
// Read config file to get hostname/name
const configPath = input.containerType === 'lxc'
? `/etc/pve/lxc/${input.containerId}.conf`
: `/etc/pve/qemu-server/${input.containerId}.conf`;
let configContent = '';
await new Promise<void>((resolve) => {
sshExecutionService.executeCommand(
server as Server,
`cat "${configPath}" 2>/dev/null || echo ""`,
(data: string) => {
configContent += data;
},
() => resolve(),
() => resolve()
);
});
if (!configContent.trim()) {
return {
success: false,
error: 'Config file not found',
scriptId: null
};
}
// Parse config for hostname/name
let hostname = '';
const lines = configContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (input.containerType === 'lxc' && trimmed.startsWith('hostname:')) {
hostname = trimmed.substring(9).trim();
break;
} else if (input.containerType === 'vm' && trimmed.startsWith('name:')) {
hostname = trimmed.substring(5).trim();
break;
}
}
if (!hostname) {
hostname = `${input.containerType}-${input.containerId}`;
}
// Create installed script record
const script = await db.createInstalledScript({
script_name: hostname,
script_path: `cloned/${hostname}`,
container_id: input.containerId,
server_id: input.serverId,
execution_mode: 'ssh',
status: 'success',
output_log: `Cloned container/VM`
});
// For LXC, store config in database
if (input.containerType === 'lxc') {
const parsedConfig = parseRawConfig(configContent);
await db.createLXCConfig(script.id, parsedConfig);
}
return {
success: true,
scriptId: script.id
};
} catch (error) {
console.error('Error in addClonedContainerToDatabase:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to add cloned container to database',
scriptId: null
};
}
})
});

View File

@@ -281,7 +281,8 @@ class DatabaseServicePrisma {
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
const result = await prisma.installedScript.findMany({
include: {
server: true
server: true,
lxc_config: true
},
orderBy: { installation_date: 'desc' }
});
@@ -302,7 +303,8 @@ class DatabaseServicePrisma {
const result = await prisma.installedScript.findMany({
where: { server_id },
include: {
server: true
server: true,
lxc_config: true
},
orderBy: { installation_date: 'desc' }
});

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/no-unused-vars, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/prefer-optional-chain */
import { getSSHExecutionService } from '../ssh-execution-service';
import { getStorageService } from './storageService';
import { getDatabase } from '../database-prisma';

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { env } from '../../env.js';

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-optional-chain, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/prefer-for-of */
import { getSSHExecutionService } from '../ssh-execution-service';
import type { Server } from '~/types/server';