import React, { useState, useEffect, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button.tsx"; import { Badge } from "@/components/ui/badge.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { Input } from "@/components/ui/input.tsx"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion.tsx"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip.tsx"; import { getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder, exportSSHHostWithCredentials, getSSHFolders, updateFolderMetadata, deleteAllHostsInFolder, getServerStatusById, } from "@/ui/main-axios.ts"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status"; import { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search, Upload, X, Check, Pencil, FolderMinus, Copy, Palette, Trash, Cloud, Database, Box, Package, Layers, Archive, HardDrive, Globe, FolderOpen, Share2, Users, ArrowDownUp, Container, } from "lucide-react"; import type { SSHHost, SSHFolder, SSHManagerHostViewerProps, } from "../../../../../types"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts"; import { FolderEditDialog } from "@/ui/desktop/apps/host-manager/dialogs/FolderEditDialog.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); const { addTab } = useTabs(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [importing, setImporting] = useState(false); const [draggedHost, setDraggedHost] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); const [editingFolder, setEditingFolder] = useState(null); const [editingFolderName, setEditingFolderName] = useState(""); const [operationLoading, setOperationLoading] = useState(false); const [folderMetadata, setFolderMetadata] = useState>( new Map(), ); const [editingFolderAppearance, setEditingFolderAppearance] = useState< string | null >(null); const [serverStatuses, setServerStatuses] = useState< Map >(new Map()); const dragCounter = useRef(0); useEffect(() => { fetchHosts(); fetchFolderMetadata(); const handleHostsRefresh = () => { fetchHosts(); fetchFolderMetadata(); }; const handleFoldersRefresh = () => { fetchFolderMetadata(); }; window.addEventListener("hosts:refresh", handleHostsRefresh); window.addEventListener("ssh-hosts:changed", handleHostsRefresh); window.addEventListener("folders:changed", handleFoldersRefresh); return () => { window.removeEventListener("hosts:refresh", handleHostsRefresh); window.removeEventListener("ssh-hosts:changed", handleHostsRefresh); window.removeEventListener("folders:changed", handleFoldersRefresh); }; }, []); const fetchHosts = async () => { try { setLoading(true); const data = await getSSHHosts(); const cleanedHosts = data.map((host) => { const cleanedHost = { ...host }; if (cleanedHost.credentialId && cleanedHost.key) { cleanedHost.key = undefined; cleanedHost.keyPassword = undefined; cleanedHost.keyType = undefined; cleanedHost.authType = "credential"; } else if (cleanedHost.credentialId && cleanedHost.password) { cleanedHost.password = undefined; cleanedHost.authType = "credential"; } else if (cleanedHost.key && cleanedHost.password) { cleanedHost.password = undefined; cleanedHost.authType = "key"; } return cleanedHost; }); setHosts(cleanedHosts); setError(null); } catch { setError(t("hosts.failedToLoadHosts")); } finally { setLoading(false); } }; const fetchFolderMetadata = async () => { try { const folders = await getSSHFolders(); const metadataMap = new Map(); folders.forEach((folder) => { metadataMap.set(folder.name, folder); }); setFolderMetadata(metadataMap); } catch (error) { console.error("Failed to fetch folder metadata:", error); } }; const handleSaveFolderAppearance = async ( folderName: string, color: string, icon: string, ) => { try { await updateFolderMetadata(folderName, color, icon); toast.success(t("hosts.folderAppearanceUpdated")); await fetchFolderMetadata(); window.dispatchEvent(new CustomEvent("folders:changed")); } catch (error) { console.error("Failed to update folder appearance:", error); toast.error(t("hosts.failedToUpdateFolderAppearance")); } }; const handleDeleteAllHostsInFolder = async (folderName: string) => { const hostsInFolder = hostsByFolder[folderName] || []; confirmWithToast( t("hosts.confirmDeleteAllHostsInFolder", { folder: folderName, count: hostsInFolder.length, }), async () => { try { const result = await deleteAllHostsInFolder(folderName); toast.success( t("hosts.allHostsInFolderDeleted", { folder: folderName, count: result.deletedCount, }), ); await fetchHosts(); await fetchFolderMetadata(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } catch (error) { console.error("Failed to delete hosts in folder:", error); toast.error(t("hosts.failedToDeleteHostsInFolder")); } }, "destructive", ); }; useEffect(() => { if (hosts.length === 0) return; const statusIntervals: NodeJS.Timeout[] = []; const statusCancelled: boolean[] = []; hosts.forEach((host, index) => { const statsConfig = (() => { try { return host.statsConfig ? JSON.parse(host.statsConfig) : DEFAULT_STATS_CONFIG; } catch { return DEFAULT_STATS_CONFIG; } })(); const shouldShowStatus = statsConfig.statusCheckEnabled !== false; if (!shouldShowStatus) { setServerStatuses((prev) => { const next = new Map(prev); next.set(host.id, "offline"); return next; }); return; } const fetchStatus = async () => { try { const res = await getServerStatusById(host.id); if (!statusCancelled[index]) { setServerStatuses((prev) => { const next = new Map(prev); next.set( host.id, res?.status === "online" ? "online" : "offline", ); return next; }); } } catch (error: unknown) { if (!statusCancelled[index]) { const err = error as { response?: { status?: number } }; let status: "online" | "offline" | "degraded" = "offline"; if (err?.response?.status === 504) { status = "degraded"; } setServerStatuses((prev) => { const next = new Map(prev); next.set(host.id, status); return next; }); } } }; fetchStatus(); const intervalId = setInterval(fetchStatus, 10000); statusIntervals.push(intervalId); }); return () => { statusCancelled.fill(true); statusIntervals.forEach((interval) => clearInterval(interval)); }; }, [hosts]); const getFolderIcon = (folderName: string) => { const metadata = folderMetadata.get(folderName); if (!metadata?.icon) return Folder; const iconMap: Record = { Folder, Server, Cloud, Database, Box, Package, Layers, Archive, HardDrive, Globe, }; return iconMap[metadata.icon] || Folder; }; const getFolderColor = (folderName: string) => { const metadata = folderMetadata.get(folderName); return metadata?.color; }; const handleDelete = async (hostId: number, hostName: string) => { confirmWithToast( t("hosts.confirmDelete", { name: hostName }), async () => { try { await deleteSSHHost(hostId); toast.success(t("hosts.hostDeletedSuccessfully", { name: hostName })); await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); const { refreshServerPolling } = await import("@/ui/main-axios.ts"); refreshServerPolling(); } catch { toast.error(t("hosts.failedToDeleteHost")); } }, "destructive", ); }; const handleExport = (host: SSHHost) => { const actualAuthType = host.credentialId ? "credential" : host.key ? "key" : "password"; if (actualAuthType === "credential") { const confirmMessage = t("hosts.exportCredentialWarning", { name: host.name || `${host.username}@${host.ip}`, }); confirmWithToast(confirmMessage, () => { performExport(host); }); return; } else if (actualAuthType === "password" || actualAuthType === "key") { const confirmMessage = t("hosts.exportSensitiveDataWarning", { name: host.name || `${host.username}@${host.ip}`, }); confirmWithToast(confirmMessage, () => { performExport(host); }); return; } performExport(host); }; const performExport = async (host: SSHHost) => { try { const decryptedHost = await exportSSHHostWithCredentials(host.id); const cleanExportData = Object.fromEntries( Object.entries(decryptedHost).filter( ([, value]) => value !== undefined, ), ); const exportFormat = { hosts: [cleanExportData], }; const blob = new Blob([JSON.stringify(exportFormat, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success( t("hosts.exportedHostConfig", { name: host.name || `${host.username}@${host.ip}`, }), ); } catch { toast.error(t("hosts.failedToExportHost")); } }; const handleEdit = (host: SSHHost) => { if (onEditHost) { onEditHost(host); } }; const handleClone = (host: SSHHost) => { if (onEditHost) { const clonedHost = { ...host }; delete clonedHost.id; onEditHost(clonedHost); } }; const handleRemoveFromFolder = async (host: SSHHost) => { confirmWithToast( t("hosts.confirmRemoveFromFolder", { name: host.name || `${host.username}@${host.ip}`, folder: host.folder, }), async () => { try { setOperationLoading(true); const updatedHost = { ...host, folder: "" }; await updateSSHHost(host.id, updatedHost); toast.success( t("hosts.removedFromFolder", { name: host.name || `${host.username}@${host.ip}`, }), ); await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } catch { toast.error(t("hosts.failedToRemoveFromFolder")); } finally { setOperationLoading(false); } }, ); }; const handleFolderRename = async (oldName: string) => { if (!editingFolderName.trim() || editingFolderName === oldName) { setEditingFolder(null); setEditingFolderName(""); return; } try { setOperationLoading(true); await renameFolder(oldName, editingFolderName.trim()); toast.success( t("hosts.folderRenamed", { oldName, newName: editingFolderName.trim(), }), ); await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); setEditingFolder(null); setEditingFolderName(""); } catch { toast.error(t("hosts.failedToRenameFolder")); } finally { setOperationLoading(false); } }; const startFolderEdit = (folderName: string) => { setEditingFolder(folderName); setEditingFolderName(folderName); }; const cancelFolderEdit = () => { setEditingFolder(null); setEditingFolderName(""); }; const handleDragStart = (e: React.DragEvent, host: SSHHost) => { setDraggedHost(host); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", ""); }; const handleDragEnd = () => { setDraggedHost(null); setDragOverFolder(null); dragCounter.current = 0; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }; const handleDragEnter = (e: React.DragEvent, folderName: string) => { e.preventDefault(); dragCounter.current++; setDragOverFolder(folderName); }; const handleDragLeave = () => { dragCounter.current--; if (dragCounter.current === 0) { setDragOverFolder(null); } }; const handleDrop = async (e: React.DragEvent, targetFolder: string) => { e.preventDefault(); dragCounter.current = 0; setDragOverFolder(null); if (!draggedHost) return; const newFolder = targetFolder === t("hosts.uncategorized") ? "" : targetFolder; if (draggedHost.folder === newFolder) { setDraggedHost(null); return; } try { setOperationLoading(true); const updatedHost = { ...draggedHost, folder: newFolder }; await updateSSHHost(draggedHost.id, updatedHost); toast.success( t("hosts.movedToFolder", { name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`, folder: targetFolder, }), ); await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } catch { toast.error(t("hosts.failedToMoveToFolder")); } finally { setOperationLoading(false); setDraggedHost(null); } }; const getSampleData = () => ({ hosts: [ { name: t("interface.webServerProduction"), ip: "192.168.1.100", port: 22, username: "admin", authType: "password", password: "your_secure_password_here", folder: t("interface.productionFolder"), tags: ["web", "production", "nginx"], pin: true, notes: "Main production web server running Nginx", enableTerminal: true, enableTunnel: false, enableFileManager: true, enableDocker: false, defaultPath: "/var/www", }, { name: t("interface.databaseServer"), ip: "192.168.1.101", port: 22, username: "dbadmin", authType: "key", key: "-----BEGIN OPENSSH PRIVATE KEY-----\\nYour SSH private key content here\\n-----END OPENSSH PRIVATE KEY-----", keyPassword: "optional_key_passphrase", keyType: "ssh-ed25519", folder: t("interface.productionFolder"), tags: ["database", "production", "postgresql"], pin: false, notes: "PostgreSQL production database", enableTerminal: true, enableTunnel: true, enableFileManager: false, enableDocker: false, tunnelConnections: [ { sourcePort: 5432, endpointPort: 5432, endpointHost: t("interface.webServerProduction"), maxRetries: 3, retryInterval: 10, autoStart: true, }, ], statsConfig: { enabledWidgets: ["cpu", "memory", "disk", "network", "uptime"], statusCheckEnabled: true, statusCheckInterval: 30, metricsEnabled: true, metricsInterval: 30, }, }, { name: t("interface.developmentServer"), ip: "192.168.1.102", port: 2222, username: "developer", authType: "credential", credentialId: 1, overrideCredentialUsername: false, folder: t("interface.developmentFolder"), tags: ["dev", "testing"], pin: false, notes: "Development environment for testing", enableTerminal: true, enableTunnel: false, enableFileManager: true, enableDocker: true, defaultPath: "/home/developer", }, { name: "Jump Host Server", ip: "10.0.0.50", port: 22, username: "sysadmin", authType: "password", password: "secure_password", folder: "Infrastructure", tags: ["bastion", "jump-host"], notes: "Jump host for accessing internal network", enableTerminal: true, enableTunnel: true, enableFileManager: true, enableDocker: false, jumpHosts: [ { hostId: 1, }, ], quickActions: [ { name: "System Update", snippetId: 5, }, ], }, { name: "Server with SOCKS5 Proxy", ip: "10.10.10.100", port: 22, username: "proxyuser", authType: "password", password: "secure_password", folder: "Proxied Hosts", tags: ["proxy", "socks5"], notes: "Accessible through SOCKS5 proxy", enableTerminal: true, enableTunnel: false, enableFileManager: true, enableDocker: false, useSocks5: true, socks5Host: "proxy.example.com", socks5Port: 1080, socks5Username: "proxyauth", socks5Password: "proxypass", }, { name: "Customized Terminal Server", ip: "192.168.1.150", port: 22, username: "devops", authType: "password", password: "terminal_password", folder: t("interface.developmentFolder"), tags: ["custom", "terminal"], notes: "Server with custom terminal configuration", enableTerminal: true, enableTunnel: false, enableFileManager: true, enableDocker: false, defaultPath: "/opt/apps", terminalConfig: { cursorBlink: true, cursorStyle: "bar", fontSize: 16, fontFamily: "jetbrainsMono", letterSpacing: 0.5, lineHeight: 1.2, theme: "monokai", scrollback: 50000, bellStyle: "visual", rightClickSelectsWord: true, fastScrollModifier: "ctrl", fastScrollSensitivity: 7, minimumContrastRatio: 4, backspaceMode: "normal", agentForwarding: true, environmentVariables: [ { key: "NODE_ENV", value: "development", }, ], autoMosh: false, sudoPasswordAutoFill: true, sudoPassword: "sudo_password_here", }, }, ], }); const handleDownloadSample = () => { const sampleData = getSampleData(); const blob = new Blob([JSON.stringify(sampleData, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "sample-ssh-hosts.json"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const handleJsonImport = async ( event: React.ChangeEvent, ) => { const file = event.target.files?.[0]; if (!file) return; try { setImporting(true); const text = await file.text(); const data = JSON.parse(text); if (!Array.isArray(data.hosts) && !Array.isArray(data)) { throw new Error(t("hosts.jsonMustContainHosts")); } const hostsArray = Array.isArray(data.hosts) ? data.hosts : data; if (hostsArray.length === 0) { throw new Error(t("hosts.noHostsInJson")); } if (hostsArray.length > 100) { throw new Error(t("hosts.maxHostsAllowed")); } const result = await bulkImportSSHHosts(hostsArray); if (result.success > 0) { toast.success( t("hosts.importCompleted", { success: result.success, failed: result.failed, }), ); if (result.errors.length > 0) { toast.error(`Import errors: ${result.errors.join(", ")}`); } await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } else { toast.error(t("hosts.importFailed") + `: ${result.errors.join(", ")}`); } } catch (err) { const errorMessage = err instanceof Error ? err.message : t("hosts.failedToImportJson"); toast.error(t("hosts.importError") + `: ${errorMessage}`); } finally { setImporting(false); event.target.value = ""; } }; const filteredAndSortedHosts = useMemo(() => { let filtered = hosts; if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = hosts.filter((host) => { const searchableText = [ host.name || "", host.username, host.ip, host.folder || "", ...(host.tags || []), host.authType, host.defaultPath || "", ] .join(" ") .toLowerCase(); return searchableText.includes(query); }); } return filtered.sort((a, b) => { if (a.pin && !b.pin) return -1; if (!a.pin && b.pin) return 1; const aName = a.name || a.username; const bName = b.name || b.username; return aName.localeCompare(bName); }); }, [hosts, searchQuery]); const hostsByFolder = useMemo(() => { const grouped: { [key: string]: SSHHost[] } = {}; filteredAndSortedHosts.forEach((host) => { const folder = host.folder || t("hosts.uncategorized"); if (!grouped[folder]) { grouped[folder] = []; } grouped[folder].push(host); }); const sortedFolders = Object.keys(grouped).sort((a, b) => { if (a === t("hosts.uncategorized")) return -1; if (b === t("hosts.uncategorized")) return 1; return a.localeCompare(b); }); const sortedGrouped: { [key: string]: SSHHost[] } = {}; sortedFolders.forEach((folder) => { sortedGrouped[folder] = grouped[folder]; }); return sortedGrouped; }, [filteredAndSortedHosts]); if (loading) { return (

{t("hosts.loadingHosts")}

); } if (error) { return (

{error}

); } if (hosts.length === 0) { return (

{t("hosts.sshHosts")}

{t("hosts.hostsCount", { count: 0 })}

{t("hosts.importJsonTitle")}

{t("hosts.importJsonDesc")}

{t("hosts.noHosts")}

{t("hosts.noHostsMessage")}

); } return (

{t("hosts.sshHosts")}

{t("hosts.hostsCount", { count: filteredAndSortedHosts.length })}

{t("hosts.importJsonTitle")}

{t("hosts.importJsonDesc")}

setSearchQuery(e.target.value)} className="pl-10" />
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
handleDragEnter(e, folder)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, folder)} >
{(() => { const FolderIcon = getFolderIcon(folder); const folderColor = getFolderColor(folder); return ( ); })()} {editingFolder === folder ? (
e.stopPropagation()} > setEditingFolderName(e.target.value) } onKeyDown={(e) => { if (e.key === "Enter") handleFolderRename(folder); if (e.key === "Escape") cancelFolderEdit(); }} className="h-6 text-sm px-2 flex-1" autoFocus disabled={operationLoading} />
) : ( <> { e.stopPropagation(); if (folder !== t("hosts.uncategorized")) { startFolderEdit(folder); } }} title={ folder !== t("hosts.uncategorized") ? t("hosts.clickToRenameFolder") : "" } > {folder} {folder !== t("hosts.uncategorized") && ( )} )} {folderHosts.length} {folder !== t("hosts.uncategorized") && (
{t("hosts.editFolderAppearance")} {t("hosts.deleteAllHostsInFolder")}
)}
{folderHosts.map((host) => (
handleDragStart(e, host)} onDragEnd={handleDragEnd} className={`bg-field border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-hover-alt transition-all duration-200 p-3 group relative ${ draggedHost?.id === host.id ? "opacity-50 scale-95" : "" }`} onClick={() => handleEdit(host)} >
{(() => { const statsConfig = (() => { try { return host.statsConfig ? JSON.parse(host.statsConfig) : DEFAULT_STATS_CONFIG; } catch { return DEFAULT_STATS_CONFIG; } })(); const shouldShowStatus = statsConfig.statusCheckEnabled !== false; const serverStatus = serverStatuses.get(host.id) || "degraded"; return shouldShowStatus ? ( ) : null; })()} {host.pin && ( )}

{host.name || `${host.username}@${host.ip}`}

{(host as any).isShared && ( {t("rbac.shared")} )}

{host.ip}:{host.port}

{host.username}

ID: {host.id}

{host.folder && host.folder !== "" && (

{t("hosts.removeFromFolder", { folder: host.folder, })}

)}

{t("hosts.editHostTooltip")}

{t("hosts.deleteHostTooltip")}

{t("hosts.exportHostTooltip")}

{t("hosts.cloneHostTooltip")}

{host.tags && host.tags.length > 0 && (
{host.tags .slice(0, 6) .map((tag, index) => ( {tag} ))} {host.tags.length > 6 && ( +{host.tags.length - 6} )}
)}
{host.enableTerminal && ( {t("hosts.terminalBadge")} )} {host.enableTunnel && ( {t("hosts.tunnelBadge")} {host.tunnelConnections && host.tunnelConnections.length > 0 && ( ({host.tunnelConnections.length}) )} )} {host.enableFileManager && ( {t("hosts.fileManagerBadge")} )} {host.enableDocker && ( {t("hosts.docker")} )}
{host.enableTerminal && (

{t("hosts.openTerminal")}

)} {host.enableFileManager && (

Open File Manager

)} {host.enableTunnel && (

Open Tunnels

)} {host.enableDocker && (

{t("hosts.openDocker")}

)}

Open Server Details

{t("hosts.clickToEditHost")}

{t("hosts.dragToMoveBetweenFolders")}

))}
))}
{editingFolderAppearance && ( { if (!open) setEditingFolderAppearance(null); }} onSave={async (color, icon) => { await handleSaveFolderAppearance( editingFolderAppearance, color, icon, ); setEditingFolderAppearance(null); }} /> )}
); }