diff --git a/src/app/_components/ConfirmationModal.tsx b/src/app/_components/ConfirmationModal.tsx new file mode 100644 index 0000000..5314ced --- /dev/null +++ b/src/app/_components/ConfirmationModal.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from './ui/button'; +import { AlertTriangle, Info } from 'lucide-react'; + +interface ConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + variant: 'simple' | 'danger'; + confirmText?: string; // What the user must type for danger variant + confirmButtonText?: string; + cancelButtonText?: string; +} + +export function ConfirmationModal({ + isOpen, + onClose, + onConfirm, + title, + message, + variant, + confirmText, + confirmButtonText = 'Confirm', + cancelButtonText = 'Cancel' +}: ConfirmationModalProps) { + const [typedText, setTypedText] = useState(''); + + if (!isOpen) return null; + + const isDanger = variant === 'danger'; + const isConfirmEnabled = isDanger ? typedText === confirmText : true; + + const handleConfirm = () => { + if (isConfirmEnabled) { + onConfirm(); + setTypedText(''); // Reset for next time + } + }; + + const handleClose = () => { + onClose(); + setTypedText(''); // Reset when closing + }; + + return ( +
+
+ {/* Header */} +
+
+ {isDanger ? ( + + ) : ( + + )} +

{title}

+
+
+ + {/* Content */} +
+

+ {message} +

