'use client'; import { useState, useEffect, useRef, useMemo } from 'react'; import { api } from '~/trpc/react'; 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 { LoadingModal } from './LoadingModal'; import { LXCSettingsModal } from './LXCSettingsModal'; import { StorageSelectionModal } from './StorageSelectionModal'; import { BackupWarningModal } from './BackupWarningModal'; import type { Storage } from '~/server/services/storageService'; import { getContrastColor } from '../../lib/colorUtils'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from './ui/dropdown-menu'; import { Settings } from 'lucide-react'; interface InstalledScript { id: number; script_name: string; script_path: string; container_id: string | null; server_id: number | null; server_name: string | null; server_ip: string | null; server_user: string | null; server_password: string | null; server_auth_type: string | null; server_ssh_key: string | null; server_ssh_key_passphrase: string | null; server_ssh_port: number | null; server_color: string | null; installation_date: string; status: 'in_progress' | 'success' | 'failed'; output_log: string | null; execution_mode: 'local' | 'ssh'; container_status?: 'running' | 'stopped' | 'unknown'; web_ui_ip: string | null; web_ui_port: number | null; is_vm?: boolean; } export function InstalledScriptsTab() { const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all'); const [serverFilter, setServerFilter] = useState('all'); const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; backupStorage?: string; isBackupOnly?: boolean } | null>(null); const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null); const [showBackupPrompt, setShowBackupPrompt] = useState(false); const [showStorageSelection, setShowStorageSelection] = useState(false); const [pendingUpdateScript, setPendingUpdateScript] = useState(null); const [backupStorages, setBackupStorages] = useState([]); 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 [editingScriptId, setEditingScriptId] = useState(null); const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); const [showAddForm, setShowAddForm] = useState(false); const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' }); const [showAutoDetectForm, setShowAutoDetectForm] = useState(false); const [autoDetectServerId, setAutoDetectServerId] = useState(''); 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); // 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([]); const statusCheckTimeoutRef = useRef(null); // Error modal state const [errorModal, setErrorModal] = useState<{ isOpen: boolean; title: string; message: string; details?: string; type?: 'error' | 'success'; } | null>(null); // Loading modal state const [loadingModal, setLoadingModal] = useState<{ isOpen: boolean; action: string; } | null>(null); // LXC Settings modal state const [lxcSettingsModal, setLxcSettingsModal] = useState<{ isOpen: boolean; script: InstalledScript | null; }>({ isOpen: false, script: null }); // Fetch installed scripts const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery(); const { data: statsData } = api.installedScripts.getInstallationStats.useQuery(); const { data: serversData } = api.servers.getAllServers.useQuery(); // Delete script mutation const deleteScriptMutation = api.installedScripts.deleteInstalledScript.useMutation({ onSuccess: () => { void refetchScripts(); } }); // Update script mutation const updateScriptMutation = api.installedScripts.updateInstalledScript.useMutation({ onSuccess: () => { void refetchScripts(); setEditingScriptId(null); setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); }, onError: (error) => { alert(`Error updating script: ${error.message}`); } }); // Create script mutation const createScriptMutation = api.installedScripts.createInstalledScript.useMutation({ onSuccess: () => { void refetchScripts(); setShowAddForm(false); setAddFormData({ script_name: '', container_id: '', server_id: 'local' }); }, onError: (error) => { alert(`Error creating script: ${error.message}`); } }); // Auto-detect LXC containers mutation const autoDetectMutation = api.installedScripts.autoDetectLXCContainers.useMutation({ onSuccess: (data) => { void refetchScripts(); setShowAutoDetectForm(false); setAutoDetectServerId(''); // Show detailed message about what was added/skipped let statusMessage = data.message ?? 'Auto-detection completed successfully!'; if (data.skippedContainers && data.skippedContainers.length > 0) { const skippedNames = data.skippedContainers.map((c: any) => String(c.hostname)).join(', '); statusMessage += ` Skipped duplicates: ${skippedNames}`; } setAutoDetectStatus({ type: 'success', message: statusMessage }); // Clear status after 8 seconds (longer for detailed info) setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000); }, onError: (error) => { console.error('Auto-detect mutation error:', error); console.error('Error details:', { message: error.message, data: error.data }); setAutoDetectStatus({ type: 'error', message: error.message ?? 'Auto-detection failed. Please try again.' }); // Clear status after 5 seconds setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000); } }); // Get container statuses mutation const containerStatusMutation = api.installedScripts.getContainerStatuses.useMutation({ onSuccess: (data) => { if (data.success) { // 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) => { console.error('Error fetching container statuses:', error); } }); // Ref for container status mutation to avoid dependency loops const containerStatusMutationRef = useRef(containerStatusMutation); // Cleanup orphaned scripts mutation const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({ onSuccess: (data) => { void refetchScripts(); if (data.deletedCount > 0) { setCleanupStatus({ type: 'success', message: `Cleanup completed! Removed ${data.deletedCount} orphaned script(s): ${data.deletedScripts.join(', ')}` }); } else { setCleanupStatus({ type: 'success', message: 'Cleanup completed! No orphaned scripts found.' }); } // Clear status after 8 seconds (longer for cleanup info) setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000); }, onError: (error) => { console.error('Cleanup mutation error:', error); setCleanupStatus({ type: 'error', message: error.message ?? 'Cleanup failed. Please try again.' }); // Clear status after 5 seconds setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000); } }); // Auto-detect Web UI mutation const autoDetectWebUIMutation = api.installedScripts.autoDetectWebUI.useMutation({ onSuccess: (data) => { console.log('โœ… Auto-detect WebUI success:', data); void refetchScripts(); setAutoDetectStatus({ type: 'success', message: data.success ? `Detected IP: ${data.detectedIp}:${data.detectedPort}` : (data.error ?? 'Failed to detect Web UI') }); setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000); }, onError: (error) => { console.error('โŒ Auto-detect WebUI error:', error); setAutoDetectStatus({ type: 'error', message: error.message ?? 'Failed to detect Web UI' }); setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000); } }); // Get backup storages query const getBackupStoragesQuery = api.installedScripts.getBackupStorages.useQuery( { serverId: pendingUpdateScript?.server_id ?? 0, forceRefresh: false }, { enabled: false } // Only fetch when explicitly called ); const fetchStorages = async (serverId: number, _forceRefresh = false) => { setIsLoadingStorages(true); try { const result = await getBackupStoragesQuery.refetch(); if (result.data?.success) { setBackupStorages(result.data.storages); } else { setErrorModal({ isOpen: true, title: 'Failed to Fetch Storages', message: result.data?.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 { setIsLoadingStorages(false); } }; // Container control mutations // Note: getStatusMutation removed - using direct API calls instead const controlContainerMutation = api.installedScripts.controlContainer.useMutation({ onSuccess: (data, variables) => { setLoadingModal(null); 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) // Trigger status check by updating scripts length dependency // This will be handled by the useEffect that watches scripts.length } 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); setLoadingModal(null); 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) => { setLoadingModal(null); 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); setLoadingModal(null); 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; // Update refs when data changes useEffect(() => { scriptsRef.current = scripts; }, [scripts]); useEffect(() => { containerStatusMutationRef.current = containerStatusMutation; }, [containerStatusMutation]); // Run cleanup when component mounts and scripts are loaded (only once) useEffect(() => { if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) { cleanupRunRef.current = true; void cleanupMutation.mutate(); } }, [scripts.length, serversData?.servers, cleanupMutation]); useEffect(() => { if (scripts.length > 0) { console.log('Status check triggered - scripts length:', scripts.length); // Clear any existing timeout if (statusCheckTimeoutRef.current) { clearTimeout(statusCheckTimeoutRef.current); } // Debounce status checks by 500ms statusCheckTimeoutRef.current = setTimeout(() => { // Prevent multiple simultaneous status checks if (containerStatusMutationRef.current.isPending) { console.log('Status check already pending, skipping'); return; } 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!))]; console.log('Executing status check for server IDs:', serverIds); if (serverIds.length > 0) { containerStatusMutationRef.current.mutate({ serverIds }); } }, 500); } }, [scripts.length]); // Cleanup timeout on unmount useEffect(() => { return () => { if (statusCheckTimeoutRef.current) { clearTimeout(statusCheckTimeoutRef.current); } }; }, []); const scriptsWithStatus = scripts.map(script => ({ ...script, container_status: script.container_id ? containerStatuses.get(script.id) ?? 'unknown' : undefined })); // Filter and sort scripts const filteredScripts = scriptsWithStatus .filter((script: InstalledScript) => { const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) || (script.container_id?.includes(searchTerm) ?? false) || (script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false); const matchesStatus = statusFilter === 'all' || script.status === statusFilter; const matchesServer = serverFilter === 'all' || (serverFilter === 'local' && !script.server_name) || (script.server_name === serverFilter); return matchesSearch && matchesStatus && matchesServer; }) .sort((a: InstalledScript, b: InstalledScript) => { // Default sorting: group by server, then by container ID if (sortField === 'server_name') { const aServer = a.server_name ?? 'Local'; const bServer = b.server_name ?? 'Local'; // First sort by server name if (aServer !== bServer) { return sortDirection === 'asc' ? aServer.localeCompare(bServer) : bServer.localeCompare(aServer); } // If same server, sort by container ID const aContainerId = a.container_id ?? ''; const bContainerId = b.container_id ?? ''; if (aContainerId !== bContainerId) { // Convert to numbers for proper numeric sorting const aNum = parseInt(aContainerId) || 0; const bNum = parseInt(bContainerId) || 0; return sortDirection === 'asc' ? aNum - bNum : bNum - aNum; } return 0; } // For other sort fields, use the original logic let aValue: any; let bValue: any; switch (sortField) { case 'script_name': aValue = a.script_name.toLowerCase(); bValue = b.script_name.toLowerCase(); break; case 'container_id': aValue = a.container_id ?? ''; bValue = b.container_id ?? ''; break; case 'status': aValue = a.status; bValue = b.status; break; case 'installation_date': aValue = new Date(a.installation_date).getTime(); bValue = new Date(b.installation_date).getTime(); break; default: return 0; } if (aValue < bValue) { return sortDirection === 'asc' ? -1 : 1; } if (aValue > bValue) { return sortDirection === 'asc' ? 1 : -1; } return 0; }); // Get unique servers for filter const uniqueServers: string[] = []; const seen = new Set(); for (const script of scripts) { if (script.server_name && !seen.has(String(script.server_name))) { uniqueServers.push(String(script.server_name)); seen.add(String(script.server_name)); } } const handleDeleteScript = (id: number, script?: InstalledScript) => { const scriptToDelete = script ?? scripts.find(s => s.id === id); if (scriptToDelete?.container_id && scriptToDelete.execution_mode === 'ssh') { // For SSH scripts with container_id, use confirmation modal setConfirmationModal({ isOpen: true, variant: 'simple', title: 'Delete Database Record Only', message: `This will only delete the database record for "${scriptToDelete.script_name}" (Container ID: ${scriptToDelete.container_id}).\n\nThe container will remain intact and can be re-detected later via auto-detect.`, onConfirm: () => { void deleteScriptMutation.mutate({ id }); setConfirmationModal(null); } }); } else { // For non-SSH scripts or scripts without container_id, use simple confirm if (confirm('Are you sure you want to delete this installation record?')) { void deleteScriptMutation.mutate({ id }); } } }; // Container control handlers const handleStartStop = (script: InstalledScript, action: 'start' | 'stop') => { if (!script.container_id) { alert('No Container ID available for this script'); return; } 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); setLoadingModal({ isOpen: true, action: `${action === 'start' ? 'Starting' : 'Stopping'} container ${script.container_id}...` }); void controlContainerMutation.mutate({ id: script.id, action }); setConfirmationModal(null); } }); }; 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); setLoadingModal({ isOpen: true, action: `Destroying container ${script.container_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: 'Continue', onConfirm: () => { setConfirmationModal(null); // Store the script for backup flow setPendingUpdateScript(script); // Show backup prompt setShowBackupPrompt(true); } }); }; const handleBackupPromptResponse = (wantsBackup: boolean) => { setShowBackupPrompt(false); if (!pendingUpdateScript) return; if (wantsBackup) { // User wants backup - fetch storages and show selection if (pendingUpdateScript.server_id) { setIsPreUpdateBackup(true); // Mark that this is for pre-update backup void fetchStorages(pendingUpdateScript.server_id, false); setShowStorageSelection(true); } else { setErrorModal({ isOpen: true, title: 'Backup Not Available', message: 'Backup is only available for SSH scripts with a configured server.', type: 'error' }); // Proceed without backup proceedWithUpdate(null); } } else { // User doesn't want backup - proceed directly to update proceedWithUpdate(null); } }; const handleStorageSelected = (storage: Storage) => { setShowStorageSelection(false); // Check if this is for a standalone backup or pre-update backup if (isPreUpdateBackup) { // Pre-update backup - proceed with update setIsPreUpdateBackup(false); // Reset flag proceedWithUpdate(storage.name); } else if (pendingUpdateScript) { // Standalone backup - execute backup directly executeStandaloneBackup(pendingUpdateScript, storage.name); } }; const executeStandaloneBackup = (script: InstalledScript, storageName: string) => { // Get server info let server = null; if (script.server_id && script.server_user) { server = { id: script.server_id, name: script.server_name, ip: script.server_ip, user: script.server_user, password: script.server_password, auth_type: script.server_auth_type ?? 'password', ssh_key: script.server_ssh_key, ssh_key_passphrase: script.server_ssh_key_passphrase, ssh_port: script.server_ssh_port ?? 22 }; } // Start backup terminal setUpdatingScript({ id: script.id, containerId: script.container_id!, server: server, backupStorage: storageName, isBackupOnly: true }); // Reset state setIsPreUpdateBackup(false); // Reset flag setPendingUpdateScript(null); setBackupStorages([]); }; const proceedWithUpdate = (backupStorage: string | null) => { if (!pendingUpdateScript) return; // Get server info if it's SSH mode let server = null; if (pendingUpdateScript.server_id && pendingUpdateScript.server_user) { server = { id: pendingUpdateScript.server_id, name: pendingUpdateScript.server_name, ip: pendingUpdateScript.server_ip, user: pendingUpdateScript.server_user, password: pendingUpdateScript.server_password, auth_type: pendingUpdateScript.server_auth_type ?? 'password', ssh_key: pendingUpdateScript.server_ssh_key, ssh_key_passphrase: pendingUpdateScript.server_ssh_key_passphrase, ssh_port: pendingUpdateScript.server_ssh_port ?? 22 }; } setUpdatingScript({ id: pendingUpdateScript.id, containerId: pendingUpdateScript.container_id!, server: server, backupStorage: backupStorage ?? undefined, isBackupOnly: false // Explicitly set to false for update operations }); // Reset state setPendingUpdateScript(null); setBackupStorages([]); }; const handleCloseUpdateTerminal = () => { setUpdatingScript(null); }; const handleBackupScript = (script: InstalledScript) => { if (!script.container_id) { setErrorModal({ isOpen: true, title: 'Backup Failed', message: 'No Container ID available for this script', details: 'This script does not have a valid container ID and cannot be backed up.' }); return; } if (!script.server_id) { setErrorModal({ isOpen: true, title: 'Backup Not Available', message: 'Backup is only available for SSH scripts with a configured server.', type: 'error' }); return; } // Store the script and fetch storages setIsPreUpdateBackup(false); // This is a standalone backup, not pre-update setPendingUpdateScript(script); void fetchStorages(script.server_id, false); setShowStorageSelection(true); }; const handleOpenShell = (script: InstalledScript) => { if (!script.container_id) { setErrorModal({ isOpen: true, title: 'Shell Access Failed', message: 'No Container ID available for this script', details: 'This script does not have a valid container ID and cannot be accessed via shell.' }); return; } // Get server info if it's SSH mode let server = null; if (script.server_id && script.server_user) { server = { id: script.server_id, name: script.server_name, ip: script.server_ip, user: script.server_user, password: script.server_password, auth_type: script.server_auth_type ?? 'password', ssh_key: script.server_ssh_key, ssh_key_passphrase: script.server_ssh_key_passphrase, ssh_port: script.server_ssh_port ?? 22 }; } setOpeningShell({ id: script.id, containerId: script.container_id, server: server }); }; const handleCloseShellTerminal = () => { setOpeningShell(null); }; // Auto-scroll to terminals when they open useEffect(() => { if (openingShell) { // Small delay to ensure the terminal is rendered setTimeout(() => { const terminalElement = document.querySelector('[data-terminal="shell"]'); if (terminalElement) { // Scroll to the terminal with smooth animation terminalElement.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); // Add a subtle highlight effect terminalElement.classList.add('animate-pulse'); setTimeout(() => { terminalElement.classList.remove('animate-pulse'); }, 2000); } }, 200); } }, [openingShell]); useEffect(() => { if (updatingScript) { // Small delay to ensure the terminal is rendered setTimeout(() => { const terminalElement = document.querySelector('[data-terminal="update"]'); if (terminalElement) { // Scroll to the terminal with smooth animation terminalElement.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); // Add a subtle highlight effect terminalElement.classList.add('animate-pulse'); setTimeout(() => { terminalElement.classList.remove('animate-pulse'); }, 2000); } }, 200); } }, [updatingScript]); const handleEditScript = (script: InstalledScript) => { setEditingScriptId(script.id); setEditFormData({ script_name: script.script_name, container_id: script.container_id ?? '', web_ui_ip: script.web_ui_ip ?? '', web_ui_port: script.web_ui_port?.toString() ?? '' }); }; const handleCancelEdit = () => { setEditingScriptId(null); setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); }; const handleLXCSettings = (script: InstalledScript) => { setLxcSettingsModal({ isOpen: true, script }); }; const handleSaveEdit = () => { if (!editFormData.script_name.trim()) { setErrorModal({ isOpen: true, title: 'Validation Error', message: 'Script name is required', details: 'Please enter a valid script name before saving.' }); return; } if (editingScriptId) { updateScriptMutation.mutate({ id: editingScriptId, script_name: editFormData.script_name.trim(), container_id: editFormData.container_id.trim() || undefined, web_ui_ip: editFormData.web_ui_ip.trim() || undefined, web_ui_port: editFormData.web_ui_port.trim() ? parseInt(editFormData.web_ui_port, 10) : undefined, }); } }; const handleInputChange = (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => { setEditFormData(prev => ({ ...prev, [field]: value })); }; const handleAddFormChange = (field: 'script_name' | 'container_id' | 'server_id', value: string) => { setAddFormData(prev => ({ ...prev, [field]: value })); }; const handleAddScript = () => { if (!addFormData.script_name.trim()) { alert('Script name is required'); return; } createScriptMutation.mutate({ script_name: addFormData.script_name.trim(), script_path: `manual/${addFormData.script_name.trim()}`, container_id: addFormData.container_id.trim() || undefined, server_id: addFormData.server_id === 'local' ? undefined : Number(addFormData.server_id), execution_mode: addFormData.server_id === 'local' ? 'local' : 'ssh', status: 'success' }); }; const handleCancelAdd = () => { setShowAddForm(false); setAddFormData({ script_name: '', container_id: '', server_id: 'local' }); }; const handleAutoDetect = () => { if (!autoDetectServerId) { return; } if (autoDetectMutation.isPending) { return; } setAutoDetectStatus({ type: null, message: '' }); autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) }); }; const handleCancelAutoDetect = () => { setShowAutoDetectForm(false); setAutoDetectServerId(''); }; const handleSort = (field: 'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date') => { if (sortField === field) { setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortDirection('asc'); } }; const handleAutoDetectWebUI = (script: InstalledScript) => { console.log('๐Ÿ” Auto-detect WebUI clicked for script:', script); console.log('Script validation:', { hasContainerId: !!script.container_id, isSSHMode: script.execution_mode === 'ssh', containerId: script.container_id, executionMode: script.execution_mode }); if (!script.container_id || script.execution_mode !== 'ssh') { console.log('โŒ Auto-detect validation failed'); setErrorModal({ isOpen: true, title: 'Auto-Detect Failed', message: 'Auto-detect only works for SSH mode scripts with container ID', details: 'This script does not have a valid container ID or is not in SSH mode.' }); return; } console.log('โœ… Calling autoDetectWebUIMutation.mutate with id:', script.id); autoDetectWebUIMutation.mutate({ id: script.id }); }; const handleOpenWebUI = (script: InstalledScript) => { if (!script.web_ui_ip) { setErrorModal({ isOpen: true, title: 'Web UI Access Failed', message: 'No IP address configured for this script', details: 'Please set the Web UI IP address before opening the interface.' }); return; } const port = script.web_ui_port ?? 80; const url = `http://${script.web_ui_ip}:${port}`; window.open(url, '_blank', 'noopener,noreferrer'); }; // Helper function to check if a script has any actions available const hasActions = (script: InstalledScript) => { if (script.container_id && script.execution_mode === 'ssh') return true; if (script.web_ui_ip != null) return true; if (!script.container_id || script.execution_mode !== 'ssh') return true; return false; }; const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString(); }; if (isLoading) { return (
Loading installed scripts...
); } return (
{/* Update Terminal */} {updatingScript && (
)} {/* Shell Terminal */} {openingShell && (
)} {/* Header with Stats */}

Installed Scripts

{stats && (
{stats.total}
Total Installations
{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' && !script.is_vm).length}
Stopped LXC
{scriptsWithStatus.filter(script => script.container_status === 'stopped' && script.is_vm).length}
Stopped VMs
)} {/* Add Script and Auto-Detect Buttons */}
{/* Add Script Form */} {showAddForm && (

Add Manual Script Entry

handleAddFormChange('script_name', 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="Enter script name" />
handleAddFormChange('container_id', 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="Enter container ID" />
)} {/* Status Messages */} {(autoDetectStatus.type ?? cleanupStatus.type) && (
{/* Auto-Detect Status Message */} {autoDetectStatus.type && (
{autoDetectStatus.type === 'success' ? ( ) : ( )}

{autoDetectStatus.message}

)} {/* Cleanup Status Message */} {cleanupStatus.type && (
{cleanupStatus.type === 'success' ? ( ) : ( )}

{cleanupStatus.message}

)}
)} {/* Auto-Detect LXC Containers Form */} {showAutoDetectForm && (

Auto-Detect LXC Containers (Must contain a tag with "community-script")

How it works

This feature will:

  • Connect to the selected server via SSH
  • Scan all LXC config files in /etc/pve/lxc/
  • Find containers with "community-script" in their tags
  • Extract the container ID and hostname
  • Add them as installed script entries
)} {/* Filters */}
{/* Search Input - Full Width on Mobile */}
setSearchTerm(e.target.value)} className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" />
{/* Filter Dropdowns - Responsive Grid */}
{/* Scripts Display - Mobile Cards / Desktop Table */}
{filteredScripts.length === 0 ? (
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
) : ( <> {/* Mobile Card Layout */}
{filteredScripts.map((script) => ( handleEditScript(script)} onSave={handleSaveEdit} onCancel={handleCancelEdit} onUpdate={() => handleUpdateScript(script)} onBackup={() => handleBackupScript(script)} onShell={() => handleOpenShell(script)} 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} onOpenWebUI={() => handleOpenWebUI(script)} onAutoDetectWebUI={() => handleAutoDetectWebUI(script)} isAutoDetecting={autoDetectWebUIMutation.isPending} /> ))}
{/* Desktop Table Layout */}
{filteredScripts.map((script) => ( ))}
handleSort('script_name')} >
Script Name {sortField === 'script_name' && ( {sortDirection === 'asc' ? 'โ†‘' : 'โ†“'} )}
handleSort('container_id')} >
Container ID {sortField === 'container_id' && ( {sortDirection === 'asc' ? 'โ†‘' : 'โ†“'} )}
Web UI handleSort('server_name')} >
Server {sortField === 'server_name' && ( {sortDirection === 'asc' ? 'โ†‘' : 'โ†“'} )}
handleSort('status')} >
Status {sortField === 'status' && ( {sortDirection === 'asc' ? 'โ†‘' : 'โ†“'} )}
handleSort('installation_date')} >
Installation Date {sortField === 'installation_date' && ( {sortDirection === 'asc' ? 'โ†‘' : 'โ†“'} )}
Actions
{editingScriptId === script.id ? (
handleInputChange('script_name', e.target.value)} className="w-full px-3 py-2 text-sm font-medium border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" placeholder="Script name" />
) : (
{script.container_id && ( {script.is_vm ? 'VM' : 'LXC'} )}
{script.script_name}
{script.script_path}
)}
{editingScriptId === script.id ? (
handleInputChange('container_id', e.target.value)} className="w-full px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" placeholder="Container ID" />
) : ( script.container_id ? (
{String(script.container_id)} {script.container_status && (
{script.container_status === 'running' ? 'Running' : script.container_status === 'stopped' ? 'Stopped' : 'Unknown'}
)}
) : ( - ) )}
{editingScriptId === script.id ? (
handleInputChange('web_ui_ip', e.target.value)} className="w-40 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" placeholder="IP" /> : handleInputChange('web_ui_port', e.target.value)} className="w-20 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" placeholder="Port" />
) : ( script.web_ui_ip ? (
{script.web_ui_ip}:{script.web_ui_port ?? 80} {containerStatuses.get(script.id) === 'running' && ( )}
) : (
- {script.container_id && script.execution_mode === 'ssh' && ( )}
) )}
{script.server_name ?? '-'} {script.status.replace('_', ' ').toUpperCase()} {formatDate(String(script.installation_date))}
{editingScriptId === script.id ? ( <> ) : ( <> {hasActions(script) && ( {script.container_id && !script.is_vm && ( handleUpdateScript(script)} disabled={containerStatuses.get(script.id) === 'stopped'} className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > Update )} {script.container_id && script.execution_mode === 'ssh' && ( handleBackupScript(script)} disabled={containerStatuses.get(script.id) === 'stopped'} className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > Backup )} {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && ( handleOpenShell(script)} disabled={containerStatuses.get(script.id) === 'stopped'} className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > Shell )} {script.web_ui_ip && ( handleOpenWebUI(script)} disabled={containerStatuses.get(script.id) === 'stopped'} className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > Open UI )} {script.container_id && script.execution_mode === 'ssh' && script.web_ui_ip && ( handleAutoDetectWebUI(script)} disabled={autoDetectWebUIMutation.isPending ?? containerStatuses.get(script.id) === 'stopped'} className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > {autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'} )} {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && ( <> handleLXCSettings(script)} className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > LXC Settings )} {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'} className={(containerStatuses.get(script.id) ?? 'unknown') === 'running' ? "text-error hover:text-error-foreground hover:bg-error/20 focus:bg-error/20" : "text-success hover:text-success-foreground hover:bg-success/20 focus:bg-success/20" } > {controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'} handleDestroy(script)} disabled={controllingScriptId === script.id} className="text-error hover:text-error-foreground hover:bg-error/20 focus:bg-error/20" > {controllingScriptId === script.id ? 'Working...' : 'Destroy'} handleDeleteScript(script.id, script)} disabled={deleteScriptMutation.isPending} className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > {deleteScriptMutation.isPending ? 'Deleting...' : 'Delete only from DB'} )} {(!script.container_id || script.execution_mode !== 'ssh') && ( <> handleDeleteScript(Number(script.id))} disabled={deleteScriptMutation.isPending} className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > {deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'} )} )} )}
)}
{/* 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'} /> )} {/* Loading Modal */} {loadingModal && ( )} {/* Backup Prompt Modal */} {showBackupPrompt && (

Backup Before Update?

Would you like to create a backup before updating the container?

)} {/* Storage Selection Modal */} { setShowStorageSelection(false); setPendingUpdateScript(null); setBackupStorages([]); }} onSelect={handleStorageSelected} storages={backupStorages} isLoading={isLoadingStorages} onRefresh={() => { if (pendingUpdateScript?.server_id) { void fetchStorages(pendingUpdateScript.server_id, true); } }} /> {/* Backup Warning Modal */} setShowBackupWarning(false)} onProceed={() => { setShowBackupWarning(false); // Proceed with update even though backup failed if (pendingUpdateScript) { proceedWithUpdate(null); } }} /> {/* LXC Settings Modal */} setLxcSettingsModal({ isOpen: false, script: null })} onSave={() => { setLxcSettingsModal({ isOpen: false, script: null }); void refetchScripts(); }} />
); }