import React, { useState, useEffect, useMemo, useRef, useCallback, } 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu.tsx"; import { getSSHHosts, deleteSSHHost, bulkImportSSHHosts, bulkUpdateSSHHosts, updateSSHHost, renameFolder, exportSSHHostWithCredentials, exportAllSSHHosts, getSSHFolders, updateFolderMetadata, deleteAllHostsInFolder, refreshServerPolling, isElectron, getConfiguredServerUrl, getGuacamoleDpi, getGuacamoleTokenFromHost, logActivity, } from "@/ui/main-axios.ts"; import { useServerStatus } from "@/ui/contexts/ServerStatusContext"; 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, ArrowDownUp, Container, Link, Plus, ListChecks, ChevronDown, Monitor, MessagesSquare, Eye, ChevronsDownUp, ChevronsUpDown, RefreshCw, } from "lucide-react"; import type { SSHHost, SSHFolder, SSHManagerHostViewerProps, } from "../../../../../types"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts"; import { Checkbox } from "@/components/ui/checkbox.tsx"; import { FolderEditDialog } from "@/ui/desktop/apps/host-manager/dialogs/FolderEditDialog.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; const INITIAL_HOSTS_PER_FOLDER = 12; export function HostManagerViewer({ onEditHost, onAddHost, }: 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 overwriteRef = useRef(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 [refreshing, setRefreshing] = useState(false); const [folderMetadata, setFolderMetadata] = useState>( new Map(), ); const [editingFolderAppearance, setEditingFolderAppearance] = useState< string | null >(null); const [expandedFolders, setExpandedFolders] = useState>( new Set(), ); const [openAccordions, setOpenAccordions] = useState([]); const [selectionMode, setSelectionMode] = useState(false); const [selectedHostIds, setSelectedHostIds] = useState>( new Set(), ); const [bulkUpdating, setBulkUpdating] = useState(false); const { getStatus, refreshStatuses } = useServerStatus(); 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 (options: { showLoading?: boolean } = {}) => { const { showLoading = true } = options; try { if (showLoading) 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 { if (showLoading) 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 handleRefreshHosts = async () => { setRefreshing(true); try { await Promise.all([ fetchHosts({ showLoading: false }), fetchFolderMetadata(), refreshServerPolling(), refreshStatuses(), ]); } finally { setRefreshing(false); } }; 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", ); }; 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")); 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 (selectionMode) return; if (onEditHost) { onEditHost(host); } }; const toggleHostSelection = (hostId: number) => { setSelectedHostIds((prev) => { const next = new Set(prev); if (next.has(hostId)) next.delete(hostId); else next.add(hostId); return next; }); }; const exitSelectionMode = () => { setSelectionMode(false); setSelectedHostIds(new Set()); }; const handleBulkUpdate = async (updates: Record) => { if (selectedHostIds.size === 0) return; try { setBulkUpdating(true); const result = await bulkUpdateSSHHosts( Array.from(selectedHostIds), updates, ); if (result.updated > 0) { toast.success(t("hosts.bulkUpdateSuccess", { count: result.updated })); } if (result.errors.length > 0) { toast.error(result.errors.join(", ")); } await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } catch { toast.error(t("hosts.bulkUpdateFailed")); } finally { setBulkUpdating(false); } }; const selectAllHosts = () => { const selectableIds = hosts.filter((h) => !h.isShared).map((h) => h.id); setSelectedHostIds(new Set(selectableIds)); }; const deselectAllHosts = () => { setSelectedHostIds(new Set()); }; const handleClone = (host: SSHHost) => { if (onEditHost) { const clonedHost = { ...host }; delete clonedHost.id; onEditHost(clonedHost); } }; const copyFullScreenUrl = (host: SSHHost, appType: string) => { const baseUrl = isElectron() ? getConfiguredServerUrl() || window.location.origin : window.location.origin; const url = `${baseUrl}?view=${appType}&hostId=${host.id}`; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(url).then( () => { toast.success(t("hosts.fullScreenUrlCopied")); }, () => { fallbackCopyTextToClipboard(url); }, ); } else { fallbackCopyTextToClipboard(url); } }; const fallbackCopyTextToClipboard = (text: string) => { const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; textArea.style.left = "-999999px"; textArea.style.top = "-999999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand("copy"); toast.success(t("hosts.fullScreenUrlCopied")); } catch { toast.error(t("hosts.failedToCopyUrl")); } document.body.removeChild(textArea); }; 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: [ { connectionType: "ssh", 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, showTerminalInSidebar: true, showFileManagerInSidebar: true, showServerStatsInSidebar: true, defaultPath: "/var/www", }, { connectionType: "ssh", 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, showTerminalInSidebar: true, showTunnelInSidebar: true, showServerStatsInSidebar: true, 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, }, }, { connectionType: "ssh", 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, showTerminalInSidebar: true, showFileManagerInSidebar: true, showDockerInSidebar: true, defaultPath: "/home/developer", sudoPassword: "dev_sudo_password", }, { connectionType: "rdp", name: "Windows Server 2022", ip: "192.168.1.200", port: 3389, username: "Administrator", password: "windows_password", domain: "COMPANY", security: "nla", ignoreCert: false, folder: "Remote Desktop", tags: ["rdp", "windows", "production"], pin: false, notes: "Production Windows Server with RDP access", guacamoleConfig: { "enable-drive": true, "drive-path": "/shared", "create-drive-path": true, "server-layout": "en-us-qwerty", "resize-method": "display-update", }, }, { connectionType: "vnc", name: "Ubuntu Desktop", ip: "192.168.1.201", port: 5900, username: "vncuser", password: "vnc_password", folder: "Remote Desktop", tags: ["vnc", "linux", "desktop"], pin: false, notes: "Ubuntu desktop with VNC server", guacamoleConfig: { "color-depth": 24, cursor: "remote", "read-only": false, "clipboard-encoding": "UTF-8", }, }, { connectionType: "telnet", name: "Network Switch", ip: "192.168.1.254", port: 23, username: "admin", password: "switch_password", folder: "Infrastructure", tags: ["telnet", "network", "switch"], pin: false, notes: "Legacy network switch with Telnet access", guacamoleConfig: { "color-scheme": "green-black", "font-name": "monospace", "font-size": 12, scrollback: 1024, backspace: 127, }, }, { connectionType: "ssh", 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, }, ], }, { connectionType: "ssh", 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", }, { connectionType: "ssh", 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 [exporting, setExporting] = useState(false); const handleExportAll = () => { confirmWithToast( t("hosts.exportAllSensitiveWarning"), async () => { setExporting(true); try { const data = await exportAllSSHHosts(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `termix-hosts-export-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success( t("hosts.exportedAllHosts", { count: data.hosts.length }), ); } catch { toast.error(t("hosts.failedToExportAllHosts")); } finally { setExporting(false); } }, "destructive", ); }; 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, overwriteRef.current); if (result.success > 0 || result.updated > 0) { const parts: string[] = []; if (result.success > 0) parts.push(`${result.success} ${t("hosts.importCreated")}`); if (result.updated > 0) parts.push(`${result.updated} ${t("hosts.importUpdated")}`); if (result.failed > 0) parts.push(`${result.failed} ${t("hosts.importFailedCount")}`); toast.success(parts.join(", ")); 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]); const folderKeys = useMemo(() => Object.keys(hostsByFolder), [hostsByFolder]); const folderKeysString = useMemo(() => folderKeys.join(","), [folderKeys]); useEffect(() => { setOpenAccordions((prev) => { if (prev.length === 0 && folderKeys.length > 0) { return folderKeys; } const existingFolders = prev.filter((folder) => folderKeys.includes(folder), ); const newFolders = folderKeys.filter((folder) => !prev.includes(folder)); return [...existingFolders, ...newFolders]; }); }, [folderKeysString]); const toggleFolderExpansion = useCallback((folderName: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(folderName)) { next.delete(folderName); } else { next.add(folderName); } return next; }); }, []); const getVisibleHosts = useCallback( (folderName: string, allHosts: SSHHost[]) => { if ( expandedFolders.has(folderName) || allHosts.length <= INITIAL_HOSTS_PER_FOLDER ) { return allHosts; } return allHosts.slice(0, INITIAL_HOSTS_PER_FOLDER); }, [expandedFolders], ); if (loading) { return ; } if (error) { return (

{error}

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

{t("hosts.sshHosts")}

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

{ overwriteRef.current = false; document.getElementById("json-import-input")?.click(); }} > {t("hosts.importSkipExisting")} { overwriteRef.current = true; document.getElementById("json-import-input")?.click(); }} > {t("hosts.importOverwriteExisting")}
setSearchQuery(e.target.value)} className="pl-10 h-9" disabled />