+ + {/* Type-to-confirm input for danger variant */} + {isDanger && confirmText && ( +
+ + setTypedText(e.target.value)} + className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" + placeholder={`Type "${confirmText}" here`} + autoComplete="off" + /> +
+ )} + + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/_components/ErrorModal.tsx b/src/app/_components/ErrorModal.tsx new file mode 100644 index 0000000..f0a82d4 --- /dev/null +++ b/src/app/_components/ErrorModal.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from './ui/button'; +import { AlertCircle, CheckCircle } from 'lucide-react'; + +interface ErrorModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + message: string; + details?: string; + type?: 'error' | 'success'; +} + +export function ErrorModal({ + isOpen, + onClose, + title, + message, + details, + type = 'error' +}: ErrorModalProps) { + // Auto-close after 10 seconds + useEffect(() => { + if (isOpen) { + const timer = setTimeout(() => { + onClose(); + }, 10000); + return () => clearTimeout(timer); + } + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ {type === 'success' ? ( + + ) : ( + + )} +

{title}

+
+
+ + {/* Content */} +
+

{message}

+ {details && ( +
+

+ {type === 'success' ? 'Details:' : 'Error Details:'} +

+
+                {details}
+              
+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/src/app/_components/HelpModal.tsx b/src/app/_components/HelpModal.tsx index 3c3fd9f..4b02af3 100644 --- a/src/app/_components/HelpModal.tsx +++ b/src/app/_components/HelpModal.tsx @@ -334,6 +334,29 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
  • Update Scripts: Re-run or update existing script installations
  • + +
    +

    Container Control (NEW)

    +

    + Directly control LXC containers from the installed scripts page via SSH. +

    + +
    +

    ⚠️ Safety Features:

    + +
    +
    ); diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index 9703ea1..1169b7e 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -6,6 +6,8 @@ import { Terminal } from './Terminal'; import { StatusBadge } from './Badge'; import { Button } from './ui/button'; import { ScriptInstallationCard } from './ScriptInstallationCard'; +import { ConfirmationModal } from './ConfirmationModal'; +import { ErrorModal } from './ErrorModal'; import { getContrastColor } from '../../lib/colorUtils'; interface InstalledScript { @@ -22,6 +24,7 @@ interface InstalledScript { installation_date: string; status: 'in_progress' | 'success' | 'failed'; output_log: string | null; + execution_mode: 'local' | 'ssh'; container_status?: 'running' | 'stopped' | 'unknown'; } @@ -41,7 +44,30 @@ export function InstalledScriptsTab() { const [autoDetectStatus, setAutoDetectStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' }); const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' }); const cleanupRunRef = useRef(false); - const [containerStatuses, setContainerStatuses] = useState>({}); + + // Container control state + const [containerStatuses, setContainerStatuses] = useState>(new Map()); + const [confirmationModal, setConfirmationModal] = useState<{ + isOpen: boolean; + variant: 'simple' | 'danger'; + title: string; + message: string; + confirmText?: string; + confirmButtonText?: string; + cancelButtonText?: string; + onConfirm: () => void; + } | null>(null); + const [controllingScriptId, setControllingScriptId] = useState(null); + const scriptsRef = useRef([]); + + // Error modal state + const [errorModal, setErrorModal] = useState<{ + isOpen: boolean; + title: string; + message: string; + details?: string; + type?: 'error' | 'success'; + } | null>(null); // Fetch installed scripts const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery(); @@ -82,7 +108,6 @@ export function InstalledScriptsTab() { // Auto-detect LXC containers mutation const autoDetectMutation = api.installedScripts.autoDetectLXCContainers.useMutation({ onSuccess: (data) => { - console.log('Auto-detect success:', data); void refetchScripts(); setShowAutoDetectForm(false); setAutoDetectServerId(''); @@ -120,7 +145,28 @@ export function InstalledScriptsTab() { const containerStatusMutation = api.installedScripts.getContainerStatuses.useMutation({ onSuccess: (data) => { if (data.success) { - setContainerStatuses(data.statusMap); + + // Map container IDs to script IDs + const currentScripts = scriptsRef.current; + const statusMap = new Map(); + + // For each script, find its container status + currentScripts.forEach(script => { + if (script.container_id && data.statusMap) { + const containerStatus = (data.statusMap as Record)[script.container_id]; + if (containerStatus) { + statusMap.set(script.id, containerStatus); + } else { + statusMap.set(script.id, 'unknown'); + } + } else { + statusMap.set(script.id, 'unknown'); + } + }); + + setContainerStatuses(statusMap); + } else { + console.error('Container status fetch failed:', data.error); } }, onError: (error) => { @@ -131,7 +177,6 @@ export function InstalledScriptsTab() { // Cleanup orphaned scripts mutation const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({ onSuccess: (data) => { - console.log('Cleanup success:', data); void refetchScripts(); if (data.deletedCount > 0) { @@ -159,76 +204,149 @@ export function InstalledScriptsTab() { } }); + // Container control mutations + // Note: getStatusMutation removed - using direct API calls instead + + const controlContainerMutation = api.installedScripts.controlContainer.useMutation({ + onSuccess: (data, variables) => { + setControllingScriptId(null); + + if (data.success) { + // Update container status immediately in UI for instant feedback + const newStatus = variables.action === 'start' ? 'running' : 'stopped'; + setContainerStatuses(prev => { + const newMap = new Map(prev); + // Find the script ID for this container using the container ID from the response + const currentScripts = scriptsRef.current; + const script = currentScripts.find(s => s.container_id === data.containerId); + if (script) { + newMap.set(script.id, newStatus); + } + return newMap; + }); + + // Show success modal + setErrorModal({ + isOpen: true, + title: `Container ${variables.action === 'start' ? 'Started' : 'Stopped'}`, + message: data.message ?? `Container has been ${variables.action === 'start' ? 'started' : 'stopped'} successfully.`, + details: undefined, + type: 'success' + }); + + // Re-fetch status for all containers using bulk method (in background) + fetchContainerStatuses(); + } else { + // Show error message from backend + const errorMessage = data.error ?? 'Unknown error occurred'; + setErrorModal({ + isOpen: true, + title: 'Container Control Failed', + message: 'Failed to control the container. Please check the error details below.', + details: errorMessage + }); + } + }, + onError: (error) => { + console.error('Container control error:', error); + setControllingScriptId(null); + + // Show detailed error message + const errorMessage = error.message ?? 'Unknown error occurred'; + setErrorModal({ + isOpen: true, + title: 'Container Control Failed', + message: 'An unexpected error occurred while controlling the container.', + details: errorMessage + }); + } + }); + + const destroyContainerMutation = api.installedScripts.destroyContainer.useMutation({ + onSuccess: (data) => { + setControllingScriptId(null); + + if (data.success) { + void refetchScripts(); + setErrorModal({ + isOpen: true, + title: 'Container Destroyed', + message: data.message ?? 'The container has been successfully destroyed and removed from the database.', + details: undefined, + type: 'success' + }); + } else { + // Show error message from backend + const errorMessage = data.error ?? 'Unknown error occurred'; + setErrorModal({ + isOpen: true, + title: 'Container Destroy Failed', + message: 'Failed to destroy the container. Please check the error details below.', + details: errorMessage + }); + } + }, + onError: (error) => { + console.error('Container destroy error:', error); + setControllingScriptId(null); + + // Show detailed error message + const errorMessage = error.message ?? 'Unknown error occurred'; + setErrorModal({ + isOpen: true, + title: 'Container Destroy Failed', + message: 'An unexpected error occurred while destroying the container.', + details: errorMessage + }); + } + }); + const scripts: InstalledScript[] = useMemo(() => (scriptsData?.scripts as InstalledScript[]) ?? [], [scriptsData?.scripts]); const stats = statsData?.stats; - // Function to fetch container statuses + // Update ref when scripts change + useEffect(() => { + scriptsRef.current = scripts; + }, [scripts]); + + // Function to fetch container statuses - simplified to just check all servers const fetchContainerStatuses = useCallback(() => { - console.log('Fetching container statuses...', { scriptsCount: scripts.length }); - const containersWithIds = scripts - .filter(script => script.container_id) - .map(script => ({ - containerId: script.container_id!, - serverId: script.server_id ?? undefined, - server: script.server_id ? { - id: script.server_id, - name: script.server_name!, - ip: script.server_ip!, - user: script.server_user!, - password: script.server_password!, - auth_type: 'password' // Default to password auth - } : undefined - })); - - console.log('Containers to check:', containersWithIds.length); - if (containersWithIds.length > 0) { - containerStatusMutation.mutate({ containers: containersWithIds }); + const currentScripts = scriptsRef.current; + + // Get unique server IDs from scripts + const serverIds = [...new Set(currentScripts + .filter(script => script.server_id) + .map(script => script.server_id!))]; + + if (serverIds.length > 0) { + containerStatusMutation.mutate({ serverIds }); } - }, [scripts, containerStatusMutation]); + }, []); // Empty dependency array to prevent infinite loops // Run cleanup when component mounts and scripts are loaded (only once) useEffect(() => { if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) { - console.log('Running automatic cleanup check...'); cleanupRunRef.current = true; void cleanupMutation.mutate(); } }, [scripts.length, serversData?.servers, cleanupMutation]); - // Auto-refresh container statuses every 60 seconds - useEffect(() => { - if (scripts.length > 0) { - fetchContainerStatuses(); // Initial fetch - const interval = setInterval(fetchContainerStatuses, 60000); // Every 60 seconds - return () => clearInterval(interval); - } - }, [scripts.length, fetchContainerStatuses]); - // Trigger status check when component becomes visible (tab is active) - useEffect(() => { - if (scripts.length > 0) { - // Small delay to ensure component is fully rendered - const timeoutId = setTimeout(() => { - fetchContainerStatuses(); - }, 100); - - return () => clearTimeout(timeoutId); - } - }, [scripts.length, fetchContainerStatuses]); // Include dependencies - // Also trigger status check when scripts data loads + // Note: Individual status fetching removed - using bulk fetchContainerStatuses instead + + // Trigger status check when tab becomes active (component mounts) useEffect(() => { - if (scripts.length > 0 && !isLoading) { - console.log('Scripts data loaded, triggering status check'); + if (scripts.length > 0) { fetchContainerStatuses(); } - }, [scriptsData, isLoading, scripts.length, fetchContainerStatuses]); + }, [scripts.length]); // Only depend on scripts.length to prevent infinite loops // Update scripts with container statuses const scriptsWithStatus = scripts.map(script => ({ ...script, - container_status: script.container_id ? containerStatuses[script.container_id] ?? 'unknown' : undefined + container_status: script.container_id ? containerStatuses.get(script.id) ?? 'unknown' : undefined })); // Filter and sort scripts @@ -323,31 +441,86 @@ export function InstalledScriptsTab() { } }; - const handleUpdateScript = (script: InstalledScript) => { + // Container control handlers + const handleStartStop = (script: InstalledScript, action: 'start' | 'stop') => { if (!script.container_id) { alert('No Container ID available for this script'); return; } - - if (confirm(`Are you sure you want to update ${script.script_name}?`)) { - // Get server info if it's SSH mode - let server = null; - if (script.server_id && script.server_user && script.server_password) { - server = { - id: script.server_id, - name: script.server_name, - ip: script.server_ip, - user: script.server_user, - password: script.server_password - }; + + setConfirmationModal({ + isOpen: true, + variant: 'simple', + title: `${action === 'start' ? 'Start' : 'Stop'} Container`, + message: `Are you sure you want to ${action} container ${script.container_id} (${script.script_name})?`, + onConfirm: () => { + setControllingScriptId(script.id); + void controlContainerMutation.mutate({ id: script.id, action }); + setConfirmationModal(null); } - - setUpdatingScript({ - id: script.id, - containerId: script.container_id, - server: server + }); + }; + + const handleDestroy = (script: InstalledScript) => { + if (!script.container_id) { + alert('No Container ID available for this script'); + return; + } + + setConfirmationModal({ + isOpen: true, + variant: 'danger', + title: 'Destroy Container', + message: `This will permanently destroy the LXC container ${script.container_id} (${script.script_name}) and all its data. This action cannot be undone!`, + confirmText: script.container_id, + onConfirm: () => { + setControllingScriptId(script.id); + void destroyContainerMutation.mutate({ id: script.id }); + setConfirmationModal(null); + } + }); + }; + + const handleUpdateScript = (script: InstalledScript) => { + if (!script.container_id) { + setErrorModal({ + isOpen: true, + title: 'Update Failed', + message: 'No Container ID available for this script', + details: 'This script does not have a valid container ID and cannot be updated.' }); + return; } + + // Show confirmation modal with type-to-confirm for update + setConfirmationModal({ + isOpen: true, + title: 'Confirm Script Update', + message: `Are you sure you want to update "${script.script_name}"?\n\n⚠️ WARNING: This will update the script and may affect the container. Consider backing up your data beforehand.`, + variant: 'danger', + confirmText: script.container_id, + confirmButtonText: 'Update Script', + onConfirm: () => { + // Get server info if it's SSH mode + let server = null; + if (script.server_id && script.server_user && script.server_password) { + server = { + id: script.server_id, + name: script.server_name, + ip: script.server_ip, + user: script.server_user, + password: script.server_password + }; + } + + setUpdatingScript({ + id: script.id, + containerId: script.container_id!, + server: server + }); + setConfirmationModal(null); + } + }); }; const handleCloseUpdateTerminal = () => { @@ -369,7 +542,12 @@ export function InstalledScriptsTab() { const handleSaveEdit = () => { if (!editFormData.script_name.trim()) { - alert('Script name is required'); + setErrorModal({ + isOpen: true, + title: 'Validation Error', + message: 'Script name is required', + details: 'Please enter a valid script name before saving.' + }); return; } @@ -427,7 +605,6 @@ export function InstalledScriptsTab() { } setAutoDetectStatus({ type: null, message: '' }); - console.log('Starting auto-detect for server ID:', autoDetectServerId); autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) }); }; @@ -798,6 +975,10 @@ export function InstalledScriptsTab() { onDelete={() => handleDeleteScript(Number(script.id))} isUpdating={updateScriptMutation.isPending} isDeleting={deleteScriptMutation.isPending} + containerStatus={containerStatuses.get(script.id) ?? 'unknown'} + onStartStop={(action) => handleStartStop(script, action)} + onDestroy={() => handleDestroy(script)} + isControlling={controllingScriptId === script.id} /> ))} @@ -993,18 +1174,43 @@ export function InstalledScriptsTab() { onClick={() => handleUpdateScript(script)} variant="update" size="sm" + disabled={containerStatuses.get(script.id) === 'stopped'} > Update )} - + {/* Container Control Buttons - only show for SSH scripts with container_id */} + {script.container_id && script.execution_mode === 'ssh' && ( + <> + + + + )} + {/* Fallback to old Delete button for non-SSH scripts */} + {(!script.container_id || script.execution_mode !== 'ssh') && ( + + )} )} @@ -1017,6 +1223,31 @@ export function InstalledScriptsTab() { )} + + {/* Confirmation Modal */} + {confirmationModal && ( + setConfirmationModal(null)} + onConfirm={confirmationModal.onConfirm} + title={confirmationModal.title} + message={confirmationModal.message} + variant={confirmationModal.variant} + confirmText={confirmationModal.confirmText} + /> + )} + + {/* Error/Success Modal */} + {errorModal && ( + setErrorModal(null)} + title={errorModal.title} + message={errorModal.message} + details={errorModal.details} + type={errorModal.type ?? 'error'} + /> + )} ); } diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx index 1852ddc..8c49e94 100644 --- a/src/app/_components/ScriptInstallationCard.tsx +++ b/src/app/_components/ScriptInstallationCard.tsx @@ -18,6 +18,7 @@ interface InstalledScript { installation_date: string; status: 'in_progress' | 'success' | 'failed'; output_log: string | null; + execution_mode: 'local' | 'ssh'; container_status?: 'running' | 'stopped' | 'unknown'; } @@ -33,6 +34,11 @@ interface ScriptInstallationCardProps { onDelete: () => void; isUpdating: boolean; isDeleting: boolean; + // New container control props + containerStatus?: 'running' | 'stopped' | 'unknown'; + onStartStop: (action: 'start' | 'stop') => void; + onDestroy: () => void; + isControlling: boolean; } export function ScriptInstallationCard({ @@ -46,7 +52,11 @@ export function ScriptInstallationCard({ onUpdate, onDelete, isUpdating, - isDeleting + isDeleting, + containerStatus, + onStartStop, + onDestroy, + isControlling }: ScriptInstallationCardProps) { const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString(); @@ -188,19 +198,46 @@ export function ScriptInstallationCard({ variant="update" size="sm" className="flex-1 min-w-0" + disabled={containerStatus === 'stopped'} > Update )} - + {/* Container Control Buttons - only show for SSH scripts with container_id */} + {script.container_id && script.execution_mode === 'ssh' && ( + <> + + + + )} + {/* Fallback to old Delete button for non-SSH scripts */} + {(!script.container_id || script.execution_mode !== 'ssh') && ( + + )} )} diff --git a/src/app/_components/ScriptsGrid.tsx b/src/app/_components/ScriptsGrid.tsx index 59873ba..14c00ff 100644 --- a/src/app/_components/ScriptsGrid.tsx +++ b/src/app/_components/ScriptsGrid.tsx @@ -452,7 +452,6 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { successful.push({ slug: slug ?? '', files: result.files ?? [] }); } else { const error = 'error' in result ? result.error : 'Failed to load script'; - console.log(`Script ${slug} failed with error:`, error, 'Full result:', result); const userFriendlyError = getFriendlyErrorMessage(error, slug ?? ''); failed.push({ slug: slug ?? '', error: userFriendlyError }); } diff --git a/src/app/_components/VersionDisplay.tsx b/src/app/_components/VersionDisplay.tsx index 5b67bc5..5fc6660 100644 --- a/src/app/_components/VersionDisplay.tsx +++ b/src/app/_components/VersionDisplay.tsx @@ -142,7 +142,6 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) { - console.log('Fallback: Assuming server restart due to long silence'); setIsNetworkError(true); setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']); diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index 7a9718f..fd30874 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -1,100 +1,8 @@ import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { getDatabase } from "~/server/database"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getSSHExecutionService } from "~/server/ssh-execution-service"; -import type { Server } from "~/types/server"; - -const execAsync = promisify(exec); - -// Helper function to check local container statuses -async function getLocalContainerStatuses(containerIds: string[]): Promise> { - try { - const { stdout } = await execAsync('pct list'); - const statusMap: Record = {}; - - // Parse pct list output - const lines = stdout.trim().split('\n'); - const dataLines = lines.slice(1); // Skip header - - for (const line of dataLines) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 2) { - const vmid = parts[0]; - const status = parts[1]; - - if (vmid && containerIds.includes(vmid)) { - statusMap[vmid] = status === 'running' ? 'running' : 'stopped'; - } - } - } - - // Set unknown for containers not found in pct list - for (const containerId of containerIds) { - if (!(containerId in statusMap)) { - statusMap[containerId] = 'unknown'; - } - } - - return statusMap; - } catch (error) { - console.error('Error checking local container statuses:', error); - // Return unknown for all containers on error - const statusMap: Record = {}; - for (const containerId of containerIds) { - statusMap[containerId] = 'unknown'; - } - return statusMap; - } -} - -// Helper function to check remote container statuses (multiple containers per server) -async function getRemoteContainerStatuses(containerIds: string[], server: Server): Promise> { - return new Promise((resolve) => { - const sshService = getSSHExecutionService(); - const statusMap: Record = {}; - - // Initialize all containers as unknown - for (const containerId of containerIds) { - statusMap[containerId] = 'unknown'; - } - - void sshService.executeCommand( - server, - 'pct list', - (data: string) => { - // Parse the output to find all containers - const lines = data.trim().split('\n'); - const dataLines = lines.slice(1); // Skip header - - for (const line of dataLines) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 2) { - const vmid = parts[0]; - const status = parts[1]; - - // Check if this is one of the containers we're looking for - if (vmid && containerIds.includes(vmid)) { - statusMap[vmid] = status === 'running' ? 'running' : 'stopped'; - } - } - } - - resolve(statusMap); - }, - (error: string) => { - console.error(`Error checking remote containers on server ${server.name}:`, error); - resolve(statusMap); // Return the map with unknown statuses - }, - (exitCode: number) => { - if (exitCode !== 0) { - resolve(statusMap); // Return the map with unknown statuses - } - } - ); - }); -} +// Removed unused imports + export const installedScriptsRouter = createTRPCRouter({ // Get all installed scripts @@ -303,12 +211,8 @@ export const installedScriptsRouter = createTRPCRouter({ autoDetectLXCContainers: publicProcedure .input(z.object({ serverId: z.number() })) .mutation(async ({ input }) => { - console.log('=== AUTO-DETECT API ENDPOINT CALLED ==='); - console.log('Input received:', input); - console.log('Timestamp:', new Date().toISOString()); try { - console.log('Starting auto-detect LXC containers for server ID:', input.serverId); const db = getDatabase(); const server = db.getServerById(input.serverId); @@ -322,7 +226,6 @@ export const installedScriptsRouter = createTRPCRouter({ }; } - console.log('Found server:', (server as any).name, 'at', (server as any).ip); // Import SSH services const { default: SSHService } = await import('~/server/ssh-service'); @@ -331,10 +234,8 @@ export const installedScriptsRouter = createTRPCRouter({ const sshExecutionService = new SSHExecutionService(); // Test SSH connection first - console.log('Testing SSH connection...'); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const connectionTest = await sshService.testSSHConnection(server as any); - console.log('SSH connection test result:', connectionTest); if (!(connectionTest as any).success) { return { @@ -344,14 +245,11 @@ export const installedScriptsRouter = createTRPCRouter({ }; } - console.log('SSH connection successful, scanning for LXC containers...'); // 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`; let detectedContainers: any[] = []; - console.log('Executing manual loop command...'); - console.log('Command:', command); let commandOutput = ''; @@ -367,8 +265,7 @@ export const installedScriptsRouter = createTRPCRouter({ (error: string) => { console.error('Command error:', error); }, - (exitCode: number) => { - console.log('Command exit code:', exitCode); + (_exitCode: number) => { // Parse the complete output to get config file paths that contain community-script tag const configFiles = commandOutput.split('\n') @@ -376,8 +273,6 @@ export const installedScriptsRouter = createTRPCRouter({ .map((line: string) => line.trim()) .filter((line: string) => line.endsWith('.conf')); - console.log('Found config files with community-script tag:', configFiles.length); - console.log('Config files:', configFiles); // Process each config file to extract hostname const processPromises = configFiles.map(async (configPath: string) => { @@ -385,7 +280,6 @@ export const installedScriptsRouter = createTRPCRouter({ const containerId = configPath.split('/').pop()?.replace('.conf', ''); if (!containerId) return null; - console.log('Processing container:', containerId, 'from', configPath); // Read the config file content const readCommand = `cat "${configPath}" 2>/dev/null`; @@ -415,13 +309,11 @@ export const installedScriptsRouter = createTRPCRouter({ containerId, hostname, configPath, - serverId: (server as any).id, + serverId: Number((server as any).id), serverName: (server as any).name }; - console.log('Adding container to detected list:', container); readResolve(container); } else { - console.log('No hostname found for', containerId); readResolve(null); } }, @@ -443,7 +335,6 @@ export const installedScriptsRouter = createTRPCRouter({ // Wait for all config files to be processed void Promise.all(processPromises).then((results) => { detectedContainers = results.filter(result => result !== null); - console.log('Final detected containers:', detectedContainers.length); resolve(); }).catch((error) => { console.error('Error processing config files:', error); @@ -453,7 +344,6 @@ export const installedScriptsRouter = createTRPCRouter({ ); }); - console.log('Detected containers:', detectedContainers.length); // Get existing scripts to check for duplicates const existingScripts = db.getAllInstalledScripts(); @@ -471,7 +361,6 @@ export const installedScriptsRouter = createTRPCRouter({ ); if (duplicate) { - console.log(`Skipping duplicate: ${container.hostname} (${container.containerId}) already exists`); skippedScripts.push({ containerId: container.containerId, hostname: container.hostname, @@ -480,7 +369,6 @@ export const installedScriptsRouter = createTRPCRouter({ continue; } - console.log('Creating script record for:', container.hostname, container.containerId); const result = db.createInstalledScript({ script_name: container.hostname, script_path: `detected/${container.hostname}`, @@ -497,7 +385,6 @@ export const installedScriptsRouter = createTRPCRouter({ hostname: container.hostname, serverName: container.serverName }); - console.log('Created script record with ID:', result.lastInsertRowid); } catch (error) { console.error(`Error creating script record for ${container.hostname}:`, error); } @@ -527,15 +414,11 @@ export const installedScriptsRouter = createTRPCRouter({ cleanupOrphanedScripts: publicProcedure .mutation(async () => { try { - console.log('=== CLEANUP ORPHANED SCRIPTS API ENDPOINT CALLED ==='); - console.log('Timestamp:', new Date().toISOString()); const db = getDatabase(); const allScripts = db.getAllInstalledScripts(); const allServers = db.getAllServers(); - console.log('Found scripts:', allScripts.length); - console.log('Found servers:', allServers.length); if (allScripts.length === 0) { return { @@ -559,26 +442,22 @@ export const installedScriptsRouter = createTRPCRouter({ script.container_id ); - console.log('Scripts to check for cleanup:', scriptsToCheck.length); for (const script of scriptsToCheck) { try { const scriptData = script as any; const server = allServers.find((s: any) => s.id === scriptData.server_id); if (!server) { - console.log(`Server not found for script ${scriptData.script_name}, marking for deletion`); db.deleteInstalledScript(Number(scriptData.id)); deletedScripts.push(String(scriptData.script_name)); continue; } - console.log(`Checking script ${scriptData.script_name} on server ${(server as any).name}`); // Test SSH connection // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const connectionTest = await sshService.testSSHConnection(server as any); if (!(connectionTest as any).success) { - console.log(`SSH connection failed for server ${(server as any).name}, skipping script ${scriptData.script_name}`); continue; } @@ -605,11 +484,9 @@ export const installedScriptsRouter = createTRPCRouter({ }); if (!containerExists) { - console.log(`Container ${scriptData.container_id} not found on server ${(server as any).name}, deleting script ${scriptData.script_name}`); db.deleteInstalledScript(Number(scriptData.id)); deletedScripts.push(String(scriptData.script_name)); } else { - console.log(`Container ${scriptData.container_id} still exists on server ${(server as any).name}, keeping script ${scriptData.script_name}`); } } catch (error) { @@ -617,7 +494,6 @@ export const installedScriptsRouter = createTRPCRouter({ } } - console.log('Cleanup completed. Deleted scripts:', deletedScripts); return { success: true, @@ -639,88 +515,454 @@ export const installedScriptsRouter = createTRPCRouter({ // Get container running statuses getContainerStatuses: publicProcedure .input(z.object({ - containers: z.array(z.object({ - containerId: z.string(), - serverId: z.number().optional(), - server: z.object({ - id: z.number(), - name: z.string(), - ip: z.string(), - user: z.string(), - password: z.string(), - auth_type: z.string() - }).optional() - })) + serverIds: z.array(z.number()).optional() // Optional: check specific servers, or all if empty })) .mutation(async ({ input }) => { try { - const { containers } = input; - const statusMap: Record = {}; - - // Group containers by server (local vs remote) - const localContainers: string[] = []; - const remoteContainers: Array<{containerId: string, server: any}> = []; - for (const container of containers) { - if (!container.serverId || !container.server) { - localContainers.push(container.containerId); - } else { - remoteContainers.push({ - containerId: container.containerId, - server: container.server + const db = getDatabase(); + const allServers = db.getAllServers(); + const statusMap: Record = {}; + + // 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(); + + // Determine which servers to check + const serversToCheck = input.serverIds + ? allServers.filter((s: any) => input.serverIds!.includes(Number(s.id))) + : allServers; + + + // Check status for each server + for (const server of serversToCheck) { + try { + + // Test SSH connection + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const connectionTest = await sshService.testSSHConnection(server as any); + if (!(connectionTest as any).success) { + continue; + } + + // Run pct list to get all container statuses at once + const listCommand = 'pct list'; + let listOutput = ''; + + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + server as any, + 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(); + } + ); }); + + // 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; + } + } + } + } catch (error) { + console.error(`Error processing server ${(server as any).name}:`, error); } } + + + return { + success: true, + statusMap + }; + } catch (error) { + console.error('Error in getContainerStatuses:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch container statuses', + statusMap: {} + }; + } + }), + + // Get container status (running/stopped) + getContainerStatus: publicProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const script = db.getInstalledScriptById(input.id); - // Check local containers - if (localContainers.length > 0) { - const localStatuses = await getLocalContainerStatuses(localContainers); - Object.assign(statusMap, localStatuses); + if (!script) { + return { + success: false, + error: 'Script not found', + status: 'unknown' as const + }; } + + const scriptData = script as any; - // Check remote containers - group by server and make one call per server - const serverGroups: Record> = {}; + // Only check status for SSH scripts with container_id + if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) { + return { + success: false, + error: 'Script is not an SSH script with container ID', + status: 'unknown' as const + }; + } + + // Get server info + const server = db.getServerById(Number(scriptData.server_id)); + if (!server) { + return { + success: false, + error: 'Server not found', + status: 'unknown' as const + }; + } + + // 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 + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const connectionTest = await sshService.testSSHConnection(server as any); + if (!(connectionTest as any).success) { + return { + success: false, + error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`, + status: 'unknown' as const + }; + } + + // Check container status + const statusCommand = `pct status ${scriptData.container_id}`; + let statusOutput = ''; - for (const { containerId, server } of remoteContainers) { - const serverKey = `${server.id}-${server.name}`; - serverGroups[serverKey] ??= []; - serverGroups[serverKey].push({ containerId, server }); + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + server as any, + statusCommand, + (data: string) => { + statusOutput += data; + }, + (error: string) => { + console.error('Status command error:', error); + reject(new Error(error)); + }, + (_exitCode: number) => { + resolve(); + } + ); + }); + + // Parse status from output + let status: 'running' | 'stopped' | 'unknown' = 'unknown'; + if (statusOutput.includes('status: running')) { + status = 'running'; + } else if (statusOutput.includes('status: stopped')) { + status = 'stopped'; } + + return { + success: true, + status, + error: undefined + }; + } catch (error) { + console.error('Error in getContainerStatus:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get container status', + status: 'unknown' as const + }; + } + }), + + // Control container (start/stop) + controlContainer: publicProcedure + .input(z.object({ + id: z.number(), + action: z.enum(['start', 'stop']) + })) + .mutation(async ({ input }) => { + try { + const db = getDatabase(); + const script = db.getInstalledScriptById(input.id); - // Make one call per server - for (const [serverKey, containers] of Object.entries(serverGroups)) { - try { - if (containers.length === 0) continue; - - const server = containers[0]?.server; - if (!server) continue; - - const containerIds = containers.map(c => c.containerId).filter(Boolean); - const serverStatuses = await getRemoteContainerStatuses(containerIds, server as Server); - - // Merge the results - Object.assign(statusMap, serverStatuses); - } catch (error) { - console.error(`Error checking statuses for server ${serverKey}:`, error); - // Set all containers for this server to unknown - for (const container of containers) { - if (container.containerId) { - statusMap[container.containerId] = 'unknown'; + if (!script) { + return { + success: false, + error: 'Script not found' + }; + } + + const scriptData = script as any; + + // Only control SSH scripts with container_id + if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) { + return { + success: false, + error: 'Script is not an SSH script with container ID' + }; + } + + // Get server info + const server = db.getServerById(Number(scriptData.server_id)); + if (!server) { + return { + success: false, + error: '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 first + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const connectionTest = await sshService.testSSHConnection(server as any); + if (!(connectionTest as any).success) { + return { + success: false, + error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}` + }; + } + + // Execute control command + const controlCommand = `pct ${input.action} ${scriptData.container_id}`; + let commandOutput = ''; + let commandError = ''; + + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + server as any, + controlCommand, + (data: string) => { + commandOutput += data; + }, + (error: string) => { + commandError += error; + }, + (exitCode: number) => { + if (exitCode !== 0) { + const errorMessage = commandError || commandOutput || `Command failed with exit code ${exitCode}`; + reject(new Error(errorMessage)); + } else { + resolve(); } } + ); + }); + + return { + success: true, + message: `Container ${scriptData.container_id} ${input.action} command executed successfully`, + containerId: scriptData.container_id + }; + } catch (error) { + console.error('Error in controlContainer:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to control container' + }; + } + }), + + // Destroy container and delete DB record + destroyContainer: publicProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + try { + const db = getDatabase(); + const script = db.getInstalledScriptById(input.id); + + if (!script) { + return { + success: false, + error: 'Script not found' + }; + } + + const scriptData = script as any; + + // Only destroy SSH scripts with container_id + if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) { + return { + success: false, + error: 'Script is not an SSH script with container ID' + }; + } + + // Get server info + const server = db.getServerById(Number(scriptData.server_id)); + if (!server) { + return { + success: false, + error: '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 first + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const connectionTest = await sshService.testSSHConnection(server as any); + if (!(connectionTest as any).success) { + return { + success: false, + error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}` + }; + } + + // First check if container is running and stop it if necessary + const statusCommand = `pct status ${scriptData.container_id}`; + let statusOutput = ''; + + try { + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + server as any, + statusCommand, + (data: string) => { + statusOutput += data; + }, + (error: string) => { + reject(new Error(error)); + }, + (_exitCode: number) => { + resolve(); + } + ); + }); + + // Check if container is running + if (statusOutput.includes('status: running')) { + // Stop the container first + const stopCommand = `pct stop ${scriptData.container_id}`; + let stopOutput = ''; + let stopError = ''; + + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + server as any, + stopCommand, + (data: string) => { + stopOutput += data; + }, + (error: string) => { + stopError += error; + }, + (exitCode: number) => { + if (exitCode !== 0) { + const errorMessage = stopError || stopOutput || `Stop command failed with exit code ${exitCode}`; + reject(new Error(`Failed to stop container: ${errorMessage}`)); + } else { + resolve(); + } + } + ); + }); } + } catch (_error) { + // If status check fails, continue with destroy attempt + // The destroy command will handle the error appropriately } + + // Execute destroy command + const destroyCommand = `pct destroy ${scriptData.container_id}`; + let commandOutput = ''; + let commandError = ''; + + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + server as any, + destroyCommand, + (data: string) => { + commandOutput += data; + }, + (error: string) => { + commandError += error; + }, + (exitCode: number) => { + if (exitCode !== 0) { + const errorMessage = commandError || commandOutput || `Destroy command failed with exit code ${exitCode}`; + reject(new Error(errorMessage)); + } else { + resolve(); + } + } + ); + }); + + // If destroy was successful, delete the database record + const deleteResult = db.deleteInstalledScript(input.id); + if (deleteResult.changes === 0) { + return { + success: false, + error: 'Container destroyed but failed to delete database record' + }; + } + + // Determine if container was stopped first + const wasStopped = statusOutput.includes('status: running'); + const message = wasStopped + ? `Container ${scriptData.container_id} stopped and destroyed successfully, database record deleted` + : `Container ${scriptData.container_id} destroyed successfully, database record deleted`; + return { success: true, - statusMap + message }; } catch (error) { - console.error('Error in getContainerStatuses:', error); + console.error('Error in destroyContainer:', error); return { success: false, - error: error instanceof Error ? error.message : 'Failed to fetch container statuses', - statusMap: {} + error: error instanceof Error ? error.message : 'Failed to destroy container' }; } })