Files
ProxmoxVE-Local/src/app/_components/InstalledScriptsTab.tsx
Michel Rögl-Brunner b5c6beafff Fix #362: auto-detect race, VM shell path, UI hints
- Defer resolve in autoDetectLXCContainers (pct/qm list) so stdout is complete
- Pass containerType when opening shell; use qm terminal for VMs, pct enter for LXC
- Add UI hint for VM shell (serial console, Ctrl+O, serial port requirement)
- Rename auto-detect to Containers & VMs and update help text

Fixes #362
2026-01-29 14:12:49 +01:00

2648 lines
102 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string>("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<InstalledScript | null>(null);
const [backupStorages, setBackupStorages] = useState<Storage[]>([]);
const [isLoadingStorages, setIsLoadingStorages] = useState(false);
const [showBackupWarning, setShowBackupWarning] = useState(false);
const [isPreUpdateBackup, setIsPreUpdateBackup] = useState(false); // Track if storage selection is for pre-update backup
const [pendingCloneScript, setPendingCloneScript] = useState<InstalledScript | null>(null);
const [cloneStorages, setCloneStorages] = useState<Storage[]>([]);
const [isLoadingCloneStorages, setIsLoadingCloneStorages] = useState(false);
const [showCloneStorageSelection, setShowCloneStorageSelection] = useState(false);
const [showCloneCountInput, setShowCloneCountInput] = useState(false);
const [cloneContainerType, setCloneContainerType] = useState<'lxc' | 'vm' | null>(null);
const [selectedCloneStorage, setSelectedCloneStorage] = useState<Storage | null>(null);
// cloneCount is passed as parameter to handleCloneCountSubmit, no need for state
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{
script_name: string;
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<string>("");
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<number, "running" | "stopped" | "unknown">
>(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<number | null>(
null,
);
const scriptsRef = useRef<InstalledScript[]>([]);
const statusCheckTimeoutRef = useRef<NodeJS.Timeout | null>(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<string>();
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 (
<div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground">
Loading installed scripts...
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Update Terminal */}
{updatingScript && (
<div className="mb-8" data-terminal="update">
<Terminal
scriptPath={
updatingScript.isClone
? `clone-${updatingScript.containerId}`
: updatingScript.isBackupOnly
? `backup-${updatingScript.containerId}`
: `update-${updatingScript.containerId}`
}
onClose={handleCloseUpdateTerminal}
mode={updatingScript.server ? "ssh" : "local"}
server={updatingScript.server}
isUpdate={!updatingScript.isBackupOnly && !updatingScript.isClone}
isBackup={updatingScript.isBackupOnly}
isClone={updatingScript.isClone}
containerId={updatingScript.containerId}
executionId={updatingScript.executionId}
cloneCount={updatingScript.cloneCount}
hostnames={updatingScript.hostnames}
containerType={updatingScript.containerType}
storage={updatingScript.isClone ? updatingScript.storage : (updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined)}
backupStorage={!updatingScript.isBackupOnly && !updatingScript.isClone ? updatingScript.backupStorage : undefined}
/>
</div>
)}
{/* Shell Terminal */}
{openingShell && (
<div className="mb-8" data-terminal="shell">
{openingShell.containerType === 'vm' && (
<p className="text-muted-foreground mb-2 text-sm">
VM shell uses the Proxmox serial console. The VM must have a
serial port configured (e.g. <code className="bg-muted rounded px-1">qm set {openingShell.containerId} -serial0 socket</code>).
Detach with <kbd className="bg-muted rounded px-1">Ctrl+O</kbd>.
</p>
)}
<Terminal
scriptPath={`shell-${openingShell.containerId}`}
onClose={handleCloseShellTerminal}
mode={openingShell.server ? "ssh" : "local"}
server={openingShell.server}
isShell={true}
containerId={openingShell.containerId}
containerType={openingShell.containerType}
/>
</div>
)}
{/* Header with Stats */}
<div className="bg-card rounded-lg p-6 shadow">
<h2 className="text-foreground mb-4 text-2xl font-bold">
Installed Scripts
</h2>
{stats && (
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
<div className="bg-info/10 border-info/20 rounded-lg border p-4 text-center">
<div className="text-info text-2xl font-bold">{stats.total}</div>
<div className="text-info/80 text-sm">Total Installations</div>
</div>
<div className="bg-success/10 border-success/20 rounded-lg border p-4 text-center">
<div className="text-success text-2xl font-bold">
{
scriptsWithStatus.filter(
(script) =>
script.container_status === "running" && !script.is_vm,
).length
}
</div>
<div className="text-success/80 text-sm">Running LXC</div>
</div>
<div className="bg-success/10 border-success/20 rounded-lg border p-4 text-center">
<div className="text-success text-2xl font-bold">
{
scriptsWithStatus.filter(
(script) =>
script.container_status === "running" && script.is_vm,
).length
}
</div>
<div className="text-success/80 text-sm">Running VMs</div>
</div>
<div className="bg-error/10 border-error/20 rounded-lg border p-4 text-center">
<div className="text-error text-2xl font-bold">
{
scriptsWithStatus.filter(
(script) =>
script.container_status === "stopped" && !script.is_vm,
).length
}
</div>
<div className="text-error/80 text-sm">Stopped LXC</div>
</div>
<div className="bg-error/10 border-error/20 rounded-lg border p-4 text-center">
<div className="text-error text-2xl font-bold">
{
scriptsWithStatus.filter(
(script) =>
script.container_status === "stopped" && script.is_vm,
).length
}
</div>
<div className="text-error/80 text-sm">Stopped VMs</div>
</div>
</div>
)}
{/* Add Script and Auto-Detect Buttons */}
<div className="mb-4 flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => setShowAddForm(!showAddForm)}
variant={showAddForm ? "outline" : "default"}
size="default"
>
{showAddForm ? "Cancel Add Script" : "+ Add Manual Script Entry"}
</Button>
<Button
onClick={() => setShowAutoDetectForm(!showAutoDetectForm)}
variant={showAutoDetectForm ? "outline" : "secondary"}
size="default"
>
{showAutoDetectForm
? "Cancel Auto-Detect"
: '🔍 Auto-Detect Containers & VMs (tag: community-script)'}
</Button>
<Button
onClick={() => {
cleanupRunRef.current = false; // Allow cleanup to run again
void cleanupMutation.mutate();
}}
disabled={cleanupMutation.isPending}
variant="outline"
size="default"
className="border-warning/30 text-warning hover:bg-warning/10"
>
{cleanupMutation.isPending
? "🧹 Cleaning up..."
: "🧹 Cleanup Orphaned Scripts"}
</Button>
<Button
onClick={() => {
// Trigger status check by calling the mutation directly
const serverIds = [
...new Set(
scripts
.filter((script) => script.server_id)
.map((script) => script.server_id!),
),
];
if (serverIds.length > 0) {
containerStatusMutation.mutate({ serverIds });
}
}}
disabled={containerStatusMutation.isPending ?? scripts.length === 0}
variant="outline"
size="default"
>
{containerStatusMutation.isPending
? "🔄 Checking..."
: "🔄 Refresh Container Status"}
</Button>
</div>
{/* Add Script Form */}
{showAddForm && (
<div className="bg-card border-border mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
<h3 className="text-foreground mb-4 text-lg font-semibold sm:mb-6">
Add Manual Script Entry
</h3>
<div className="space-y-4 sm:space-y-6">
<div className="space-y-2">
<label className="text-foreground block text-sm font-medium">
Script Name *
</label>
<input
type="text"
value={addFormData.script_name}
onChange={(e) =>
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"
/>
</div>
<div className="space-y-2">
<label className="text-foreground block text-sm font-medium">
Container ID
</label>
<input
type="text"
value={addFormData.container_id}
onChange={(e) =>
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"
/>
</div>
<div className="space-y-2">
<label className="text-foreground block text-sm font-medium">
Server
</label>
<select
value={addFormData.server_id}
onChange={(e) =>
handleAddFormChange("server_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 focus:ring-2 focus:outline-none"
>
<option value="local">Select Server</option>
{serversData?.servers?.map((server: any) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-col justify-end gap-3 sm:mt-6 sm:flex-row">
<Button
onClick={handleCancelAdd}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleAddScript}
disabled={createScriptMutation.isPending}
variant="default"
size="default"
className="w-full sm:w-auto"
>
{createScriptMutation.isPending ? "Adding..." : "Add Script"}
</Button>
</div>
</div>
)}
{/* Status Messages */}
{(autoDetectStatus.type ?? cleanupStatus.type) && (
<div className="mb-4 space-y-2">
{/* Auto-Detect Status Message */}
{autoDetectStatus.type && (
<div
className={`rounded-lg border p-4 ${
autoDetectStatus.type === "success"
? "bg-success/10 border-success/20"
: "bg-error/10 border-error/20"
}`}
>
<div className="flex items-center">
<div className="flex-shrink-0">
{autoDetectStatus.type === "success" ? (
<svg
className="text-success h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="text-error h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<div className="ml-3">
<p
className={`text-sm font-medium ${
autoDetectStatus.type === "success"
? "text-success-foreground"
: "text-error-foreground"
}`}
>
{autoDetectStatus.message}
</p>
</div>
</div>
</div>
)}
{/* Cleanup Status Message */}
{cleanupStatus.type && (
<div
className={`rounded-lg border p-4 ${
cleanupStatus.type === "success"
? "bg-muted/50 border-muted"
: "bg-error/10 border-error/20"
}`}
>
<div className="flex items-center">
<div className="flex-shrink-0">
{cleanupStatus.type === "success" ? (
<svg
className="text-muted-foreground h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="text-error h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<div className="ml-3">
<p
className={`text-sm font-medium ${
cleanupStatus.type === "success"
? "text-foreground"
: "text-error-foreground"
}`}
>
{cleanupStatus.message}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Auto-Detect Containers & VMs Form */}
{showAutoDetectForm && (
<div className="bg-card border-border mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
<h3 className="text-foreground mb-4 text-lg font-semibold sm:mb-6">
Auto-Detect Containers &amp; VMs (tag: community-script)
</h3>
<div className="space-y-4 sm:space-y-6">
<div className="bg-muted/30 border-muted rounded-lg border p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg
className="text-muted-foreground h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h4 className="text-foreground text-sm font-medium">
How it works
</h4>
<div className="text-muted-foreground mt-2 text-sm">
<p>This feature will:</p>
<ul className="mt-1 list-inside list-disc space-y-1">
<li>Connect to the selected server via SSH</li>
<li>Scan LXC configs in /etc/pve/lxc/ and VM configs in /etc/pve/qemu-server/</li>
<li>
Find containers and VMs with &quot;community-script&quot; in
their tags
</li>
<li>Extract the container/VM ID and hostname or name</li>
<li>Add them as installed script entries</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-foreground block text-sm font-medium">
Select Server *
</label>
<select
value={autoDetectServerId}
onChange={(e) => setAutoDetectServerId(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 focus:ring-2 focus:outline-none"
>
<option value="">Choose a server...</option>
{serversData?.servers?.map((server: any) => (
<option key={server.id} value={server.id}>
{server.name} ({server.ip})
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-col justify-end gap-3 sm:mt-6 sm:flex-row">
<Button
onClick={handleCancelAutoDetect}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleAutoDetect}
disabled={autoDetectMutation.isPending ?? !autoDetectServerId}
variant="default"
size="default"
className="w-full sm:w-auto"
>
{autoDetectMutation.isPending
? "🔍 Scanning..."
: "🔍 Start Auto-Detection"}
</Button>
</div>
</div>
)}
{/* Filters */}
<div className="space-y-4">
{/* Search Input - Full Width on Mobile */}
<div className="w-full">
<input
type="text"
placeholder="Search scripts, container IDs, or servers..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
{/* Filter Dropdowns - Responsive Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<select
value={statusFilter}
onChange={(e) =>
setStatusFilter(
e.target.value as
| "all"
| "success"
| "failed"
| "in_progress",
)
}
className="border-border bg-card text-foreground focus:ring-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none"
>
<option value="all">All Status</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="in_progress">In Progress</option>
</select>
<select
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
className="border-border bg-card text-foreground focus:ring-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none"
>
<option value="all">All Servers</option>
<option value="local">Local</option>
{uniqueServers.map((server) => (
<option key={server} value={server}>
{server}
</option>
))}
</select>
</div>
</div>
</div>
{/* Scripts Display - Mobile Cards / Desktop Table */}
<div className="bg-card overflow-hidden rounded-lg shadow">
{filteredScripts.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{scripts.length === 0
? "No installed scripts found."
: "No scripts match your filters."}
</div>
) : (
<>
{/* Mobile Card Layout */}
<div className="block space-y-4 p-4 md:hidden">
{filteredScripts.map((script) => (
<ScriptInstallationCard
key={script.id}
script={script}
isEditing={editingScriptId === script.id}
editFormData={editFormData}
onInputChange={handleInputChange}
onEdit={() => 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}
/>
))}
</div>
{/* Desktop Table Layout */}
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted">
<tr>
<th
className="text-muted-foreground hover:bg-muted/80 cursor-pointer px-6 py-3 text-left text-xs font-medium tracking-wider uppercase select-none"
onClick={() => handleSort("script_name")}
>
<div className="flex items-center space-x-1">
<span>Script Name</span>
{sortField === "script_name" && (
<span className="text-primary">
{sortDirection === "asc" ? "↑" : "↓"}
</span>
)}
</div>
</th>
<th
className="text-muted-foreground hover:bg-muted/80 cursor-pointer px-6 py-3 text-left text-xs font-medium tracking-wider uppercase select-none"
onClick={() => handleSort("container_id")}
>
<div className="flex items-center space-x-1">
<span>Container ID</span>
{sortField === "container_id" && (
<span className="text-primary">
{sortDirection === "asc" ? "↑" : "↓"}
</span>
)}
</div>
</th>
<th className="text-muted-foreground px-6 py-3 text-left text-xs font-medium tracking-wider uppercase">
Web UI
</th>
<th
className="text-muted-foreground hover:bg-muted/80 cursor-pointer px-6 py-3 text-left text-xs font-medium tracking-wider uppercase select-none"
onClick={() => handleSort("server_name")}
>
<div className="flex items-center space-x-1">
<span>Server</span>
{sortField === "server_name" && (
<span className="text-primary">
{sortDirection === "asc" ? "↑" : "↓"}
</span>
)}
</div>
</th>
<th
className="text-muted-foreground hover:bg-muted/80 cursor-pointer px-6 py-3 text-left text-xs font-medium tracking-wider uppercase select-none"
onClick={() => handleSort("status")}
>
<div className="flex items-center space-x-1">
<span>Status</span>
{sortField === "status" && (
<span className="text-primary">
{sortDirection === "asc" ? "↑" : "↓"}
</span>
)}
</div>
</th>
<th
className="text-muted-foreground hover:bg-muted/80 cursor-pointer px-6 py-3 text-left text-xs font-medium tracking-wider uppercase select-none"
onClick={() => handleSort("installation_date")}
>
<div className="flex items-center space-x-1">
<span>Installation Date</span>
{sortField === "installation_date" && (
<span className="text-primary">
{sortDirection === "asc" ? "↑" : "↓"}
</span>
)}
</div>
</th>
<th className="text-muted-foreground px-6 py-3 text-left text-xs font-medium tracking-wider uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-gray-200">
{filteredScripts.map((script) => (
<tr
key={script.id}
className="hover:bg-accent"
style={{
borderLeft: `4px solid ${script.server_color ?? "transparent"}`,
}}
>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="flex min-h-[2.5rem] items-center">
<input
type="text"
value={editFormData.script_name}
onChange={(e) =>
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"
/>
</div>
) : (
<div>
<div className="flex items-center gap-2">
{script.container_id && (
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${
script.is_vm
? "border border-purple-500/30 bg-purple-500/20 text-purple-600 dark:text-purple-400"
: "border border-blue-500/30 bg-blue-500/20 text-blue-600 dark:text-blue-400"
}`}
>
{script.is_vm ? "VM" : "LXC"}
</span>
)}
<div className="text-foreground text-sm font-medium">
{script.script_name}
</div>
</div>
<div className="text-muted-foreground text-sm">
{script.script_path}
</div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="flex min-h-[2.5rem] items-center">
<input
type="text"
value={editFormData.container_id}
onChange={(e) =>
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"
/>
</div>
) : script.container_id ? (
<div className="flex items-center space-x-2">
<span className="text-foreground font-mono text-sm">
{String(script.container_id)}
</span>
{script.container_status && (
<div className="flex items-center space-x-1">
<div
className={`h-2 w-2 rounded-full ${
script.container_status === "running"
? "bg-success"
: script.container_status === "stopped"
? "bg-error"
: "bg-muted-foreground"
}`}
></div>
<span
className={`text-xs font-medium ${
script.container_status === "running"
? "text-success"
: script.container_status === "stopped"
? "text-error"
: "text-muted-foreground"
}`}
>
{script.container_status === "running"
? "Running"
: script.container_status === "stopped"
? "Stopped"
: "Unknown"}
</span>
</div>
)}
</div>
) : (
<span className="text-muted-foreground text-sm">
-
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="flex min-h-[2.5rem] items-center space-x-2">
<input
type="text"
value={editFormData.web_ui_ip}
onChange={(e) =>
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"
/>
<span className="text-muted-foreground">:</span>
<input
type="number"
value={editFormData.web_ui_port}
onChange={(e) =>
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"
/>
</div>
) : script.web_ui_ip ? (
<div className="flex items-center space-x-3">
<span className="text-foreground text-sm">
{script.web_ui_ip}:{script.web_ui_port ?? 80}
</span>
{containerStatuses.get(script.id) === "running" && (
<button
onClick={() => handleOpenWebUI(script)}
className="bg-info/20 hover:bg-info/30 border-info/50 text-info hover:text-info-foreground hover:border-info/60 flex-shrink-0 rounded border px-2 py-1 text-xs transition-all duration-200 hover:scale-105 hover:shadow-md disabled:opacity-50"
title="Open Web UI"
>
Open UI
</button>
)}
</div>
) : (
<div className="flex items-center space-x-2">
<span className="text-muted-foreground text-sm">
-
</span>
{script.container_id &&
script.execution_mode === "ssh" && (
<button
onClick={() => handleAutoDetectWebUI(script)}
disabled={autoDetectWebUIMutation.isPending}
className="bg-info hover:bg-info/90 text-info-foreground border-info rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50"
title="Re-detect IP and port"
>
{autoDetectWebUIMutation.isPending
? "..."
: "Re-detect"}
</button>
)}
</div>
)}
</td>
<td className="px-6 py-4 text-left whitespace-nowrap">
<span
className="inline-block rounded px-3 py-1 text-sm"
style={{
backgroundColor:
script.server_color ?? "transparent",
color: script.server_color
? getContrastColor(script.server_color)
: "inherit",
}}
>
{script.server_name ?? "-"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={script.status}>
{script.status.replace("_", " ").toUpperCase()}
</StatusBadge>
</td>
<td className="text-muted-foreground px-6 py-4 text-sm whitespace-nowrap">
{formatDate(String(script.installation_date))}
</td>
<td className="px-6 py-4 text-sm font-medium whitespace-nowrap">
<div className="flex space-x-2">
{editingScriptId === script.id ? (
<>
<Button
onClick={handleSaveEdit}
disabled={updateScriptMutation.isPending}
variant="save"
size="sm"
>
{updateScriptMutation.isPending
? "Saving..."
: "Save"}
</Button>
<Button
onClick={handleCancelEdit}
variant="cancel"
size="sm"
>
Cancel
</Button>
</>
) : (
<>
<Button
onClick={() => handleEditScript(script)}
variant="edit"
size="sm"
>
Edit
</Button>
{hasActions(script) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="bg-muted/20 hover:bg-muted/30 border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground border transition-all duration-200 hover:scale-105 hover:shadow-md"
>
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card border-border w-48">
{script.container_id && !script.is_vm && (
<DropdownMenuItem
onClick={() =>
handleUpdateScript(script)
}
disabled={
containerStatuses.get(script.id) ===
"stopped"
}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Update
</DropdownMenuItem>
)}
{script.container_id &&
script.execution_mode === "ssh" && (
<DropdownMenuItem
onClick={() =>
handleBackupScript(script)
}
disabled={
containerStatuses.get(script.id) ===
"stopped"
}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Backup
</DropdownMenuItem>
)}
{script.container_id &&
script.execution_mode === "ssh" && (
<DropdownMenuItem
onClick={() =>
handleCloneScript(script)
}
disabled={
containerStatuses.get(script.id) ===
"stopped"
}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Clone
</DropdownMenuItem>
)}
{script.container_id &&
script.execution_mode === "ssh" && (
<DropdownMenuItem
onClick={() =>
handleOpenShell(script)
}
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
</DropdownMenuItem>
)}
{script.web_ui_ip && (
<DropdownMenuItem
onClick={() => 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
</DropdownMenuItem>
)}
{script.container_id &&
script.execution_mode === "ssh" &&
script.web_ui_ip && (
<DropdownMenuItem
onClick={() =>
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"}
</DropdownMenuItem>
)}
{script.container_id &&
script.execution_mode === "ssh" &&
!script.is_vm && (
<>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={() =>
handleLXCSettings(script)
}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
<Settings className="mr-2 h-4 w-4" />
LXC Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" />
</>
)}
{script.container_id &&
script.execution_mode === "ssh" && (
<>
{script.is_vm && (
<DropdownMenuSeparator className="bg-border" />
)}
<DropdownMenuItem
onClick={() =>
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"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
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"}
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={() =>
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"}
</DropdownMenuItem>
</>
)}
{(!script.container_id ||
script.execution_mode !== "ssh") && (
<>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={() =>
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"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
{/* Confirmation Modal */}
{confirmationModal && (
<ConfirmationModal
isOpen={confirmationModal.isOpen}
onClose={() => setConfirmationModal(null)}
onConfirm={confirmationModal.onConfirm}
title={confirmationModal.title}
message={confirmationModal.message}
variant={confirmationModal.variant}
confirmText={confirmationModal.confirmText}
/>
)}
{/* Error/Success Modal */}
{errorModal && (
<ErrorModal
isOpen={errorModal.isOpen}
onClose={() => setErrorModal(null)}
title={errorModal.title}
message={errorModal.message}
details={errorModal.details}
type={errorModal.type ?? "error"}
/>
)}
{/* Loading Modal */}
{loadingModal && (
<LoadingModal
isOpen={loadingModal.isOpen}
action={loadingModal.action}
/>
)}
{/* Backup Prompt Modal */}
{showBackupPrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center gap-3">
<svg
className="text-info h-8 w-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
<h2 className="text-card-foreground text-2xl font-bold">
Backup Before Update?
</h2>
</div>
</div>
<div className="p-6">
<p className="text-muted-foreground mb-6 text-sm">
Would you like to create a backup before updating the container?
</p>
<div className="flex flex-col justify-end gap-3 sm:flex-row">
<Button
onClick={() => {
setShowBackupPrompt(false);
handleBackupPromptResponse(false);
}}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
No, Update Without Backup
</Button>
<Button
onClick={() => handleBackupPromptResponse(true)}
variant="default"
size="default"
className="w-full sm:w-auto"
>
Yes, Backup First
</Button>
</div>
</div>
</div>
</div>
)}
{/* Storage Selection Modal */}
<StorageSelectionModal
isOpen={showStorageSelection}
onClose={() => {
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 */}
<BackupWarningModal
isOpen={showBackupWarning}
onClose={() => setShowBackupWarning(false)}
onProceed={() => {
setShowBackupWarning(false);
// Proceed with update even though backup failed
if (pendingUpdateScript) {
proceedWithUpdate(null);
}
}}
/>
{/* Clone Storage Selection Modal */}
<StorageSelectionModal
isOpen={showCloneStorageSelection}
onClose={() => {
setShowCloneStorageSelection(false);
setPendingCloneScript(null);
setCloneStorages([]);
}}
onSelect={handleCloneStorageSelected}
storages={cloneStorages}
isLoading={isLoadingCloneStorages}
onRefresh={() => {
if (pendingCloneScript?.server_id) {
void fetchCloneStorages(pendingCloneScript.server_id, true);
}
}}
title="Select Clone Storage"
description="Select a storage to use for cloning. Only storages with rootdir content are shown."
filterFn={(storage) => {
return storage.content.includes('rootdir');
}}
showBackupTag={false}
/>
{/* Clone Count Input Modal */}
<CloneCountInputModal
isOpen={showCloneCountInput}
onClose={() => {
setShowCloneCountInput(false);
setPendingCloneScript(null);
setCloneStorages([]);
setSelectedCloneStorage(null);
}}
onSubmit={handleCloneCountSubmit}
storageName={selectedCloneStorage?.name ?? ''}
/>
{/* LXC Settings Modal */}
<LXCSettingsModal
isOpen={lxcSettingsModal.isOpen}
script={lxcSettingsModal.script}
onClose={() => setLxcSettingsModal({ isOpen: false, script: null })}
onSave={() => {
setLxcSettingsModal({ isOpen: false, script: null });
void refetchScripts();
}}
/>
</div>
);
}