{t("hosts.noHosts")}

{t("hosts.noHostsMessage")}

); } return (

{t("hosts.sshHosts")}

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

{ overwriteRef.current = false; document.getElementById("json-import-input")?.click(); }} > {t("hosts.importSkipExisting")} { overwriteRef.current = true; document.getElementById("json-import-input")?.click(); }} > {t("hosts.importOverwriteExisting")}
setSearchQuery(e.target.value)} className="pl-10 h-9" />
{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} {selectionMode && (() => { const selectableIds = folderHosts .filter((h) => !h.isShared) .map((h) => h.id); const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedHostIds.has(id), ); return ( { setSelectedHostIds((prev) => { const next = new Set(prev); if (checked) { selectableIds.forEach((id) => next.add(id), ); } else { selectableIds.forEach((id) => next.delete(id), ); } return next; }); }} onClick={(e) => e.stopPropagation()} className="ml-1" /> ); })()} {folder !== t("hosts.uncategorized") && (
{t("hosts.editFolderAppearance")} {t("hosts.deleteAllHostsInFolder")}
)}
{getVisibleHosts(folder, folderHosts).map((host) => (
handleDragStart(e, host)} onDragEnd={handleDragEnd} className={`bg-field border rounded-lg cursor-pointer hover:shadow-lg hover:bg-hover-alt transition-all duration-200 p-3 group relative ${ draggedHost?.id === host.id ? "opacity-50 scale-95" : "" } ${ selectionMode && selectedHostIds.has(host.id) ? "ring-2 ring-blue-500 border-blue-500" : "border-input hover:border-blue-400/50" } ${ selectionMode && host.isShared ? "opacity-50 pointer-events-none" : "" }`} onClick={() => { if (selectionMode && !host.isShared) { toggleHostSelection(host.id); } else { handleEdit(host); } }} >
{selectionMode && !host.isShared && ( toggleHostSelection(host.id) } onClick={(e) => e.stopPropagation()} className="bg-background border-2 mr-1 flex-shrink-0" /> )} {(() => { const statsConfig = (() => { if (!host.statsConfig) { return DEFAULT_STATS_CONFIG; } if ( typeof host.statsConfig === "object" ) { return host.statsConfig; } try { return JSON.parse(host.statsConfig); } catch { return DEFAULT_STATS_CONFIG; } })(); const shouldShowStatus = ![ false, "false", ].includes( statsConfig.statusCheckEnabled, ); if (!shouldShowStatus) return null; const serverStatus = getStatus(host.id); return ( ); })()} {host.pin && ( )}

{host.name || (host.username ? `${host.username}@${host.ip}` : host.ip)}

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

{host.ip}:{host.port}

{host.username && (

{host.username}

)}

ID: {host.id}

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

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

)}

