import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowUpDown, Check, ChevronsDownUp, ChevronsUpDown, Download, Filter, FolderPlus, Group, ListChecks, MoreHorizontal, Plus, RefreshCw, Search, Server, Upload, X, } from "lucide-react"; import { toast } from "sonner"; import { SidebarTree, isFolder } from "@/sidebar/SidebarTree"; import { HostManager } from "@/sidebar/HostManager"; import { HostShareModal } from "@/sidebar/HostShareModal"; import { ProxmoxDiscoverDialog } from "@/components/proxmox/ProxmoxDiscoverDialog"; import { Button } from "@/components/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/dropdown-menu"; import { getSSHHosts, bulkImportSSHHosts, importSSHConfigHosts, exportAllSSHHosts, } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios"; import type { Host, HostFolder, TabType } from "@/types/ui-types"; import { resolveHostSortPreferences, sortHostTree, type SortKey, } from "@/sidebar/host-sort"; type FilterState = { status: ("online" | "offline" | "pinned")[]; authType: ("password" | "key" | "credential" | "none" | "opkssh")[]; protocol: ("ssh" | "rdp" | "vnc" | "telnet")[]; features: ("terminal" | "fileManager" | "tunnel" | "docker")[]; tags: string[]; }; const DEFAULT_FILTERS: FilterState = { status: [], authType: [], protocol: [], features: [], tags: [], }; type GroupKey = "folder" | "tag" | "status" | "protocol" | "auth"; function flattenHosts(folder: HostFolder): Host[] { const out: Host[] = []; for (const child of folder.children) { if (isFolder(child)) out.push(...flattenHosts(child)); else out.push(child); } return out; } function hostGroupNames(host: Host, key: GroupKey): string[] { switch (key) { case "tag": return host.tags && host.tags.length > 0 ? host.tags : ["__none__"]; case "status": return [host.online ? "online" : "offline"]; case "protocol": { const protos: string[] = []; if (host.enableSsh) protos.push("ssh"); if (host.enableRdp) protos.push("rdp"); if (host.enableVnc) protos.push("vnc"); if (host.enableTelnet) protos.push("telnet"); return protos.length > 0 ? protos : ["__none__"]; } case "auth": return [host.authType || "none"]; default: return ["__none__"]; } } function groupHosts( tree: HostFolder, key: GroupKey, labelFor: (key: GroupKey, group: string) => string, ): HostFolder { if (key === "folder") return tree; const hosts = flattenHosts(tree); const groups = new Map(); for (const host of hosts) { for (const name of hostGroupNames(host, key)) { if (!groups.has(name)) groups.set(name, []); groups.get(name)!.push(host); } } const children: (Host | HostFolder)[] = [...groups.entries()] .sort((a, b) => labelFor(key, a[0]).localeCompare(labelFor(key, b[0]))) .map(([group, members]) => ({ name: labelFor(key, group), path: `__group__:${key}:${group}`, children: members, })); return { name: "root", children }; } function hostPassesFilters(host: Host, filters: FilterState): boolean { if (filters.status.length > 0) { const ok = (filters.status.includes("online") && host.online) || (filters.status.includes("offline") && !host.online) || (filters.status.includes("pinned") && !!host.pin); if (!ok) return false; } if (filters.authType.length > 0) { if ( !filters.authType.includes( host.authType as FilterState["authType"][number], ) ) return false; } if (filters.protocol.length > 0) { const ok = (filters.protocol.includes("ssh") && host.enableSsh) || (filters.protocol.includes("rdp") && host.enableRdp) || (filters.protocol.includes("vnc") && host.enableVnc) || (filters.protocol.includes("telnet") && host.enableTelnet); if (!ok) return false; } if (filters.features.length > 0) { const ok = (filters.features.includes("terminal") && host.enableTerminal) || (filters.features.includes("fileManager") && host.enableFileManager) || (filters.features.includes("tunnel") && host.enableTunnel) || (filters.features.includes("docker") && host.enableDocker); if (!ok) return false; } if (filters.tags.length > 0) { const ok = filters.tags.some((tag) => host.tags?.includes(tag)); if (!ok) return false; } return true; } function applyFilters(folder: HostFolder, filters: FilterState): HostFolder { const active = Object.values(filters).some((arr) => arr.length > 0); if (!active) return folder; const filteredChildren = folder.children .map((child) => { if (isFolder(child)) return applyFilters(child, filters); return hostPassesFilters(child, filters) ? child : null; }) .filter((child): child is Host | HostFolder => { if (child === null) return false; if (isFolder(child)) return child.children.length > 0; return true; }); return { ...folder, children: filteredChildren }; } export function HostsPanel({ onOpenTab, onEditHost, hostTree, loading, onEditingChange, active = true, }: { onOpenTab: (host: Host, type: TabType) => void; onEditHost: (host: Host) => void; hostTree?: HostFolder; loading?: boolean; onEditingChange?: (editing: boolean) => void; active?: boolean; }) { const { t } = useTranslation(); const [hostSearch, setHostSearch] = useState(""); const [managerEditing, setManagerEditing] = useState(false); const [selectionMode, setSelectionMode] = useState(false); const [refreshing, setRefreshing] = useState(false); const [rawHosts, setRawHosts] = useState([]); const [shareModalHost, setShareModalHost] = useState(null); const [proxmoxDialogOpen, setProxmoxDialogOpen] = useState(false); const [proxmoxHostId, setProxmoxHostId] = useState( undefined, ); const [proxmoxDefaultCredentialId, setProxmoxDefaultCredentialId] = useState< number | null >(null); const [proxmoxDefaultAuthType, setProxmoxDefaultAuthType] = useState< string | undefined >(undefined); const [proxmoxDefaultUsername, setProxmoxDefaultUsername] = useState< string | undefined >(undefined); const [sortKey, setSortKey] = useState(() => { return resolveHostSortPreferences( localStorage.getItem("hostSortKey"), localStorage.getItem("hostPinnedFirst"), ).sortKey; }); const [pinnedFirst, setPinnedFirst] = useState(() => { return resolveHostSortPreferences( localStorage.getItem("hostSortKey"), localStorage.getItem("hostPinnedFirst"), ).pinnedFirst; }); const [groupKey, setGroupKey] = useState( () => (localStorage.getItem("hostGroupKey") as GroupKey) ?? "folder", ); const [filterState, setFilterState] = useState(() => { try { const saved = localStorage.getItem("hostFilterState"); return saved ? (JSON.parse(saved) as FilterState) : DEFAULT_FILTERS; } catch { return DEFAULT_FILTERS; } }); const filterActive = Object.values(filterState).some((arr) => arr.length > 0); const fileInputRef = useRef(null); const sshConfigInputRef = useRef(null); const importOverwriteRef = useRef(false); const allTags = [...new Set(rawHosts.flatMap((h) => h.tags ?? []))]; function handleSortChange(key: SortKey) { setSortKey(key); localStorage.setItem("hostSortKey", key); } function handlePinnedFirstChange(enabled: boolean) { setPinnedFirst(enabled); localStorage.setItem("hostPinnedFirst", String(enabled)); } function handleGroupChange(key: GroupKey) { setGroupKey(key); localStorage.setItem("hostGroupKey", key); } function groupLabel(key: GroupKey, group: string): string { if (group === "__none__") return t("hosts.groupUngrouped"); if (key === "status") return group === "online" ? t("hosts.filterOnline") : t("hosts.filterOffline"); if (key === "protocol") return group.toUpperCase(); if (key === "auth") return t( `hosts.filterAuth${group.charAt(0).toUpperCase() + group.slice(1)}`, ); return group; } function handleFilterToggle( group: K, value: FilterState[K][number], ) { setFilterState((prev) => { const arr = prev[group] as string[]; const next = arr.includes(value as string) ? arr.filter((v) => v !== value) : [...arr, value as string]; const updated = { ...prev, [group]: next }; localStorage.setItem("hostFilterState", JSON.stringify(updated)); return updated as FilterState; }); } function handleFilterClear() { setFilterState(DEFAULT_FILTERS); localStorage.setItem("hostFilterState", JSON.stringify(DEFAULT_FILTERS)); } useEffect(() => { getSSHHosts() .then(setRawHosts) .catch(() => {}); }, []); function handleEditingChange(editing: boolean) { setManagerEditing(editing); onEditingChange?.(editing); } function toggleSelectionMode() { setSelectionMode((v) => !v); } async function handleRefresh() { setRefreshing(true); try { const hosts = await getSSHHosts(); setRawHosts(hosts); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); } catch { // best-effort } finally { setRefreshing(false); } } async function handleExportHosts(share = false) { try { const result = await exportAllSSHHosts( share ? { share: true } : undefined, ); const data = JSON.stringify(result, null, 2); const blob = new Blob([data], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = share ? "termix-hosts-share.json" : "termix-hosts.json"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success( t(share ? "hosts.hostsShareExported" : "hosts.hostsExported"), ); } catch { toast.error(t("hosts.exportFailed")); } } function handleDownloadSample() { const sample = JSON.stringify( { hosts: [ { name: "Web Server (Production)", ip: "192.168.1.100", username: "admin", authType: "password", password: "your_secure_password_here", folder: "Production", tags: ["web", "production", "nginx"], pin: true, notes: "Main production web server running Nginx", enableSsh: true, enableRdp: false, enableVnc: false, enableTelnet: false, sshPort: 22, enableTerminal: true, enableTunnel: false, enableFileManager: true, enableDocker: false, defaultPath: "/var/www", }, { name: "Database Server", ip: "192.168.1.101", 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: "Production", tags: ["database", "production", "postgresql"], enableSsh: true, enableRdp: false, enableVnc: false, enableTelnet: false, sshPort: 22, enableTerminal: true, enableTunnel: true, enableFileManager: false, enableDocker: false, }, ], }, null, 2, ); const blob = new Blob([sample], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "termix-hosts-sample.json"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } return (
{!managerEditing && (
setHostSearch(e.target.value)} placeholder={t("hosts.searchHosts")} className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0" /> {hostSearch && ( )}
{ const file = e.target.files?.[0]; if (!file) return; e.target.value = ""; try { const text = await file.text(); const parsed = JSON.parse(text); const hostsArray = Array.isArray(parsed) ? parsed : (parsed.hosts ?? []); const credentialsArray = !Array.isArray(parsed) && Array.isArray(parsed.credentials) ? parsed.credentials : undefined; if (!Array.isArray(hostsArray) || hostsArray.length === 0) { toast.error("No hosts found in file"); return; } if (hostsArray.length > 100) { toast.error("Cannot import more than 100 hosts at once"); return; } const normalized = hostsArray.map( (h: Record) => ({ ...h, port: h.port ?? h.sshPort ?? 22, enableSsh: h.enableSsh ?? h.connectionType === "ssh", enableRdp: h.enableRdp ?? h.connectionType === "rdp", enableVnc: h.enableVnc ?? h.connectionType === "vnc", enableTelnet: h.enableTelnet ?? h.connectionType === "telnet", }), ); const result = await bulkImportSSHHosts( normalized, importOverwriteRef.current, credentialsArray, ); const hosts = await getSSHHosts(); setRawHosts(hosts); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); const msg = [ result.success ? `${result.success} imported` : null, result.updated ? `${result.updated} updated` : null, result.failed ? `${result.failed} failed` : null, ] .filter(Boolean) .join(", "); toast.success(`Import complete: ${msg}`); } catch (err: unknown) { toast.error( err instanceof Error ? err.message : "Failed to import hosts", ); } }} /> { const file = e.target.files?.[0]; if (!file) return; e.target.value = ""; try { const text = await file.text(); const result = await importSSHConfigHosts( text, importOverwriteRef.current, ); const hosts = await getSSHHosts(); setRawHosts(hosts); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); const msg = [ result.success ? `${result.success} imported` : null, result.updated ? `${result.updated} updated` : null, result.failed ? `${result.failed} failed` : null, ] .filter(Boolean) .join(", "); toast.success(`${t("hosts.importSSHConfig")}: ${msg}`); } catch (err: unknown) { toast.error( err instanceof Error ? err.message : "Failed to import SSH config", ); } }} />
{ importOverwriteRef.current = false; fileInputRef.current?.click(); }} > {t("hosts.importSkipExisting")} { importOverwriteRef.current = true; fileInputRef.current?.click(); }} > {t("hosts.importOverwrite")} { importOverwriteRef.current = false; sshConfigInputRef.current?.click(); }} > {t("hosts.importSSHConfig")} { setProxmoxHostId(undefined); setProxmoxDialogOpen(true); }} disabled={ !rawHosts.some( (h) => !isFolder(h) && (h as any).enableProxmox, ) } > {t("hosts.proxmoxImportTitle")} handleExportHosts(false)} disabled={rawHosts.length === 0} > {t("hosts.exportAll")} handleExportHosts(true)} disabled={rawHosts.length === 0} > {t("hosts.exportForSharing")} {t("hosts.downloadSample")} handleSortChange("default")} className="flex items-center gap-1.5" > {sortKey === "default" ? ( ) : ( )} {t("hosts.sortDefault")} {(["name-asc", "name-desc"] as const).map((key) => ( handleSortChange(key)} className="flex items-center gap-1.5" > {sortKey === key ? ( ) : ( )} {t( `hosts.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`, )} ))} {(["ip-asc", "ip-desc"] as const).map((key) => ( handleSortChange(key)} className="flex items-center gap-1.5" > {sortKey === key ? ( ) : ( )} {t(`hosts.sort${key === "ip-asc" ? "IpAsc" : "IpDesc"}`)} ))} {(["status-online", "status-offline"] as const).map((key) => ( handleSortChange(key)} className="flex items-center gap-1.5" > {sortKey === key ? ( ) : ( )} {t( key === "status-online" ? "hosts.sortOnlineFirst" : "hosts.sortOfflineFirst", )} ))} event.preventDefault()} > {t("hosts.sortPinnedFirst")} {filterActive && ( <> {t("hosts.filterClearAll")} )} {t("hosts.filterStatusGroup")} {(["online", "offline", "pinned"] as const).map((val) => ( handleFilterToggle("status", val)} onSelect={(e) => e.preventDefault()} > {t( `hosts.filter${val.charAt(0).toUpperCase() + val.slice(1)}`, )} ))} {t("hosts.filterAuthGroup")} {( ["password", "key", "credential", "none", "opkssh"] as const ).map((val) => ( handleFilterToggle("authType", val)} onSelect={(e) => e.preventDefault()} > {t( `hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`, )} ))} {t("hosts.filterProtocolGroup")} {( [ ["ssh", "Ssh"], ["rdp", "Rdp"], ["vnc", "Vnc"], ["telnet", "Telnet"], ] as const ).map(([val, key]) => ( handleFilterToggle("protocol", val)} onSelect={(e) => e.preventDefault()} > {t(`hosts.filterProtocol${key}`)} ))} {t("hosts.filterFeaturesGroup")} {( [ ["terminal", "Terminal"], ["fileManager", "FileManager"], ["tunnel", "Tunnel"], ["docker", "Docker"], ] as const ).map(([val, key]) => ( handleFilterToggle("features", val)} onSelect={(e) => e.preventDefault()} > {t(`hosts.filterFeature${key}`)} ))} {allTags.length > 0 && ( <> {t("hosts.filterTagsGroup")} {allTags.map((tag) => ( handleFilterToggle("tags", tag)} onSelect={(e) => e.preventDefault()} > {tag} ))} )} {t("hosts.groupBy")} {groupKey !== "folder" && ( {t( `hosts.GroupBy${groupKey.charAt(0).toUpperCase() + groupKey.slice(1)}`, )} )} {( [ ["folder", "GroupByFolder"], ["tag", "GroupByTag"], ["status", "GroupByStatus"], ["protocol", "GroupByProtocol"], ["auth", "GroupByAuth"], ] as const ).map(([key, label]) => ( handleGroupChange(key)} className="flex items-center gap-1.5" > {groupKey === key ? ( ) : ( )} {t(`hosts.${label}`)} ))} window.dispatchEvent(new CustomEvent("hosts:create-folder")) } > {t("hosts.newFolder")} window.dispatchEvent(new CustomEvent("hosts:expand-all")) } > {t("hosts.expandAll")} window.dispatchEvent(new CustomEvent("hosts:collapse-all")) } > {t("hosts.collapseAll")} {selectionMode ? t("hosts.exitSelectionTitle") : t("hosts.selectHosts")} {t("hosts.importExportBtn")} { importOverwriteRef.current = false; fileInputRef.current?.click(); }} > {t("hosts.importSkipExisting")} { importOverwriteRef.current = true; fileInputRef.current?.click(); }} > {t("hosts.importOverwrite")} { importOverwriteRef.current = false; sshConfigInputRef.current?.click(); }} > {t("hosts.importSSHConfig")} handleExportHosts(false)} disabled={rawHosts.length === 0} > {t("hosts.exportAll")} handleExportHosts(true)} disabled={rawHosts.length === 0} > {t("hosts.exportForSharing")} {t("hosts.downloadSample")}
)}
setShareModalHost(host)} onProxmoxDiscover={(host) => { const cfg = host.proxmoxConfig; setProxmoxHostId(Number(host.id)); setProxmoxDefaultCredentialId(cfg?.defaultCredentialId ?? null); setProxmoxDefaultAuthType(cfg?.defaultAuthType ?? undefined); setProxmoxDefaultUsername(undefined); setProxmoxDialogOpen(true); }} query={hostSearch.trim().toLowerCase()} selectionMode={selectionMode} onToggleSelectionMode={toggleSelectionMode} loading={loading} />
setShareModalHost(null)} host={shareModalHost} /> { setProxmoxDialogOpen(false); setProxmoxHostId(undefined); }} hosts={rawHosts} onHostsChanged={setRawHosts} preselectedHostId={proxmoxHostId} defaultCredentialId={proxmoxDefaultCredentialId} defaultAuthType={proxmoxDefaultAuthType} defaultUsername={proxmoxDefaultUsername} />
); }