"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 { CloneCountInputModal } from "./CloneCountInputModal"; 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; isClone?: boolean; executionId?: string; cloneCount?: number; hostnames?: string[]; containerType?: 'lxc' | 'vm'; storage?: string; } | null>(null); const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any; containerType?: 'lxc' | 'vm'; } | 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 [pendingCloneScript, setPendingCloneScript] = useState(null); const [cloneStorages, setCloneStorages] = useState([]); const [isLoadingCloneStorages, setIsLoadingCloneStorages] = useState(false); const [showCloneStorageSelection, setShowCloneStorageSelection] = useState(false); const [showCloneCountInput, setShowCloneCountInput] = useState(false); const [cloneContainerType, setCloneContainerType] = useState<'lxc' | 'vm' | null>(null); const [selectedCloneStorage, setSelectedCloneStorage] = useState(null); // cloneCount is passed as parameter to handleCloneCountSubmit, no need for state 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< Map >(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< number, "running" | "stopped" | "unknown" >(); // For each script, find its container status currentScripts.forEach((script) => { if (script.container_id && data.statusMap) { const containerStatus = ( data.statusMap as Record< string, "running" | "stopped" | "unknown" > )[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 ?? "unknown"}` : (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; } const containerType = script.is_vm ? "VM" : "LXC"; 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"} ${containerType}...`, }); 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); }; // Clone queries const getContainerHostnameQuery = api.installedScripts.getContainerHostname.useQuery( { containerId: pendingCloneScript?.container_id ?? '', serverId: pendingCloneScript?.server_id ?? 0, containerType: cloneContainerType ?? 'lxc' }, { enabled: false } ); const executeCloneMutation = api.installedScripts.executeClone.useMutation(); const utils = api.useUtils(); const fetchCloneStorages = async (serverId: number, _forceRefresh = false) => { setIsLoadingCloneStorages(true); try { // Use utils.fetch to call with the correct serverId const result = await utils.installedScripts.getCloneStorages.fetch({ serverId, forceRefresh: _forceRefresh }); if (result?.success && result.storages) { setCloneStorages(result.storages as Storage[]); } else { setErrorModal({ isOpen: true, title: 'Failed to Fetch Storages', message: result?.error ?? 'Unknown error occurred', type: 'error' }); } } catch (error) { setErrorModal({ isOpen: true, title: 'Failed to Fetch Storages', message: error instanceof Error ? error.message : 'Unknown error occurred', type: 'error' }); } finally { setIsLoadingCloneStorages(false); } }; const handleCloneScript = async (script: InstalledScript) => { if (!script.container_id) { setErrorModal({ isOpen: true, title: 'Clone Failed', message: 'No Container ID available for this script', details: 'This script does not have a valid container ID and cannot be cloned.' }); return; } if (!script.server_id) { setErrorModal({ isOpen: true, title: 'Clone Not Available', message: 'Clone is only available for SSH scripts with a configured server.', type: 'error' }); return; } // Store the script and determine container type using is_vm property setPendingCloneScript(script); // Use is_vm property from batch detection (from main branch) // If not available, default to LXC const containerType = script.is_vm ? 'vm' : 'lxc'; setCloneContainerType(containerType); // Fetch storages and show selection modal void fetchCloneStorages(script.server_id, false); setShowCloneStorageSelection(true); }; const handleCloneStorageSelected = (storage: Storage) => { setShowCloneStorageSelection(false); setSelectedCloneStorage(storage); setShowCloneCountInput(true); }; const handleCloneCountSubmit = async (count: number) => { setShowCloneCountInput(false); if (!pendingCloneScript || !cloneContainerType) { setErrorModal({ isOpen: true, title: 'Clone Failed', message: 'Missing required information for cloning.', type: 'error' }); return; } try { // Get original hostname const hostnameResult = await getContainerHostnameQuery.refetch(); if (!hostnameResult.data?.success || !hostnameResult.data.hostname) { setErrorModal({ isOpen: true, title: 'Clone Failed', message: 'Could not retrieve container hostname.', type: 'error' }); return; } const originalHostname = hostnameResult.data.hostname; // Generate clone hostnames using utils to call with originalHostname const hostnamesResult = await utils.installedScripts.generateCloneHostnames.fetch({ originalHostname, containerType: cloneContainerType ?? 'lxc', serverId: pendingCloneScript.server_id!, count }); if (!hostnamesResult?.success || !hostnamesResult.hostnames.length) { setErrorModal({ isOpen: true, title: 'Clone Failed', message: hostnamesResult?.error ?? 'Could not generate clone hostnames.', type: 'error' }); return; } const hostnames = hostnamesResult.hostnames; // Execute clone (nextIds will be obtained sequentially in server.js) const cloneResult = await executeCloneMutation.mutateAsync({ containerId: pendingCloneScript.container_id!, serverId: pendingCloneScript.server_id!, storage: selectedCloneStorage!.name, cloneCount: count, hostnames: hostnames, containerType: cloneContainerType }); if (!cloneResult.success || !cloneResult.executionId) { setErrorModal({ isOpen: true, title: 'Clone Failed', message: cloneResult.error ?? 'Failed to start clone operation.', type: 'error' }); return; } // Get server info for websocket const server = pendingCloneScript.server_id && pendingCloneScript.server_user ? { id: pendingCloneScript.server_id, name: pendingCloneScript.server_name, ip: pendingCloneScript.server_ip, user: pendingCloneScript.server_user, password: pendingCloneScript.server_password, auth_type: pendingCloneScript.server_auth_type ?? 'password', ssh_key: pendingCloneScript.server_ssh_key, ssh_key_passphrase: pendingCloneScript.server_ssh_key_passphrase, ssh_port: pendingCloneScript.server_ssh_port ?? 22, } : null; // Set up terminal for clone execution setUpdatingScript({ id: pendingCloneScript.id, containerId: pendingCloneScript.container_id!, server: server, isClone: true, executionId: cloneResult.executionId, cloneCount: count, hostnames: hostnames, containerType: cloneContainerType, storage: selectedCloneStorage!.name }); // Reset clone state setPendingCloneScript(null); setCloneStorages([]); setSelectedCloneStorage(null); setCloneContainerType(null); // Reset clone count (no state variable needed, count is passed as parameter) } catch (error) { setErrorModal({ isOpen: true, title: 'Clone Failed', message: error instanceof Error ? error.message : 'Unknown error occurred', type: 'error' }); } }; const handleOpenShell = (script: InstalledScript) => { if (!script.container_id) { setErrorModal({ 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, containerType: script.is_vm ? 'vm' : 'lxc', }); }; 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 && (
{openingShell.containerType === 'vm' && (

VM shell uses the Proxmox serial console. The VM must have a serial port configured (e.g. qm set {openingShell.containerId} -serial0 socket). Detach with Ctrl+O.

)}
)} {/* 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="border-input bg-background text-foreground placeholder:text-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none" placeholder="Enter script name" />
handleAddFormChange("container_id", e.target.value) } className="border-input bg-background text-foreground placeholder:text-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none" 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 Containers & VMs Form */} {showAutoDetectForm && (

Auto-Detect Containers & VMs (tag: community-script)

How it works

This feature will:

  • Connect to the selected server via SSH
  • Scan LXC configs in /etc/pve/lxc/ and VM configs in /etc/pve/qemu-server/
  • Find containers and VMs with "community-script" in their tags
  • Extract the container/VM ID and hostname or name
  • Add them as installed script entries
)} {/* Filters */}
{/* Search Input - Full Width on Mobile */}
setSearchTerm(e.target.value)} className="border-border bg-card text-foreground placeholder-muted-foreground focus:ring-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none" />
{/* 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)} onClone={() => handleCloneScript(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="border-input bg-background text-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 text-sm font-medium focus:ring-2 focus:outline-none" 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="border-input bg-background text-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 font-mono text-sm focus:ring-2 focus:outline-none" 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="border-input bg-background text-foreground focus:ring-ring focus:border-ring w-40 rounded-md border px-3 py-2 font-mono text-sm focus:ring-2 focus:outline-none" placeholder="IP" /> : handleInputChange("web_ui_port", e.target.value) } className="border-input bg-background text-foreground focus:ring-ring focus:border-ring w-20 rounded-md border px-3 py-2 font-mono text-sm focus:ring-2 focus:outline-none" 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" && ( handleCloneScript(script) } disabled={ containerStatuses.get(script.id) === "stopped" } className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" > Clone )} {script.container_id && script.execution_mode === "ssh" && ( handleOpenShell(script) } disabled={ containerStatuses.get(script.id) === "stopped" } className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" title={ script.is_vm ? "VM serial console (requires serial port; detach with Ctrl+O)" : undefined } > 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); } }} /> {/* Clone Storage Selection Modal */} { setShowCloneStorageSelection(false); setPendingCloneScript(null); setCloneStorages([]); }} onSelect={handleCloneStorageSelected} storages={cloneStorages} isLoading={isLoadingCloneStorages} onRefresh={() => { if (pendingCloneScript?.server_id) { void fetchCloneStorages(pendingCloneScript.server_id, true); } }} title="Select Clone Storage" description="Select a storage to use for cloning. Only storages with rootdir content are shown." filterFn={(storage) => { return storage.content.includes('rootdir'); }} showBackupTag={false} /> {/* Clone Count Input Modal */} { setShowCloneCountInput(false); setPendingCloneScript(null); setCloneStorages([]); setSelectedCloneStorage(null); }} onSubmit={handleCloneCountSubmit} storageName={selectedCloneStorage?.name ?? ''} /> {/* LXC Settings Modal */} setLxcSettingsModal({ isOpen: false, script: null })} onSave={() => { setLxcSettingsModal({ isOpen: false, script: null }); void refetchScripts(); }} />
); }