{t("hosts.editHostTooltip")}

{!host.isShared && ( <>

{t("hosts.deleteHostTooltip")}

{t("hosts.exportHostTooltip")}

{t("hosts.cloneHostTooltip")}

{t("hosts.copyFullScreenUrl")}

{(() => { const connType = host.connectionType; const isRemoteDesktop = connType === "rdp" || connType === "vnc" || connType === "telnet"; if (isRemoteDesktop) { return ( { e.stopPropagation(); copyFullScreenUrl( host, connType, ); }} > {connType === "rdp" ? ( ) : connType === "vnc" ? ( ) : ( )} {t( "hosts.copyRemoteDesktopUrl", )} ); } return ( <> {host.enableTerminal && ( { e.stopPropagation(); copyFullScreenUrl( host, "terminal", ); }} > {t( "hosts.copyTerminalUrl", )} )} {host.enableFileManager && ( { e.stopPropagation(); copyFullScreenUrl( host, "file-manager", ); }} > {t( "hosts.copyFileManagerUrl", )} )} {host.enableTunnel && ( { e.stopPropagation(); copyFullScreenUrl( host, "tunnel", ); }} > {t("hosts.copyTunnelUrl")} )} { e.stopPropagation(); copyFullScreenUrl( host, "server-stats", ); }} > {t( "hosts.copyServerStatsUrl", )} {host.enableDocker && ( { e.stopPropagation(); copyFullScreenUrl( host, "docker", ); }} > {t("hosts.copyDockerUrl")} )} ); })()}
)}
{host.tags && host.tags.length > 0 && (
{host.tags .slice(0, 6) .map((tag, index) => ( {tag} ))} {host.tags.length > 6 && ( +{host.tags.length - 6} )}
)}
{(() => { const connType = host.connectionType; if (connType === "rdp") { return ( {t("hosts.rdp")} ); } if (connType === "vnc") { return ( {t("hosts.vnc")} ); } if (connType === "telnet") { return ( {t("hosts.telnet")} ); } return ( <> {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")} )} ); })()}
{(() => { const connType = host.connectionType; const isRemoteDesktop = connType === "rdp" || connType === "vnc" || connType === "telnet"; if (isRemoteDesktop) { return (

{t("hosts.remoteDesktop")}

); } return ( <> {host.enableTerminal && (

{t("hosts.openTerminal")}

)} {host.enableFileManager && (

{t("hosts.openFileManager")}

)} {host.enableTunnel && (

{t("hosts.openTunnels")}

)} {host.enableDocker && (

{t("hosts.openDocker")}

)}

{t("hosts.openServerStats")}

); })()}

{t("hosts.clickToEditHost")}

{t("hosts.dragToMoveBetweenFolders")}

))}
{folderHosts.length > INITIAL_HOSTS_PER_FOLDER && (
)}
))}
{editingFolderAppearance && ( { if (!open) setEditingFolderAppearance(null); }} onSave={async (color, icon) => { await handleSaveFolderAppearance( editingFolderAppearance, color, icon, ); setEditingFolderAppearance(null); }} /> )} {selectionMode && (
{t("hosts.selectedCount", { count: selectedHostIds.size })}
{ e.preventDefault(); handleBulkUpdate({ statsConfig: { statusCheckEnabled: true }, }); }} > {t("hosts.enableStatusCheck")} { e.preventDefault(); handleBulkUpdate({ statsConfig: { statusCheckEnabled: false }, }); }} > {t("hosts.disableStatusCheck")} { e.preventDefault(); handleBulkUpdate({ statsConfig: { useGlobalStatusInterval: true }, }); }} > {t("hosts.useGlobalStatusDefault")}
{ e.preventDefault(); handleBulkUpdate({ statsConfig: { metricsEnabled: true } }); }} > {t("hosts.enableMetrics")} { e.preventDefault(); handleBulkUpdate({ statsConfig: { metricsEnabled: false }, }); }} > {t("hosts.disableMetrics")} { e.preventDefault(); handleBulkUpdate({ statsConfig: { useGlobalMetricsInterval: true }, }); }} > {t("hosts.useGlobalMetricsDefault")} { e.preventDefault(); handleBulkUpdate({ enableTerminal: true, enableFileManager: true, enableTunnel: true, enableDocker: true, }); }} > {t("hosts.enableAllFeatures")} { e.preventDefault(); handleBulkUpdate({ enableTerminal: false, enableFileManager: false, enableTunnel: false, enableDocker: false, }); }} > {t("hosts.disableAllFeatures")}
{ e.preventDefault(); handleBulkUpdate({ enableTerminal: true }); }} > {t("hosts.bulkEnableTerminal")} { e.preventDefault(); handleBulkUpdate({ enableTerminal: false }); }} > {t("hosts.bulkDisableTerminal")} { e.preventDefault(); handleBulkUpdate({ enableFileManager: true }); }} > {t("hosts.bulkEnableFileManager")} { e.preventDefault(); handleBulkUpdate({ enableFileManager: false }); }} > {t("hosts.bulkDisableFileManager")} { e.preventDefault(); handleBulkUpdate({ enableTunnel: true }); }} > {t("hosts.bulkEnableTunnel")} { e.preventDefault(); handleBulkUpdate({ enableTunnel: false }); }} > {t("hosts.bulkDisableTunnel")} { e.preventDefault(); handleBulkUpdate({ enableDocker: true }); }} > {t("hosts.bulkEnableDocker")} { e.preventDefault(); handleBulkUpdate({ enableDocker: false }); }} > {t("hosts.bulkDisableDocker")} { e.preventDefault(); handleBulkUpdate({ folder: "" }); }} > {t("hosts.uncategorized")} {Object.keys(hostsByFolder) .filter((f) => f !== t("hosts.uncategorized")) .map((f) => { const FolderIcon = getFolderIcon(f); const folderColor = getFolderColor(f); return ( { e.preventDefault(); handleBulkUpdate({ folder: f }); }} > {f} ); })} { e.preventDefault(); handleBulkUpdate({ pin: true }); }} > {t("hosts.bulkPin")} { e.preventDefault(); handleBulkUpdate({ pin: false }); }} > {t("hosts.bulkUnpin")}
)}
); }