diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx
index ad155b4..ef1f115 100644
--- a/src/app/_components/InstalledScriptsTab.tsx
+++ b/src/app/_components/InstalledScriptsTab.tsx
@@ -45,6 +45,7 @@ interface InstalledScript {
container_status?: 'running' | 'stopped' | 'unknown';
web_ui_ip: string | null;
web_ui_port: number | null;
+ is_vm?: boolean;
}
export function InstalledScriptsTab() {
@@ -1077,23 +1078,35 @@ export function InstalledScriptsTab() {
Installed Scripts
{stats && (
-
+
{stats.total}
Total Installations
- {scriptsWithStatus.filter(script => script.container_status === 'running').length}
+ {scriptsWithStatus.filter(script => script.container_status === 'running' && !script.is_vm).length}
Running LXC
+
+
+ {scriptsWithStatus.filter(script => script.container_status === 'running' && script.is_vm).length}
+
+
Running VMs
+
- {scriptsWithStatus.filter(script => script.container_status === 'stopped').length}
+ {scriptsWithStatus.filter(script => script.container_status === 'stopped' && !script.is_vm).length}
Stopped LXC
+
+
+ {scriptsWithStatus.filter(script => script.container_status === 'stopped' && script.is_vm).length}
+
+
Stopped VMs
+
)}
@@ -1527,7 +1540,18 @@ export function InstalledScriptsTab() {
) : (
-
{script.script_name}
+
+ {script.container_id && (
+
+ {script.is_vm ? 'VM' : 'LXC'}
+
+ )}
+
{script.script_name}
+
{script.script_path}
)}
@@ -1683,7 +1707,7 @@ export function InstalledScriptsTab() {
- {script.container_id && (
+ {script.container_id && !script.is_vm && (
handleUpdateScript(script)}
disabled={containerStatuses.get(script.id) === 'stopped'}
@@ -1701,7 +1725,7 @@ export function InstalledScriptsTab() {
Backup
)}
- {script.container_id && script.execution_mode === 'ssh' && (
+ {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
handleOpenShell(script)}
disabled={containerStatuses.get(script.id) === 'stopped'}
@@ -1728,7 +1752,7 @@ export function InstalledScriptsTab() {
{autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'}
)}
- {script.container_id && script.execution_mode === 'ssh' && (
+ {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
<>
+ >
+ )}
+ {script.container_id && script.execution_mode === 'ssh' && (
+ <>
+ {script.is_vm && }
handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx
index 6221839..75bde46 100644
--- a/src/app/_components/ScriptInstallationCard.tsx
+++ b/src/app/_components/ScriptInstallationCard.tsx
@@ -33,6 +33,7 @@ interface InstalledScript {
container_status?: 'running' | 'stopped' | 'unknown';
web_ui_ip: string | null;
web_ui_port: number | null;
+ is_vm?: boolean;
}
interface ScriptInstallationCardProps {
@@ -300,7 +301,7 @@ export function ScriptInstallationCard({
- {script.container_id && (
+ {script.container_id && !script.is_vm && (
)}
- {script.container_id && script.execution_mode === 'ssh' && (
+ {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
{
+ const db = getDatabase();
+
+ // Method 1: Check if LXCConfig exists (if exists, it's an LXC container)
+ const lxcConfig = await db.getLXCConfigByScriptId(scriptId);
+ if (lxcConfig) {
+ return false; // Has LXCConfig, so it's an LXC container
+ }
+
+ // Method 2: If no LXCConfig, check config file paths on server
+ if (!serverId) {
+ // Can't determine without server, default to false (LXC) for safety
+ return false;
+ }
+
+ try {
+ const server = await db.getServerById(serverId);
+ if (!server) {
+ return false; // Default to LXC if server not found
+ }
+
+ // 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
+ const connectionTest = await sshService.testSSHConnection(server as Server);
+ if (!(connectionTest as any).success) {
+ return false; // Default to LXC if SSH fails
+ }
+
+ // Check both config file paths
+ const vmConfigPath = `/etc/pve/qemu-server/${containerId}.conf`;
+ const lxcConfigPath = `/etc/pve/lxc/${containerId}.conf`;
+
+ // Check VM config file
+ let vmConfigExists = false;
+ await new Promise((resolve) => {
+ void sshExecutionService.executeCommand(
+ server as Server,
+ `test -f "${vmConfigPath}" && echo "exists" || echo "not_exists"`,
+ (data: string) => {
+ if (data.includes('exists')) {
+ vmConfigExists = true;
+ }
+ },
+ () => resolve(),
+ () => resolve()
+ );
+ });
+
+ if (vmConfigExists) {
+ return true; // VM config file exists
+ }
+
+ // Check LXC config file
+ let lxcConfigExists = false;
+ await new Promise((resolve) => {
+ void sshExecutionService.executeCommand(
+ server as Server,
+ `test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`,
+ (data: string) => {
+ if (data.includes('exists')) {
+ lxcConfigExists = true;
+ }
+ },
+ () => 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)
+ } catch (error) {
+ console.error('Error determining container type:', error);
+ return false; // Default to LXC on error
+ }
+}
+
export const installedScriptsRouter = createTRPCRouter({
// Get all installed scripts
@@ -393,18 +475,27 @@ export const installedScriptsRouter = createTRPCRouter({
const scripts = await db.getAllInstalledScripts();
// Transform scripts to flatten server data for frontend compatibility
- const transformedScripts = scripts.map(script => ({
- ...script,
- server_name: script.server?.name ?? null,
- server_ip: script.server?.ip ?? null,
- server_user: script.server?.user ?? null,
- server_password: script.server?.password ?? null,
- server_auth_type: script.server?.auth_type ?? null,
- server_ssh_key: script.server?.ssh_key ?? null,
- server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
- server_ssh_port: script.server?.ssh_port ?? null,
- server_color: script.server?.color ?? null,
- server: undefined // Remove nested server object
+ const transformedScripts = await Promise.all(scripts.map(async (script) => {
+ // Determine if it's a VM or LXC
+ let is_vm = false;
+ if (script.container_id && script.server_id) {
+ is_vm = await isVM(script.id, script.container_id, script.server_id);
+ }
+
+ return {
+ ...script,
+ server_name: script.server?.name ?? null,
+ server_ip: script.server?.ip ?? null,
+ server_user: script.server?.user ?? null,
+ server_password: script.server?.password ?? null,
+ server_auth_type: script.server?.auth_type ?? null,
+ server_ssh_key: script.server?.ssh_key ?? null,
+ server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
+ server_ssh_port: script.server?.ssh_port ?? null,
+ server_color: script.server?.color ?? null,
+ is_vm,
+ server: undefined // Remove nested server object
+ };
}));
return {
@@ -430,18 +521,27 @@ export const installedScriptsRouter = createTRPCRouter({
const scripts = await db.getInstalledScriptsByServer(input.serverId);
// Transform scripts to flatten server data for frontend compatibility
- const transformedScripts = scripts.map(script => ({
- ...script,
- server_name: script.server?.name ?? null,
- server_ip: script.server?.ip ?? null,
- server_user: script.server?.user ?? null,
- server_password: script.server?.password ?? null,
- server_auth_type: script.server?.auth_type ?? null,
- server_ssh_key: script.server?.ssh_key ?? null,
- server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
- server_ssh_port: script.server?.ssh_port ?? null,
- server_color: script.server?.color ?? null,
- server: undefined // Remove nested server object
+ const transformedScripts = await Promise.all(scripts.map(async (script) => {
+ // Determine if it's a VM or LXC
+ let is_vm = false;
+ if (script.container_id && script.server_id) {
+ is_vm = await isVM(script.id, script.container_id, script.server_id);
+ }
+
+ return {
+ ...script,
+ server_name: script.server?.name ?? null,
+ server_ip: script.server?.ip ?? null,
+ server_user: script.server?.user ?? null,
+ server_password: script.server?.password ?? null,
+ server_auth_type: script.server?.auth_type ?? null,
+ server_ssh_key: script.server?.ssh_key ?? null,
+ server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
+ server_ssh_port: script.server?.ssh_port ?? null,
+ server_color: script.server?.color ?? null,
+ is_vm,
+ server: undefined // Remove nested server object
+ };
}));
return {
@@ -472,6 +572,12 @@ export const installedScriptsRouter = createTRPCRouter({
script: null
};
}
+ // Determine if it's a VM or LXC
+ let is_vm = false;
+ if (script.container_id && script.server_id) {
+ is_vm = await isVM(script.id, script.container_id, script.server_id);
+ }
+
// Transform script to flatten server data for frontend compatibility
const transformedScript = {
...script,
@@ -484,6 +590,7 @@ export const installedScriptsRouter = createTRPCRouter({
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
server_ssh_port: script.server?.ssh_port ?? null,
server_color: script.server?.color ?? null,
+ is_vm,
server: undefined // Remove nested server object
};
@@ -677,113 +784,159 @@ export const installedScriptsRouter = createTRPCRouter({
}
- // Use the working approach - manual loop through all config files
- const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
+ // Get containers from pct list and VMs from qm list
let detectedContainers: any[] = [];
+ // Helper function to parse list output and extract IDs
+ const parseListOutput = (output: string, isVM: boolean): 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;
+ };
- let commandOutput = '';
-
+ // Helper function to check config file for community-script tag and extract hostname/name
+ const checkConfigAndExtractInfo = async (id: string, isVM: boolean): Promise => {
+ const configPath = isVM
+ ? `/etc/pve/qemu-server/${id}.conf`
+ : `/etc/pve/lxc/${id}.conf`;
+
+ const readCommand = `cat "${configPath}" 2>/dev/null`;
+
+ return new Promise((resolve) => {
+ let configData = '';
+
+ void sshExecutionService.executeCommand(
+ server as Server,
+ readCommand,
+ (data: string) => {
+ configData += data;
+ },
+ (_error: string) => {
+ // Config file doesn't exist or can't be read
+ resolve(null);
+ },
+ (_exitCode: number) => {
+ // Check if config contains community-script tag
+ if (!configData.includes('community-script')) {
+ resolve(null);
+ return;
+ }
+
+ // Extract hostname (for containers) or name (for VMs)
+ const lines = configData.split('\n');
+ let hostname = '';
+ let name = '';
+
+ for (const line of lines) {
+ const trimmedLine = line.trim();
+ if (trimmedLine.startsWith('hostname:')) {
+ hostname = trimmedLine.substring(9).trim();
+ } else if (trimmedLine.startsWith('name:')) {
+ name = trimmedLine.substring(5).trim();
+ }
+ }
+
+ // Use hostname for containers, name for VMs
+ const displayName = isVM ? name : hostname;
+
+ if (displayName) {
+ // Parse full config and store in database (only for containers)
+ let parsedConfig = null;
+ let configHash = null;
+
+ if (!isVM) {
+ parsedConfig = parseRawConfig(configData);
+ configHash = calculateConfigHash(configData);
+ }
+
+ resolve({
+ containerId: id,
+ hostname: displayName,
+ configPath,
+ isVM,
+ serverId: Number((server as any).id),
+ serverName: (server as any).name,
+ parsedConfig: parsedConfig ? {
+ ...parsedConfig,
+ config_hash: configHash,
+ synced_at: new Date()
+ } : null
+ });
+ } else {
+ resolve(null);
+ }
+ }
+ );
+ });
+ };
+
+ // Get containers from pct list
+ let pctOutput = '';
await new Promise((resolve, reject) => {
-
void sshExecutionService.executeCommand(
-
server as Server,
- command,
+ 'pct list',
(data: string) => {
- commandOutput += data;
+ pctOutput += data;
},
(error: string) => {
- console.error('Command error:', error);
+ console.error('pct list error:', error);
+ reject(new Error(`pct list failed: ${error}`));
},
(_exitCode: number) => {
-
- // Parse the complete output to get config file paths that contain community-script tag
- const configFiles = commandOutput.split('\n')
- .filter((line: string) => line.trim())
- .map((line: string) => line.trim())
- .filter((line: string) => line.endsWith('.conf'));
-
-
- // Process each config file to extract hostname
- const processPromises = configFiles.map(async (configPath: string) => {
- try {
- const containerId = configPath.split('/').pop()?.replace('.conf', '');
- if (!containerId) return null;
-
-
- // Read the config file content
- const readCommand = `cat "${configPath}" 2>/dev/null`;
-
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
- return new Promise((readResolve) => {
-
- void sshExecutionService.executeCommand(
-
- server as Server,
- readCommand,
- (configData: string) => {
- // Parse config file for hostname
- const lines = configData.split('\n');
- let hostname = '';
-
- for (const line of lines) {
- const trimmedLine = line.trim();
- if (trimmedLine.startsWith('hostname:')) {
- hostname = trimmedLine.substring(9).trim();
- break;
- }
- }
-
- if (hostname) {
- // Parse full config and store in database
- const parsedConfig = parseRawConfig(configData);
- const configHash = calculateConfigHash(configData);
-
- const container = {
- containerId,
- hostname,
- configPath,
- serverId: Number((server as any).id),
- serverName: (server as any).name,
- parsedConfig: {
- ...parsedConfig,
- config_hash: configHash,
- synced_at: new Date()
- }
- };
- readResolve(container);
- } else {
- readResolve(null);
- }
- },
- (readError: string) => {
- console.error(`Error reading config file ${configPath}:`, readError);
- readResolve(null);
- },
- (_exitCode: number) => {
- readResolve(null);
- }
- );
- });
- } catch (error) {
- console.error(`Error processing config file ${configPath}:`, error);
- return null;
- }
- });
+ resolve();
+ }
+ );
+ });
- // Wait for all config files to be processed
- void Promise.all(processPromises).then((results) => {
- detectedContainers = results.filter(result => result !== null);
- resolve();
- }).catch((error) => {
- console.error('Error processing config files:', error);
- reject(new Error(`Error processing config files: ${error}`));
- });
+ // Get VMs from qm list
+ let qmOutput = '';
+ await new Promise((resolve, reject) => {
+ void sshExecutionService.executeCommand(
+ server as Server,
+ 'qm list',
+ (data: string) => {
+ qmOutput += data;
+ },
+ (error: string) => {
+ console.error('qm list error:', error);
+ reject(new Error(`qm list failed: ${error}`));
+ },
+ (_exitCode: number) => {
+ resolve();
}
);
});
+ // Parse IDs from both lists
+ const containerIds = parseListOutput(pctOutput, false);
+ const vmIds = parseListOutput(qmOutput, true);
+
+ // Check each container/VM for community-script tag
+ const checkPromises = [
+ ...containerIds.map(id => checkConfigAndExtractInfo(id, false)),
+ ...vmIds.map(id => checkConfigAndExtractInfo(id, true))
+ ];
+
+ const results = await Promise.all(checkPromises);
+ detectedContainers = results.filter(result => result !== null);
+
// Get existing scripts to check for duplicates
const existingScripts = await db.getAllInstalledScripts();
@@ -816,11 +969,11 @@ export const installedScriptsRouter = createTRPCRouter({
server_id: container.serverId,
execution_mode: 'ssh',
status: 'success',
- output_log: `Auto-detected from LXC config: ${container.configPath}`
+ output_log: `Auto-detected from ${container.isVM ? 'VM' : 'LXC'} config: ${container.configPath}`
});
- // Store LXC config in database
- if (container.parsedConfig) {
+ // Store LXC config in database (only for containers, not VMs)
+ if (container.parsedConfig && !container.isVM) {
await db.createLXCConfig(result.id, container.parsedConfig);
}
@@ -836,8 +989,8 @@ export const installedScriptsRouter = createTRPCRouter({
}
const message = skippedScripts.length > 0
- ? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
- : `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`;
+ ? `Auto-detection completed. Found ${detectedContainers.length} containers/VMs with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
+ : `Auto-detection completed. Found ${detectedContainers.length} containers/VMs with community-script tag. Added ${createdScripts.length} new scripts.`;
return {
success: true,
@@ -920,11 +1073,32 @@ export const installedScriptsRouter = createTRPCRouter({
continue;
}
- // Get all existing containers from pct list (more reliable than checking config files)
- const listCommand = 'pct list';
- let listOutput = '';
-
- const existingContainerIds = await new Promise>((resolve, reject) => {
+ // Helper function to parse list output and extract IDs
+ const parseListOutput = (output: string): Set => {
+ const ids = new Set();
+ 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.add(id);
+ }
+ }
+ }
+
+ return ids;
+ };
+
+ // Get all existing containers from pct list
+ let pctOutput = '';
+ const existingContainerIds = await new Promise>((resolve) => {
const timeout = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
resolve(new Set()); // Treat timeout as no containers found
@@ -932,9 +1106,9 @@ export const installedScriptsRouter = createTRPCRouter({
void sshExecutionService.executeCommand(
server as Server,
- listCommand,
+ 'pct list',
(data: string) => {
- listOutput += data;
+ pctOutput += data;
},
(error: string) => {
console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error);
@@ -943,58 +1117,95 @@ export const installedScriptsRouter = createTRPCRouter({
},
(_exitCode: number) => {
clearTimeout(timeout);
-
- // Parse pct list output to extract container IDs
- const containerIds = new Set();
- const lines = listOutput.split('\n').filter(line => line.trim());
-
- for (const line of lines) {
- // pct list format: CTID Status Name
- // Skip header line if present
- if (line.includes('CTID') || line.includes('VMID')) continue;
-
- const parts = line.trim().split(/\s+/);
- if (parts.length > 0) {
- const containerId = parts[0]?.trim();
- if (containerId && /^\d{3,4}$/.test(containerId)) {
- containerIds.add(containerId);
- }
- }
- }
-
- resolve(containerIds);
+ resolve(parseListOutput(pctOutput));
}
);
});
- // Check each script against the list of existing containers
+ // Get all existing VMs from qm list
+ let qmOutput = '';
+ const existingVMIds = await new Promise>((resolve) => {
+ const timeout = setTimeout(() => {
+ console.warn(`cleanupOrphanedScripts: timeout while getting VM list from server ${String((server as any).name)}`);
+ resolve(new Set()); // Treat timeout as no VMs found
+ }, 20000);
+
+ void sshExecutionService.executeCommand(
+ server as Server,
+ 'qm list',
+ (data: string) => {
+ qmOutput += data;
+ },
+ (error: string) => {
+ console.error(`cleanupOrphanedScripts: error getting VM list from server ${String((server as any).name)}:`, error);
+ clearTimeout(timeout);
+ resolve(new Set()); // Treat error as no VMs found
+ },
+ (_exitCode: number) => {
+ clearTimeout(timeout);
+ resolve(parseListOutput(qmOutput));
+ }
+ );
+ });
+
+ // Combine both sets - an ID exists if it's in either list
+ const existingIds = new Set([...existingContainerIds, ...existingVMIds]);
+
+ // Check each script against the list of existing containers and VMs
for (const scriptData of serverScripts) {
try {
const containerId = String(scriptData.container_id).trim();
- // Check if container exists in pct list
- if (!existingContainerIds.has(containerId)) {
+ // Check if ID exists in either pct list (containers) or qm list (VMs)
+ if (!existingIds.has(containerId)) {
// Also verify config file doesn't exist as a double-check
- const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
+ // Check both container and VM config paths
+ const checkContainerCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
+ const checkVMCommand = `test -f "/etc/pve/qemu-server/${containerId}.conf" && echo "exists" || echo "not_found"`;
const configExists = await new Promise((resolve) => {
let combinedOutput = '';
let resolved = false;
+ let checksCompleted = 0;
const finish = () => {
if (resolved) return;
- resolved = true;
- const out = combinedOutput.trim();
- resolve(out.includes('exists'));
+ checksCompleted++;
+ if (checksCompleted === 2) {
+ resolved = true;
+ clearTimeout(timer);
+ const out = combinedOutput.trim();
+ resolve(out.includes('exists'));
+ }
};
const timer = setTimeout(() => {
- finish();
+ if (!resolved) {
+ resolved = true;
+ const out = combinedOutput.trim();
+ resolve(out.includes('exists'));
+ }
}, 10000);
+ // Check container config
void sshExecutionService.executeCommand(
server as Server,
- checkCommand,
+ checkContainerCommand,
+ (data: string) => {
+ combinedOutput += data;
+ },
+ (_error: string) => {
+ // Ignore errors, just check output
+ },
+ (_exitCode: number) => {
+ finish();
+ }
+ );
+
+ // Check VM config
+ void sshExecutionService.executeCommand(
+ server as Server,
+ checkVMCommand,
(data: string) => {
combinedOutput += data;
},
@@ -1002,20 +1213,19 @@ export const installedScriptsRouter = createTRPCRouter({
// Ignore errors, just check output
},
(_exitCode: number) => {
- clearTimeout(timer);
finish();
}
);
});
- // If container is not in pct list AND config file doesn't exist, it's orphaned
+ // If ID is not in either list AND config file doesn't exist, it's orphaned
if (!configExists) {
- console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`);
+ console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (ID ${containerId}) from server ${String((server as any).name)}`);
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
- // Config exists but not in pct list - might be in a transitional state, log but don't delete
- console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`);
+ // Config exists but not in lists - might be in a transitional state, log but don't delete
+ console.warn(`cleanupOrphanedScripts: Container/VM ${containerId} (${String(scriptData.script_name)}) config exists but not in pct/qm list - may be in transitional state`);
}
}
} catch (error) {
@@ -1080,59 +1290,120 @@ export const installedScriptsRouter = createTRPCRouter({
continue;
}
- // Run pct list to get all container statuses at once
- const listCommand = 'pct list';
- let listOutput = '';
+ // Helper function to parse list output and extract statuses
+ const parseListStatuses = (output: string): Record => {
+ const statuses: Record = {};
+ const lines = output.split('\n').filter(line => line.trim());
+
+ // Find header line to determine column positions
+ let statusColumnIndex = 1; // Default to second column
+ for (const line of lines) {
+ if (line.includes('STATUS')) {
+ // Parse header to find STATUS column index
+ const headerParts = line.trim().split(/\s+/);
+ const statusIndex = headerParts.findIndex(part => part.includes('STATUS'));
+ if (statusIndex >= 0) {
+ statusColumnIndex = statusIndex;
+ }
+ break;
+ }
+ }
+
+ for (const line of lines) {
+ // Skip header lines
+ if (line.includes('VMID') || line.includes('CTID') || line.includes('STATUS')) continue;
+
+ // Parse line
+ const parts = line.trim().split(/\s+/);
+ if (parts.length > statusColumnIndex) {
+ const id = parts[0]?.trim();
+ const status = parts[statusColumnIndex]?.trim().toLowerCase();
+
+ if (id && /^\d+$/.test(id)) { // Validate ID is numeric
+ // Map status to our status format
+ let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
+ if (status === 'running') {
+ mappedStatus = 'running';
+ } else if (status === 'stopped') {
+ mappedStatus = 'stopped';
+ }
+ // All other statuses (paused, locked, suspended, etc.) map to 'unknown'
+
+ statuses[id] = mappedStatus;
+ }
+ }
+ }
+
+ return statuses;
+ };
+
+ // Run pct list to get all container statuses
+ let pctOutput = '';
// Add timeout to prevent hanging connections
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('SSH command timeout after 30 seconds')), 30000);
});
- await Promise.race([
- new Promise((resolve, reject) => {
- void sshExecutionService.executeCommand(
-
- server as Server,
- listCommand,
- (data: string) => {
- listOutput += data;
- },
- (error: string) => {
- console.error(`pct list error on server ${(server as any).name}:`, error);
- reject(new Error(error));
- },
- (_exitCode: number) => {
- resolve();
- }
- );
- }),
- timeoutPromise
- ]);
-
- // Parse pct list output
- const lines = listOutput.split('\n').filter(line => line.trim());
- for (const line of lines) {
- // pct list format: CTID Status Name
- // Example: "100 running my-container"
- const parts = line.trim().split(/\s+/);
- if (parts.length >= 3) {
- const containerId = parts[0];
- const status = parts[1];
-
- if (containerId && status) {
- // Map pct list status to our status
- let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
- if (status === 'running') {
- mappedStatus = 'running';
- } else if (status === 'stopped') {
- mappedStatus = 'stopped';
- }
-
- statusMap[containerId] = mappedStatus;
- }
- }
+ try {
+ await Promise.race([
+ new Promise((resolve, reject) => {
+ void sshExecutionService.executeCommand(
+ server as Server,
+ 'pct list',
+ (data: string) => {
+ pctOutput += data;
+ },
+ (error: string) => {
+ console.error(`pct list error on server ${(server as any).name}:`, error);
+ // Don't reject, just continue with empty output
+ resolve();
+ },
+ (_exitCode: number) => {
+ resolve();
+ }
+ );
+ }),
+ timeoutPromise
+ ]);
+ } catch (error) {
+ console.error(`Timeout or error getting pct list from server ${(server as any).name}:`, error);
+ }
+
+ // Run qm list to get all VM statuses
+ let qmOutput = '';
+
+ try {
+ await Promise.race([
+ new Promise((resolve, reject) => {
+ void sshExecutionService.executeCommand(
+ server as Server,
+ 'qm list',
+ (data: string) => {
+ qmOutput += data;
+ },
+ (error: string) => {
+ console.error(`qm list error on server ${(server as any).name}:`, error);
+ // Don't reject, just continue with empty output
+ resolve();
+ },
+ (_exitCode: number) => {
+ resolve();
+ }
+ );
+ }),
+ timeoutPromise
+ ]);
+ } catch (error) {
+ console.error(`Timeout or error getting qm list from server ${(server as any).name}:`, error);
}
+
+ // Parse both outputs and combine into statusMap
+ const containerStatuses = parseListStatuses(pctOutput);
+ const vmStatuses = parseListStatuses(qmOutput);
+
+ // Merge both status maps (VMs will overwrite containers if same ID, but that's unlikely)
+ Object.assign(statusMap, containerStatuses, vmStatuses);
} catch (error) {
console.error(`Error processing server ${(server as any).name}:`, error);
}
@@ -1207,8 +1478,13 @@ export const installedScriptsRouter = createTRPCRouter({
};
}
- // Check container status
- const statusCommand = `pct status ${scriptData.container_id}`;
+ // Determine if it's a VM or LXC
+ const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
+
+ // Check container status (use qm for VMs, pct for LXC)
+ const statusCommand = vm
+ ? `qm status ${scriptData.container_id}`
+ : `pct status ${scriptData.container_id}`;
let statusOutput = '';
await new Promise((resolve, reject) => {
@@ -1305,8 +1581,13 @@ export const installedScriptsRouter = createTRPCRouter({
};
}
- // Execute control command
- const controlCommand = `pct ${input.action} ${scriptData.container_id}`;
+ // Determine if it's a VM or LXC
+ const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
+
+ // Execute control command (use qm for VMs, pct for LXC)
+ const controlCommand = vm
+ ? `qm ${input.action} ${scriptData.container_id}`
+ : `pct ${input.action} ${scriptData.container_id}`;
let commandOutput = '';
let commandError = '';
@@ -1396,8 +1677,13 @@ export const installedScriptsRouter = createTRPCRouter({
};
}
+ // Determine if it's a VM or LXC
+ const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
+
// First check if container is running and stop it if necessary
- const statusCommand = `pct status ${scriptData.container_id}`;
+ const statusCommand = vm
+ ? `qm status ${scriptData.container_id}`
+ : `pct status ${scriptData.container_id}`;
let statusOutput = '';
try {
@@ -1420,8 +1706,10 @@ export const installedScriptsRouter = createTRPCRouter({
// Check if container is running
if (statusOutput.includes('status: running')) {
- // Stop the container first
- const stopCommand = `pct stop ${scriptData.container_id}`;
+ // Stop the container first (use qm for VMs, pct for LXC)
+ const stopCommand = vm
+ ? `qm stop ${scriptData.container_id}`
+ : `pct stop ${scriptData.container_id}`;
let stopOutput = '';
let stopError = '';
@@ -1451,8 +1739,10 @@ export const installedScriptsRouter = createTRPCRouter({
}
- // Execute destroy command
- const destroyCommand = `pct destroy ${scriptData.container_id}`;
+ // Execute destroy command (use qm for VMs, pct for LXC)
+ const destroyCommand = vm
+ ? `qm destroy ${scriptData.container_id}`
+ : `pct destroy ${scriptData.container_id}`;
let commandOutput = '';
let commandError = '';