diff --git a/package-lock.json b/package-lock.json index a2021ba0..3aa82037 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "2.2.1", + "version": "2.3.0", "hasInstallScript": true, "dependencies": { "axios": "^1.15.2", diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 087366fe..515b65af 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -150,7 +150,8 @@ export type TabType = | "user-profile" | "admin-settings" | "docker" - | "tunnel"; + | "tunnel" + | "network_graph"; export type TunnelStatusValue = | "CONNECTED" diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index d9368574..6dbda7f5 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -119,6 +119,7 @@ const SINGLETON_TAB_LABELS: Partial> = { "host-manager": "Host Manager", docker: "Docker", tunnel: "Tunnels", + network_graph: "Network Graph", }; // ─── AppShell ──────────────────────────────────────────────────────────────── @@ -592,8 +593,6 @@ export function AppShell({ "host-manager", "user-profile", "admin-settings", - "docker", - "tunnel", ].includes(type) ) { openSingletonTab(type, pendingEvent); diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 6fa84b88..3d68802c 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -12,7 +12,6 @@ import { LayoutDashboard, Network, Plus, - RefreshCw, Server, Settings, Terminal, @@ -20,7 +19,6 @@ import { User, Zap, } from "lucide-react"; -import CytoscapeComponent from "react-cytoscapejs"; import { Kbd } from "@/components/kbd"; import { DASHBOARD_CARDS } from "@/lib/theme"; import type { DashboardCardId, TabType, Host } from "@/types/ui-types"; @@ -33,16 +31,21 @@ import { getTunnelStatuses, getCredentials, resetRecentActivity, + getAllServerStatuses, + getServerMetricsById, + registerMetricsViewer, + sendMetricsHeartbeat, } from "@/main-axios"; import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios"; import { useTranslation } from "react-i18next"; +import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard"; function sshHostToHost(h: SSHHostWithStatus): Host { return { id: String(h.id), name: h.name, - user: h.username, - address: h.ip, + username: h.username, + ip: h.ip, port: h.port, folder: h.folder ?? "", online: h.status === "online", @@ -365,9 +368,11 @@ function QuickActionsCard({ function HostStatusCard({ hosts, + hostMetrics, onOpenTab, }: { hosts: Host[]; + hostMetrics: Map; onOpenTab: (host: Host, type: TabType) => void; }) { const { t } = useTranslation(); @@ -391,79 +396,89 @@ function HostStatusCard({ {t("dashboardTab.noHostsConfigured")} )} - {hosts.map((host, i) => ( -
onOpenTab(host, "stats")} - className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer" - > -
- -
- {host.name} - - {host.ip} + {hosts.map((host, i) => { + const metrics = hostMetrics.get(host.id); + const cpu = metrics?.cpu ?? null; + const ram = metrics?.ram ?? null; + const hasMetrics = cpu !== null || ram !== null; + return ( +
onOpenTab(host, "stats")} + className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer" + > +
+ +
+ {host.name} + + {host.ip} + +
+
+
+ {host.online && hasMetrics ? ( +
+ {cpu !== null && ( +
+
+ + {t("dashboard.cpu")} + + + {cpu.toFixed(0)}% + +
+
+
+
+
+ )} + {ram !== null && ( +
+
+ + {t("dashboard.ram")} + + + {ram.toFixed(0)}% + +
+
+
+
+
+ )} +
+ ) : ( +
+ + — + + + — + +
+ )} + + {host.online + ? t("dashboardTab.online") + : t("dashboardTab.offline")}
-
- {host.online ? ( -
-
-
- - {t("dashboard.cpu")} - - - {host.cpu ?? 0}% - -
-
-
-
-
-
-
- - {t("dashboard.ram")} - - - {host.ram ?? 0}% - -
-
-
-
-
-
- ) : ( -
- - — - - - — - -
- )} - - {host.online - ? t("dashboardTab.online") - : t("dashboardTab.offline")} - -
-
- ))} + ); + })}
); @@ -579,188 +594,6 @@ function RecentActivityCard({ ); } -function NetworkGraphCard({ hosts }: { hosts: Host[] }) { - const { t } = useTranslation(); - const cyRef = useRef(null); - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - x: number; - y: number; - node: any; - } | null>(null); - const contextMenuRef = useRef(null); - - const elements = hosts.map((h, i) => ({ - data: { - id: h.id, - label: h.name, - ip: `${h.ip}:${h.port ?? 22}`, - status: h.online ? "online" : "offline", - }, - position: { x: 120 + (i % 4) * 160, y: 80 + Math.floor(i / 4) * 100 }, - })); - - const buildNodeStyle = useCallback((ele: any) => { - const isOnline = ele.data("status") === "online"; - const name = ele.data("label") || ""; - const ip = ele.data("ip") || ""; - const statusColor = isOnline ? "rgb(251,146,60)" : "rgb(100,116,139)"; - const svg = ` - - - - - - ${name} - ${ip} - `; - return "data:image/svg+xml;utf8," + encodeURIComponent(svg); - }, []); - - const handleCyInit = useCallback( - (cy: any) => { - cyRef.current = cy; - cy.style() - .selector("node") - .style({ - label: "", - width: "160px", - height: "72px", - shape: "round-rectangle", - "border-width": "0px", - "background-opacity": 0, - "background-image": buildNodeStyle, - "background-fit": "contain", - }) - .selector("edge") - .style({ - width: "1.5px", - "line-color": "#2a2a2c", - "curve-style": "bezier", - "target-arrow-shape": "none", - }) - .selector("node:selected") - .style({ - "overlay-color": "#fb923c", - "overlay-opacity": 0.08, - "overlay-padding": "4px", - }) - .update(); - cy.nodes().ungrabify(); - cy.on("tap", (evt: any) => { - if (evt.target === cy) setContextMenu(null); - }); - cy.on("cxttap tap", "node", (evt: any) => { - evt.stopPropagation(); - const node = evt.target; - setContextMenu({ - visible: true, - x: evt.originalEvent.clientX, - y: evt.originalEvent.clientY, - node: node.data(), - }); - }); - cy.on("zoom pan", () => setContextMenu(null)); - }, - [buildNodeStyle], - ); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if ( - contextMenuRef.current && - !contextMenuRef.current.contains(e.target as Node) - ) - setContextMenu(null); - }; - document.addEventListener("mousedown", handler, true); - return () => document.removeEventListener("mousedown", handler, true); - }, []); - - return ( - -
-
- - - {t("dashboard.networkGraph")} - -
-
- - {t("dashboardTab.nodes", { count: hosts.length })} - - -
-
-
- {contextMenu?.visible && ( -
-
- - {contextMenu.node.label} - - - {contextMenu.node.ip} - -
- - -
- )} - {hosts.length > 0 ? ( - - ) : ( -
- {t("dashboardTab.noHostsToDisplay")} -
- )} -
-
- - - {t("dashboardTab.onlineLower")} - -
-
- - - {t("dashboardTab.offlineLower")} - -
-
-
-
- ); -} - // ─── CardItem ───────────────────────────────────────────────────────────────── function CardItem({ @@ -775,6 +608,7 @@ function CardItem({ onOpenSingletonTab, onOpenTab, hosts, + hostMetrics, uptimeFormatted, versionText, versionStatus, @@ -795,6 +629,7 @@ function CardItem({ onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; + hostMetrics: Map; uptimeFormatted: string; versionText: string; versionStatus: "up_to_date" | "requires_update" | "beta"; @@ -874,7 +709,11 @@ function CardItem({ )} {slot.id === "host_status" && ( - + )} {slot.id === "recent_activity" && ( )} - {slot.id === "network_graph" && } + {slot.id === "network_graph" && ( + onOpenSingletonTab("network_graph")} + /> + )}
{editMode && !isFlex && (
void; onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; + hostMetrics: Map; uptimeFormatted: string; versionText: string; versionStatus: "up_to_date" | "requires_update" | "beta"; @@ -1006,6 +851,7 @@ function PanelColumn({ onOpenSingletonTab, onOpenTab, hosts, + hostMetrics, uptimeFormatted, versionText, versionStatus, @@ -1057,6 +903,7 @@ function PanelColumn({ onOpenSingletonTab={onOpenSingletonTab} onOpenTab={onOpenTab} hosts={hosts} + hostMetrics={hostMetrics} uptimeFormatted={uptimeFormatted} versionText={versionText} versionStatus={versionStatus} @@ -1120,10 +967,45 @@ export function DashboardTab({ onOpenTab: (host: Host, type: TabType) => void; }) { const { t, i18n } = useTranslation(); - const [slots, setSlots] = useState(DEFAULT_SLOTS); + + const [slots, setSlots] = useState(() => { + try { + const saved = localStorage.getItem("dashboardTab.slots"); + if (saved) return JSON.parse(saved) as CardSlot[]; + } catch { + /* ignore */ + } + return DEFAULT_SLOTS; + }); + const [editMode, setEditMode] = useState(false); const [dragState, setDragState] = useState(null); - const [mainWidthPct, setMainWidthPct] = useState(68); + + const [mainWidthPct, setMainWidthPct] = useState(() => { + try { + const saved = localStorage.getItem("dashboardTab.mainWidthPct"); + if (saved) return Number(saved); + } catch { + /* ignore */ + } + return 68; + }); + + useEffect(() => { + try { + localStorage.setItem("dashboardTab.slots", JSON.stringify(slots)); + } catch { + /* ignore */ + } + }, [slots]); + + useEffect(() => { + try { + localStorage.setItem("dashboardTab.mainWidthPct", String(mainWidthPct)); + } catch { + /* ignore */ + } + }, [mainWidthPct]); const [hosts, setHosts] = useState([]); const [uptimeFormatted, setUptimeFormatted] = useState(""); @@ -1135,11 +1017,70 @@ export function DashboardTab({ const [credentialCount, setCredentialCount] = useState(0); const [activeTunnelCount, setActiveTunnelCount] = useState(0); const [activity, setActivity] = useState([]); + const [hostMetrics, setHostMetrics] = useState< + Map + >(new Map()); + const viewerSessionsRef = useRef>(new Map()); + + const fetchMetrics = useCallback(async (hostList: Host[]) => { + let statuses: Record = {}; + try { + statuses = (await getAllServerStatuses()) as Record< + number, + { status?: string } + >; + } catch { + /* best-effort */ + } + + const newSessions = new Map(viewerSessionsRef.current); + const results = await Promise.all( + hostList.map(async (host) => { + const hostId = Number(host.id); + const knownStatus = statuses?.[hostId]?.status; + if (knownStatus === "offline") return null; + if (host.authType === "none" || host.authType === "opkssh") return null; + + try { + const existing = newSessions.get(hostId); + if (!existing) { + const reg = await registerMetricsViewer(hostId); + if (reg.skipped) return null; + if (reg.success && reg.viewerSessionId) { + newSessions.set(hostId, reg.viewerSessionId); + } + } + const metrics = await getServerMetricsById(hostId); + if (!metrics) return null; + return { + id: host.id, + cpu: metrics.cpu?.percent ?? null, + ram: metrics.memory?.percent ?? null, + }; + } catch { + return null; + } + }), + ); + + viewerSessionsRef.current = newSessions; + const map = new Map(); + for (const r of results) { + if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram }); + } + setHostMetrics(map); + }, []); useEffect(() => { - getSSHHosts() - .then((raw) => setHosts(raw.map(sshHostToHost))) - .catch(() => {}); + let mounted = true; + const load = async () => { + const raw = await getSSHHosts().catch(() => []); + const mapped = raw.map(sshHostToHost); + if (mounted) setHosts(mapped); + fetchMetrics(mapped).catch(() => {}); + }; + load(); + getUptime() .then((u) => setUptimeFormatted(u.formatted)) .catch(() => {}); @@ -1178,7 +1119,29 @@ export function DashboardTab({ setActiveTunnelCount(active); }) .catch(() => {}); - }, []); + + const metricsInterval = setInterval(async () => { + const raw = await getSSHHosts().catch(() => []); + const mapped = raw.map(sshHostToHost); + if (mounted) setHosts(mapped); + fetchMetrics(mapped).catch(() => {}); + }, 30000); + + return () => { + mounted = false; + clearInterval(metricsInterval); + }; + }, [fetchMetrics]); + + useEffect(() => { + if (viewerSessionsRef.current.size === 0) return; + const heartbeat = setInterval(async () => { + for (const [, sessionId] of viewerSessionsRef.current) { + sendMetricsHeartbeat(sessionId).catch(() => {}); + } + }, 30000); + return () => clearInterval(heartbeat); + }, [hostMetrics]); const handleClearActivity = async () => { try { @@ -1280,11 +1243,11 @@ export function DashboardTab({ ? Math.max(...panelSlots.map((s) => s.order)) + 1 : 0; const defaultHeight: number | null = - id === "host_status" || - id === "recent_activity" || id === "network_graph" - ? null - : 150; + ? 350 + : id === "host_status" || id === "recent_activity" + ? null + : 150; return [...prev, { id, panel, order: maxOrder, height: defaultHeight }]; }); }; @@ -1294,12 +1257,19 @@ export function DashboardTab({ ); const handleReset = () => { setSlots(DEFAULT_SLOTS); - setMainWidthPct(72); + setMainWidthPct(68); setEditMode(false); + try { + localStorage.removeItem("dashboardTab.slots"); + localStorage.removeItem("dashboardTab.mainWidthPct"); + } catch { + /* ignore */ + } }; const columnProps = { hosts, + hostMetrics, uptimeFormatted, versionText, versionStatus, @@ -1397,7 +1367,11 @@ export function DashboardTab({ )} {slot.id === "host_status" && ( - + )} {slot.id === "recent_activity" && ( )} {slot.id === "network_graph" && ( - + onOpenSingletonTab("network_graph")} + /> )}
))} diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index c1d02cb9..b625d514 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useReducer, } from "react"; +import { Card } from "@/components/card"; import CytoscapeComponent from "react-cytoscapejs"; import cytoscape from "cytoscape"; import { @@ -49,16 +50,17 @@ import { FolderMinus, Terminal, ArrowUp, - NetworkIcon, + Network, FolderOpen, Container, Server, Check, ChevronsUpDown, ArrowDownUp, + Loader2, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useTabs } from "@/shell/TabContext"; +import { useTabsSafe } from "@/shell/TabContext"; import { Command, CommandEmpty, @@ -68,7 +70,6 @@ import { } from "@/components/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover"; import { cn } from "@/lib/utils"; -import { SimpleLoader } from "@/lib/SimpleLoader"; const AVAILABLE_COLORS = [ { value: "#ef4444", label: "Red" }, @@ -98,15 +99,73 @@ interface NetworkGraphCardProps { rightSidebarOpen?: boolean; rightSidebarWidth?: number; embedded?: boolean; + onOpenInNewTab?: () => void; } type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; +function buildNodeSvg( + name: string, + ip: string, + tags: string[], + status: string, +): string { + const isOnline = status === "online"; + const isOffline = status === "offline"; + const statusColor = isOnline + ? "rgb(16,185,129)" + : isOffline + ? "rgb(239,68,68)" + : "rgb(100,116,139)"; + + const isDark = + document.documentElement.classList.contains("dark") || + document.documentElement.classList.contains("dracula") || + document.documentElement.classList.contains("catppuccin") || + document.documentElement.classList.contains("nord") || + document.documentElement.classList.contains("solarized") || + document.documentElement.classList.contains("tokyo-night") || + document.documentElement.classList.contains("one-dark") || + document.documentElement.classList.contains("gruvbox"); + + const bg = isDark ? "#1c1c1e" : "#ffffff"; + const border = isDark ? "#2a2a2c" : "#e5e7eb"; + const textPrimary = isDark ? "#f1f5f9" : "#111827"; + const textSecondary = isDark ? "#94a3b8" : "#6b7280"; + + const safeName = name.replace(/[<>&"]/g, (c) => + c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : """, + ); + const safeIp = ip.replace(/[<>&"]/g, (c) => + c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : """, + ); + + const tagsHtml = tags + .slice(0, 2) + .map( + (tag) => + `${tag.replace(/[<>&"]/g, (c) => (c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : """))}`, + ) + .join(""); + + return ( + "data:image/svg+xml;utf8," + + encodeURIComponent(` + + + ${safeName.substring(0, 18)} + ${safeIp} + ${tagsHtml ? `
${tagsHtml}
` : ""} +
`) + ); +} + export function NetworkGraphCard({ embedded = true, + onOpenInNewTab, }: NetworkGraphCardProps): React.ReactElement { const { t } = useTranslation(); - const { addTab } = useTabs(); + const { addTab } = useTabsSafe(); const [elements, setElements] = useState([]); const [hosts, setHosts] = useState([]); @@ -125,17 +184,15 @@ export function NetworkGraphCard({ const [showNodeDetail, setShowNodeDetail] = useState(false); const [showMoveNodeDialog, setShowMoveNodeDialog] = useState(false); - const [selectedHostForAddNode, setSelectedHostForAddNode] = - useState(""); + const [selectedHostForAddNode, setSelectedHostForAddNode] = useState(""); const [selectedGroupForAddNode, setSelectedGroupForAddNode] = - useState("ROOT"); + useState("ROOT"); const [newGroupName, setNewGroupName] = useState(""); const [newGroupColor, setNewGroupColor] = useState("#3b82f6"); const [editingGroupId, setEditingGroupId] = useState(null); - const [selectedGroupForMove, setSelectedGroupForMove] = - useState("ROOT"); - const [selectedHostForEdge, setSelectedHostForEdge] = useState(""); - const [targetHostForEdge, setTargetHostForEdge] = useState(""); + const [selectedGroupForMove, setSelectedGroupForMove] = useState("ROOT"); + const [selectedHostForEdge, setSelectedHostForEdge] = useState(""); + const [targetHostForEdge, setTargetHostForEdge] = useState(""); const [selectedNodeForDetail, setSelectedNodeForDetail] = useState(null); @@ -155,8 +212,8 @@ export function NetworkGraphCard({ const [, forceUpdate] = useReducer((x: number) => x + 1, 0); const cyRef = useRef(null); - const statusCheckIntervalRef = useRef(null); - const saveTimeoutRef = useRef(null); + const statusIntervalRef = useRef | null>(null); + const saveTimeoutRef = useRef | null>(null); const contextMenuRef = useRef(null); const fileInputRef = useRef(null); @@ -166,84 +223,65 @@ export function NetworkGraphCard({ useEffect(() => { loadData(); - const interval = setInterval(updateHostStatuses, 30000); - statusCheckIntervalRef.current = interval; - - const handleClickOutside = (e: MouseEvent) => { + statusIntervalRef.current = setInterval(updateHostStatuses, 30000); + const onClickOutside = (e: MouseEvent) => { if ( contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node) - ) { - setContextMenu((prev) => - prev.visible ? { ...prev, visible: false } : prev, - ); - } + ) + setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); }; - document.addEventListener("mousedown", handleClickOutside, true); - + document.addEventListener("mousedown", onClickOutside, true); return () => { - if (statusCheckIntervalRef.current) - clearInterval(statusCheckIntervalRef.current); - document.removeEventListener("mousedown", handleClickOutside, true); + if (statusIntervalRef.current) clearInterval(statusIntervalRef.current); + document.removeEventListener("mousedown", onClickOutside, true); }; }, []); const loadData = async () => { + setLoading(true); + setError(null); try { - setLoading(true); - setError(null); - const hostsData = await getSSHHosts(); const hostsArray = Array.isArray(hostsData) ? hostsData : []; setHosts(hostsArray); - - const newHostMap: HostMap = {}; - hostsArray.forEach((host) => { - newHostMap[String(host.id)] = host; - }); - setHostMap(newHostMap); + const newMap: HostMap = {}; + hostsArray.forEach((h) => (newMap[String(h.id)] = h)); + setHostMap(newMap); let nodes: NetworkTopologyNode[] = []; let edges: NetworkTopologyEdge[] = []; - try { - const topologyData = await getNetworkTopology(); - if ( - topologyData && - topologyData.nodes && - Array.isArray(topologyData.nodes) - ) { - nodes = topologyData.nodes.map((node) => { - const host = newHostMap[node.data.id]; + const topo = await getNetworkTopology(); + if (topo?.nodes && Array.isArray(topo.nodes)) { + nodes = topo.nodes.map((node) => { + const h = newMap[node.data.id]; return { data: { id: node.data.id, - label: host?.name || node.data.label || "Unknown", - ip: host ? `${host.ip}:${host.port}` : node.data.ip || "", - status: host?.status || "unknown", - tags: host?.tags || [], + label: h?.name || node.data.label || "Unknown", + ip: h ? `${h.ip}:${h.port}` : node.data.ip || "", + status: h?.status || "unknown", + tags: h?.tags || [], parent: node.data.parent, color: node.data.color, }, position: node.position || { x: 0, y: 0 }, }; }); - edges = topologyData.edges || []; + edges = topo.edges || []; } } catch { - console.warn("Starting with empty topology"); + /* start with empty topology */ } const nodeIds = new Set(nodes.map((n) => n.data.id)); - const validEdges = edges.filter((edge) => { - const sourceExists = nodeIds.has(edge.data.source); - const targetExists = nodeIds.has(edge.data.target); - return sourceExists && targetExists; - }); - + const validEdges = edges.filter( + (e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target), + ); setElements([...nodes, ...validEdges]); - } catch (err) { - console.error("Failed to load topology:", err); + } catch { + /* ignore */ } finally { setLoading(false); } @@ -252,61 +290,53 @@ export function NetworkGraphCard({ const updateHostStatuses = useCallback(async () => { if (!cyRef.current) return; try { - const updatedHosts = await getSSHHosts(); - const updatedHostMap: HostMap = {}; - updatedHosts.forEach((host) => { - updatedHostMap[String(host.id)] = host; - }); - + const updated = await getSSHHosts(); + const updatedMap: HostMap = {}; + updated.forEach((h) => (updatedMap[String(h.id)] = h)); cyRef.current.nodes().forEach((node) => { if (node.isParent()) return; - const hostId = node.data("id"); - const updatedHost = updatedHostMap[hostId]; - if (updatedHost) { - node.data("status", updatedHost.status); - node.data("tags", updatedHost.tags || []); + const h = updatedMap[node.data("id")]; + if (h) { + node.data("status", h.status); + node.data("tags", h.tags || []); } }); - setHostMap(updatedHostMap); - } catch (err) { - console.error("Status update failed:", err); + setHostMap(updatedMap); + } catch { + /* ignore */ } }, []); const debouncedSave = useCallback(() => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); - saveTimeoutRef.current = setTimeout(() => { - saveCurrentLayout(); - }, 1000); + saveTimeoutRef.current = setTimeout(() => saveCurrentLayout(), 1000); }, []); const saveCurrentLayout = async () => { if (!cyRef.current) return; try { - const nodes = cyRef.current.nodes().map((node) => ({ + const nodes = cyRef.current.nodes().map((n) => ({ data: { - id: node.data("id"), - label: node.data("label"), - ip: node.data("ip"), - status: node.data("status"), - tags: node.data("tags") || [], - parent: node.data("parent"), - color: node.data("color"), + id: n.data("id"), + label: n.data("label"), + ip: n.data("ip"), + status: n.data("status"), + tags: n.data("tags") || [], + parent: n.data("parent"), + color: n.data("color"), }, - position: node.position(), + position: n.position(), })); - - const edges = cyRef.current.edges().map((edge) => ({ + const edges = cyRef.current.edges().map((e) => ({ data: { - id: edge.data("id"), - source: edge.data("source"), - target: edge.data("target"), + id: e.data("id"), + source: e.data("source"), + target: e.data("target"), }, })); - await saveNetworkTopology({ nodes, edges }); - } catch (err) { - console.error("Save failed:", err); + } catch { + /* ignore */ } }; @@ -318,7 +348,6 @@ export function NetworkGraphCard({ el.position && (el.position.x !== 0 || el.position.y !== 0), ); - if (!hasPositions) { cyRef.current .layout({ @@ -327,237 +356,149 @@ export function NetworkGraphCard({ randomize: true, componentSpacing: 100, nodeOverlap: 20, - }) + } as any) .run(); } else { cyRef.current.fit(); } }, [loading]); + const applyStyle = useCallback((cy: cytoscape.Core) => { + cy.style() + .selector("node") + .style({ + label: "", + width: "180px", + height: "80px", + shape: "round-rectangle", + "border-width": "0px", + "background-opacity": 0, + "background-image": (ele) => + buildNodeSvg( + ele.data("label") || "", + ele.data("ip") || "", + ele.data("tags") || [], + ele.data("status") || "unknown", + ), + "background-fit": "contain", + }) + .selector("node:parent") + .style({ + "background-image": "none", + "background-color": (ele) => ele.data("color") || "#1e3a8a", + "background-opacity": 0.08, + "border-color": (ele) => ele.data("color") || "#3b82f6", + "border-width": "1.5px", + "border-style": "dashed", + label: "data(label)", + "text-valign": "top", + "text-halign": "center", + "text-margin-y": -6, + color: "#94a3b8", + "font-size": "13px", + "font-weight": "bold", + shape: "round-rectangle", + padding: "12px", + }) + .selector("edge") + .style({ + width: "1.5px", + "line-color": "#3a3a3c", + "curve-style": "bezier", + "target-arrow-shape": "none", + }) + .selector("edge:selected") + .style({ "line-color": "#f59145", width: "2.5px" }) + .selector("node:selected") + .style({ + "overlay-color": "#f59145", + "overlay-opacity": 0.06, + "overlay-padding": "6px", + }) + .update(); + }, []); + const handleNodeInit = useCallback( (cy: cytoscape.Core) => { cyRef.current = cy; - - if (!embedded) { - cy.nodes().forEach((node) => { - node.grabify(); - }); + if (embedded) { + cy.nodes().forEach((n) => n.ungrabify()); } else { - cy.nodes().forEach((node) => { - node.ungrabify(); - }); + cy.nodes().forEach((n) => n.grabify()); } - - cy.style() - .selector("node") - .style({ - label: "", - width: "180px", - height: "90px", - shape: "round-rectangle", - "border-width": "0px", - "background-opacity": 0, - "background-image": function (ele) { - const host = ele.data(); - const name = host.label || ""; - const ip = host.ip || ""; - const tags = host.tags || []; - const isOnline = host.status === "online"; - const isOffline = host.status === "offline"; - - const statusColor = isOnline - ? `rgb(16, 185, 129)` - : isOffline - ? `rgb(239, 68, 68)` - : `rgb(100, 116, 139)`; - - const isDarkMode = - document.documentElement.classList.contains("dark"); - const bgColor = isDarkMode ? "#09090b" : "#f9fafb"; - const textColor = isDarkMode ? "#f1f5f9" : "#18181b"; - const secondaryTextColor = isDarkMode ? "#94a3b8" : "#64748b"; - - const tagsHtml = tags - .map( - (t) => ` - ${t}`, - ) - .join(""); - - const svg = ` - - - - - - - - -
-
${name}
-
${ip}
-
- ${tagsHtml} -
-
-
-
- `; - return "data:image/svg+xml;utf8," + encodeURIComponent(svg); - }, - "background-fit": "contain", - }) - .selector("node:parent") - .style({ - "background-image": "none", - "background-color": (ele) => ele.data("color") || "#1e3a8a", - "background-opacity": 0.05, - "border-color": (ele) => ele.data("color") || "#3b82f6", - "border-width": "2px", - "border-style": "dashed", - label: "data(label)", - "text-valign": "top", - "text-halign": "center", - "text-margin-y": -5, - color: "#94a3b8", - "font-size": "16px", - "font-weight": "bold", - shape: "round-rectangle", - padding: "10px", - }) - .selector("edge") - .style({ - width: "2px", - "line-color": "#373739", - "curve-style": "round-taxi", - "source-endpoint": "outside-to-node", - "target-endpoint": "outside-to-node", - "control-point-step-size": 10, - "control-point-distances": [40, -40], - "control-point-weights": [0.2, 0.8], - "target-arrow-shape": "none", - }) - .selector("edge:selected") - .style({ - "line-color": "#3b82f6", - width: "3px", - }) - .selector("node:selected") - .style({ - "overlay-color": "#3b82f6", - "overlay-opacity": 0.05, - "overlay-padding": "5px", - }); + applyStyle(cy); cy.on("tap", "node", (evt) => { - const node = evt.target; - setContextMenu((prev) => - prev.visible ? { ...prev, visible: false } : prev, - ); + setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); setSelectedEdgeId(null); - setSelectedNodeId(node.id()); + setSelectedNodeId(evt.target.id()); }); - cy.on("tap", "edge", (evt) => { evt.stopPropagation(); setSelectedEdgeId(evt.target.id()); setSelectedNodeId(null); }); - cy.on("tap", (evt) => { if (evt.target === cy) { - setContextMenu((prev) => - prev.visible ? { ...prev, visible: false } : prev, - ); + setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); setSelectedNodeId(null); setSelectedEdgeId(null); } }); - cy.on("cxttap", "node", (evt) => { evt.preventDefault(); evt.stopPropagation(); const node = evt.target; - const nodeId = node.id(); const isGroup = node.isParent() || String(nodeId).startsWith("group-"); - - if (isGroup && embedded) { - return; - } - - const x = evt.originalEvent.clientX; - const y = evt.originalEvent.clientY; - + if (isGroup && embedded) return; setContextMenu({ visible: true, - x, - y, + x: evt.originalEvent.clientX, + y: evt.originalEvent.clientY, targetId: nodeId, type: isGroup ? "group" : "node", }); }); - - cy.on("zoom pan", () => { - setContextMenu((prev) => - prev.visible ? { ...prev, visible: false } : prev, - ); - }); - + cy.on("zoom pan", () => + setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)), + ); cy.on("free", "node", () => !embedded && debouncedSave()); - cy.on("boxselect", "node", () => { - const selected = cy.$("node:selected"); - if (selected.length === 1) setSelectedNodeId(selected[0].id()); + const sel = cy.$("node:selected"); + if (sel.length === 1) setSelectedNodeId(sel[0].id()); }); }, - [debouncedSave, embedded], + [applyStyle, debouncedSave, embedded], ); + const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false })); + const handleContextAction = (action: string) => { - setContextMenu((prev) => ({ ...prev, visible: false })); + hideMenu(); const targetId = contextMenu.targetId; if (!cyRef.current) return; - if (action === "details") { - const host = hostMap[targetId]; - if (host) { - setSelectedNodeForDetail(host); + const h = hostMap[targetId]; + if (h) { + setSelectedNodeForDetail(h); setShowNodeDetail(true); } } else if (action === "connect") { - const host = hostMap[targetId]; - if (host) { - const title = host.name?.trim() - ? host.name - : `${host.username}@${host.ip}:${host.port}`; - addTab({ type: "terminal", title, hostConfig: host }); - } + const h = hostMap[targetId]; + if (h) + addTab({ + type: "terminal", + title: h.name || `${h.username}@${h.ip}:${h.port}`, + hostConfig: h, + }); } else if (action === "move") { setSelectedNodeId(targetId); const node = cyRef.current.$id(targetId); - const parentId = node.data("parent"); - setSelectedGroupForMove(parentId || "ROOT"); + setSelectedGroupForMove(node.data("parent") || "ROOT"); setShowMoveNodeDialog(true); } else if (action === "removeFromGroup") { - const node = cyRef.current.$id(targetId); - node.move({ parent: null }); + cyRef.current.$id(targetId).move({ parent: null }); debouncedSave(); } else if (action === "editGroup") { const node = cyRef.current.$id(targetId); @@ -576,35 +517,28 @@ export function NetworkGraphCard({ }; const handleConnectAction = (appType: string) => { - setContextMenu((prev) => ({ ...prev, visible: false })); - const host = hostMap[contextMenu.targetId]; - if (!host) return; - - const title = host.name?.trim() - ? host.name - : `${host.username}@${host.ip}:${host.port}`; - - addTab({ type: appType, title, hostConfig: host }); + hideMenu(); + const h = hostMap[contextMenu.targetId]; + if (!h) return; + addTab({ + type: appType as any, + title: h.name || `${h.username}@${h.ip}:${h.port}`, + hostConfig: h, + }); }; - const hasTunnelConnections = (host: SSHHostWithStatus | undefined) => { - if (!host?.tunnelConnections) return false; + const hasTunnelConnections = (h: SSHHostWithStatus | undefined) => { + if (!h?.tunnelConnections) return false; try { - const tunnelConnections = Array.isArray(host.tunnelConnections) - ? host.tunnelConnections - : JSON.parse(host.tunnelConnections); - return Array.isArray(tunnelConnections) && tunnelConnections.length > 0; + const arr = Array.isArray(h.tunnelConnections) + ? h.tunnelConnections + : JSON.parse(h.tunnelConnections as string); + return Array.isArray(arr) && arr.length > 0; } catch { return false; } }; - const handleAddNode = () => { - setSelectedHostForAddNode(""); - setSelectedGroupForAddNode("ROOT"); - setShowAddNodeDialog(true); - }; - const handleConfirmAddNode = async () => { if (!cyRef.current || !selectedHostForAddNode) return; try { @@ -612,26 +546,28 @@ export function NetworkGraphCard({ setError(t("networkGraph.hostAlreadyExists")); return; } - const host = hostMap[selectedHostForAddNode]; + const h = hostMap[selectedHostForAddNode]; const parent = selectedGroupForAddNode === "ROOT" ? undefined : selectedGroupForAddNode; - - const newNode = { + cyRef.current.add({ data: { id: selectedHostForAddNode, - label: host.name || `${host.ip}`, - ip: `${host.ip}:${host.port}`, - status: host.status, - tags: host.tags || [], - parent: parent, + label: h?.name || h?.ip || selectedHostForAddNode, + ip: h ? `${h.ip}:${h.port}` : "", + status: h?.status || "unknown", + tags: h?.tags || [], + parent, }, - position: { x: 100 + Math.random() * 50, y: 100 + Math.random() * 50 }, - }; - cyRef.current.add(newNode); + position: { + x: 100 + Math.random() * 200, + y: 100 + Math.random() * 200, + }, + }); + applyStyle(cyRef.current); await saveCurrentLayout(); - setElements([...cyRef.current.elements().jsons()]); + setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]); forceUpdate(); setShowAddNodeDialog(false); } catch { @@ -646,7 +582,7 @@ export function NetworkGraphCard({ data: { id: groupId, label: newGroupName, color: newGroupColor }, }); await saveCurrentLayout(); - setElements([...cyRef.current.elements().jsons()]); + setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]); forceUpdate(); setShowAddGroupDialog(false); setNewGroupName(""); @@ -654,9 +590,9 @@ export function NetworkGraphCard({ const handleUpdateGroup = async () => { if (!cyRef.current || !editingGroupId || !newGroupName) return; - const group = cyRef.current.$id(editingGroupId); - group.data("label", newGroupName); - group.data("color", newGroupColor); + const g = cyRef.current.$id(editingGroupId); + g.data("label", newGroupName); + g.data("color", newGroupColor); await saveCurrentLayout(); setShowEditGroupDialog(false); setEditingGroupId(null); @@ -664,10 +600,9 @@ export function NetworkGraphCard({ const handleMoveNodeToGroup = async () => { if (!cyRef.current || !selectedNodeId) return; - const node = cyRef.current.$id(selectedNodeId); - const parent = - selectedGroupForMove === "ROOT" ? null : selectedGroupForMove; - node.move({ parent: parent }); + cyRef.current.$id(selectedNodeId).move({ + parent: selectedGroupForMove === "ROOT" ? null : selectedGroupForMove, + }); await saveCurrentLayout(); setShowMoveNodeDialog(false); }; @@ -676,11 +611,9 @@ export function NetworkGraphCard({ if (!cyRef.current || !selectedHostForEdge || !targetHostForEdge) return; if (selectedHostForEdge === targetHostForEdge) return setError(t("networkGraph.sourceDifferentFromTarget")); - const edgeId = `${selectedHostForEdge}-${targetHostForEdge}`; if (cyRef.current.$id(edgeId).length > 0) return setError(t("networkGraph.connectionExists")); - cyRef.current.add({ data: { id: edgeId, @@ -694,7 +627,6 @@ export function NetworkGraphCard({ const handleRemoveSelected = () => { if (!cyRef.current) return; - if (selectedNodeId) { cyRef.current.$id(selectedNodeId).remove(); setSelectedNodeId(null); @@ -702,73 +634,724 @@ export function NetworkGraphCard({ cyRef.current.$id(selectedEdgeId).remove(); setSelectedEdgeId(null); } - debouncedSave(); }; - const availableGroups = useMemo(() => { - return elements - .filter( - (el) => !el.data.source && !el.data.target && !el.data.ip && el.data.id, - ) - .map((el) => ({ id: el.data.id, label: el.data.label })); - }, [elements]); + const handleExport = () => { + if (!cyRef.current) return; + const json = JSON.stringify(cyRef.current.json().elements, null, 2); + const a = document.createElement("a"); + a.href = URL.createObjectURL( + new Blob([json], { type: "application/json" }), + ); + a.download = "network.json"; + a.click(); + }; - const availableNodesForConnection = useMemo(() => { - return elements - .filter((el) => !el.data.source && !el.data.target) - .map((el) => ({ - id: el.data.id, - label: el.data.label, - })); - }, [elements]); + const handleOpenInNewTab = () => { + if (onOpenInNewTab) { + onOpenInNewTab(); + } else { + addTab({ + type: "network_graph" as any, + title: t("dashboard.networkGraph"), + }); + } + }; + + const availableGroups = useMemo( + () => + elements + .filter( + (el) => + !el.data.source && !el.data.target && !el.data.ip && el.data.id, + ) + .map((el) => ({ + id: el.data.id!, + label: el.data.label || el.data.id!, + })), + [elements], + ); + + const availableNodesForConnection = useMemo( + () => + elements + .filter((el) => !el.data.source && !el.data.target) + .map((el) => ({ + id: el.data.id!, + label: el.data.label || el.data.id!, + })), + [elements], + ); const availableHostsForAdd = useMemo(() => { if (!cyRef.current) return hosts; - const existingIds = new Set(elements.map((e) => e.data.id)); - return hosts.filter((h) => !existingIds.has(String(h.id))); + const existing = new Set(elements.map((e) => e.data.id)); + return hosts.filter((h) => !existing.has(String(h.id))); }, [hosts, elements]); - const handleOpenInNewTab = () => { - addTab({ - type: "network_graph", - title: t("dashboard.networkGraph"), - }); - }; + const btnCls = + "h-7 w-7 p-0 rounded-sm border-0 hover:bg-muted/60 transition-colors flex items-center justify-center text-muted-foreground hover:text-foreground"; + + const contextMenuEl = contextMenu.visible ? ( +
+ {contextMenu.type === "node" && ( + <> + {hostMap[contextMenu.targetId]?.enableTerminal && ( + + )} + {hostMap[contextMenu.targetId]?.enableFileManager && ( + + )} + {hostMap[contextMenu.targetId]?.enableTunnel && + hasTunnelConnections(hostMap[contextMenu.targetId]) && ( + + )} + {hostMap[contextMenu.targetId]?.enableDocker && ( + + )} + + {!embedded && ( + <> +
+ + {cyRef.current?.$id(contextMenu.targetId).parent().length ? ( + + ) : null} +
+ + + )} + + )} + {contextMenu.type === "group" && !embedded && ( + <> + + +
+ + + )} +
+ ) : null; + + const cytoscapeEl = ( +
+ {loading && ( +
+ +
+ )} + {contextMenuEl} + + {!loading && elements.length === 0 && ( +
+ +

+ {t("networkGraph.noNodes")} +

+
+ )} +
+ ); + + const dialogs = ( + <> + setError(null)}> + +
+ + + {error} + +
+
+ setError(null)}> + OK + +
+
+
+ + + + + {t("networkGraph.addHost")} + +
+
+ + + + + + + + + {t("networkGraph.noHostFound")} + + {availableHostsForAdd.map((h) => ( + { + setSelectedHostForAddNode(String(h.id)); + setHostComboOpen(false); + }} + > + +
+ {h.name || h.ip} + + {h.username}@{h.ip}:{h.port} + +
+
+ ))} +
+
+
+
+
+
+ + + + + + + + + + {t("networkGraph.noGroupFound")} + + + { + setSelectedGroupForAddNode("ROOT"); + setGroupComboOpen(false); + }} + > + + {t("networkGraph.noGroup")} + + {availableGroups.map((g) => ( + { + setSelectedGroupForAddNode(g.id); + setGroupComboOpen(false); + }} + > + + {g.label} + + ))} + + + + +
+
+ + + + +
+
+ + { + if (!o) { + setShowAddGroupDialog(false); + setShowEditGroupDialog(false); + } + }} + > + + + + {showEditGroupDialog + ? t("networkGraph.editGroup") + : t("networkGraph.createGroup")} + + +
+
+ + setNewGroupName(e.target.value)} + placeholder={t("networkGraph.groupName")} + /> +
+
+ +
+ {AVAILABLE_COLORS.map((c) => ( +
+
+
+ + + + +
+
+ + + + + {t("networkGraph.moveToGroup")} + +
+
+ + + + + + + + + + {t("networkGraph.noGroupFound")} + + + { + setSelectedGroupForMove("ROOT"); + setMoveGroupComboOpen(false); + }} + > + + {t("networkGraph.noGroup")} + + {availableGroups.map((g) => ( + { + if (g.id !== selectedNodeId) { + setSelectedGroupForMove(g.id); + setMoveGroupComboOpen(false); + } + }} + > + + {g.label} + + ))} + + + + +
+
+ + + + +
+
+ + + + + {t("networkGraph.addConnection")} + +
+ {[ + { + label: t("networkGraph.source"), + val: selectedHostForEdge, + setVal: setSelectedHostForEdge, + open: sourceComboOpen, + setOpen: setSourceComboOpen, + placeholder: t("networkGraph.selectSourcePlaceholder"), + }, + { + label: t("networkGraph.target"), + val: targetHostForEdge, + setVal: setTargetHostForEdge, + open: targetComboOpen, + setOpen: setTargetComboOpen, + placeholder: t("networkGraph.selectTargetPlaceholder"), + }, + ].map(({ label, val, setVal, open, setOpen, placeholder }) => ( +
+ + + + + + + + + + {t("networkGraph.noNodeFound")} + + + {availableNodesForConnection.map((el) => ( + { + setVal(el.id); + setOpen(false); + }} + > + + {el.label} + + ))} + + + + +
+ ))} +
+ + + + +
+
+ + + + + {t("networkGraph.hostDetails")} + + {selectedNodeForDetail && ( +
+
+ + {t("networkGraph.name")} + + {selectedNodeForDetail.name} + + {t("networkGraph.ip")} + + {selectedNodeForDetail.ip} + + {t("networkGraph.status")} + + + {selectedNodeForDetail.status || t("networkGraph.unknown")} + +
+ {selectedNodeForDetail.tags && + selectedNodeForDetail.tags.length > 0 && ( +
+ {selectedNodeForDetail.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ )} + + + +
+
+ + { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = async (evt) => { + try { + const json = JSON.parse(evt.target?.result as string); + await saveNetworkTopology({ + nodes: json.nodes, + edges: json.edges, + }); + await loadData(); + if (fileInputRef.current) fileInputRef.current.value = ""; + } catch { + setError(t("networkGraph.invalidFile")); + } + }; + reader.readAsText(file); + }} + /> + + ); if (!embedded) { return ( -
-
-

{t("dashboard.networkGraph")}

-
- - setError(null)}> - -
- - - {error} - -
-
- setError(null)}> - OK - -
-
-
- -
-
+
+ {/* toolbar */} +
+
- -
- -
- - + - + + +
- -
- -
- - + - { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = async (evt) => { - try { - const json = JSON.parse(evt.target?.result as string); - await saveNetworkTopology({ - nodes: json.nodes, - edges: json.edges, - }); - await loadData(); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - } catch { - setError(t("networkGraph.invalidFile")); - } - }; - reader.readAsText(file); - }} - className="hidden" - /> + +
- -
- - - {contextMenu.visible && ( -
- {contextMenu.type === "node" && ( - <> - {hostMap[contextMenu.targetId]?.enableTerminal && ( - - )} - {hostMap[contextMenu.targetId]?.enableFileManager && ( - - )} - {hostMap[contextMenu.targetId]?.enableTunnel && - hasTunnelConnections(hostMap[contextMenu.targetId]) && ( - - )} - {hostMap[contextMenu.targetId]?.enableDocker && ( - - )} - - - {!embedded && ( - <> -
- - {cyRef.current?.$id(contextMenu.targetId).parent() - .length ? ( - - ) : null} -
- - - )} - - )} - - {contextMenu.type === "group" && !embedded && ( - <> - - -
- - - )} -
- )} - - -
- - - - - {t("networkGraph.addHost")} - -
-
- - - - - - - - - - {t("networkGraph.noHostFound")} - - - {availableHostsForAdd.map((h) => ( - { - setSelectedHostForAddNode(String(h.id)); - setHostComboOpen(false); - }} - > - -
- {h.name || h.ip} - - {h.username}@{h.ip}:{h.port} - -
-
- ))} -
-
-
-
-
-
- - - - - - - - - - {t("networkGraph.noGroupFound")} - - - { - setSelectedGroupForAddNode("ROOT"); - setGroupComboOpen(false); - }} - > - - {t("networkGraph.noGroup")} - - {availableGroups.map((g) => ( - { - setSelectedGroupForAddNode(g.id); - setGroupComboOpen(false); - }} - > - - {g.label} - - ))} - - - - -
-
- - - - -
-
- - { - if (!open) { - setShowAddGroupDialog(false); - setShowEditGroupDialog(false); - } - }} - > - - - - {showEditGroupDialog - ? t("networkGraph.editGroup") - : t("networkGraph.createGroup")} - - -
-
- - setNewGroupName(e.target.value)} - placeholder={t("networkGraph.groupName")} - className="border-2 border-edge" - /> -
-
- -
- {AVAILABLE_COLORS.map((color) => ( -
-
-
- - - - -
-
- - - - - {t("networkGraph.moveToGroup")} - -
-
- - - - - - - - - - {t("networkGraph.noGroupFound")} - - - { - setSelectedGroupForMove("ROOT"); - setMoveGroupComboOpen(false); - }} - > - - {t("networkGraph.noGroup")} - - {availableGroups.map((g) => ( - { - if (g.id !== selectedNodeId) { - setSelectedGroupForMove(g.id); - setMoveGroupComboOpen(false); - } - }} - > - - {g.label} - - ))} - - - - -
-
- - - - -
-
- - - - - {t("networkGraph.addConnection")} - -
-
- - - - - - - - - - {t("networkGraph.noNodeFound")} - - - {availableNodesForConnection.map((el) => ( - { - setSelectedHostForEdge(el.id); - setSourceComboOpen(false); - }} - > - - {el.label} - - ))} - - - - -
-
- - - - - - - - - - {t("networkGraph.noNodeFound")} - - - {availableNodesForConnection.map((el) => ( - { - setTargetHostForEdge(el.id); - setTargetComboOpen(false); - }} - > - - {el.label} - - ))} - - - - -
-
- - - - -
-
- - - - - {t("networkGraph.hostDetails")} - - {selectedNodeForDetail && ( -
-
- - {t("networkGraph.name")}: - - {selectedNodeForDetail.name} - {t("networkGraph.ip")}: - {selectedNodeForDetail.ip} - - {t("networkGraph.status")}: - - - {selectedNodeForDetail.status || t("networkGraph.unknown")} - - {t("networkGraph.id")}: - - {selectedNodeForDetail.id} - -
- {selectedNodeForDetail.tags && - selectedNodeForDetail.tags.length > 0 && ( -
- {selectedNodeForDetail.tags.map((t) => ( - - {t} - - ))} -
- )} -
- )} - - - -
-
+ {cytoscapeEl} + {dialogs}
); } + /* ── embedded card ── */ + const nodeCount = elements.filter((e) => !e.data.source).length; return ( -
-
-
-

- + +

+
+ + {t("dashboard.networkGraph")} -

-
- - - - -
-
- - setError(null)}> - -
- - - {error} - -
-
- setError(null)}> - OK - -
-
-
- -
- - - {contextMenu.visible && ( -
- {contextMenu.type === "node" && ( - <> - {hostMap[contextMenu.targetId]?.enableTerminal && ( - - )} - {hostMap[contextMenu.targetId]?.enableFileManager && ( - - )} - {hostMap[contextMenu.targetId]?.enableTunnel && - hasTunnelConnections(hostMap[contextMenu.targetId]) && ( - - )} - {hostMap[contextMenu.targetId]?.enableDocker && ( - - )} - - - )} -
+ + {!loading && ( + + {t("dashboardTab.nodes", { count: nodeCount })} + )} - - +
+
+ + + +
-
+ {cytoscapeEl} + {dialogs} + ); } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ec2c0d51..723bb0fd 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2870,7 +2870,8 @@ "fileManager": "File Manager", "tunnel": "Tunnel", "docker": "Docker", - "serverStats": "Server Stats" + "serverStats": "Server Stats", + "noNodes": "No nodes yet — add hosts to build your topology" }, "rbac": { "shareHost": "Share Host", diff --git a/src/ui/shell/CommandPalette.tsx b/src/ui/shell/CommandPalette.tsx index 52309731..d26f5b60 100644 --- a/src/ui/shell/CommandPalette.tsx +++ b/src/ui/shell/CommandPalette.tsx @@ -13,30 +13,23 @@ import { Settings, Terminal, FolderOpen, + FolderSearch, Box, Globe, Plus, MessagesSquare, LifeBuoy, - DollarSign, Search, Activity, Network, - MoreHorizontal, - Edit3, User, KeyRound, LayoutDashboard, Monitor, Clock, + Folder, + Pencil, } from "lucide-react"; -import { Button } from "@/components/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/dropdown-menu"; import { getRecentActivity, type RecentActivityItem } from "@/main-axios"; import type { Host } from "@/types/ui-types"; @@ -69,6 +62,29 @@ const ACTIVITY_TAB_TYPE: Record = { rdp: "rdp", }; +function getSshActions(host: Host) { + const metricsEnabled = host.statsConfig?.metricsEnabled !== false; + return [ + host.enableTerminal !== false && { + type: "terminal", + icon: Terminal, + label: "Terminal", + }, + host.enableFileManager && { + type: "files", + icon: FolderSearch, + label: "Files", + }, + host.enableDocker && { type: "docker", icon: Box, label: "Docker" }, + host.enableTunnel && { type: "tunnel", icon: Network, label: "Tunnels" }, + metricsEnabled && { type: "stats", icon: Activity, label: "Stats" }, + ].filter(Boolean) as { + type: string; + icon: React.ElementType; + label: string; + }[]; +} + export function CommandPalette({ isOpen, setIsOpen, @@ -108,6 +124,24 @@ export function CommandPalette({ h.username.toLowerCase().includes(search.toLowerCase()), ); + // Group hosts by folder; ungrouped hosts appear first under an implicit root group + const groupedHosts: { folder: string | null; hosts: Host[] }[] = []; + const folderMap = new Map(); + const ungrouped: Host[] = []; + for (const h of filteredHosts) { + if (h.folder) { + if (!folderMap.has(h.folder)) folderMap.set(h.folder, []); + folderMap.get(h.folder)!.push(h); + } else { + ungrouped.push(h); + } + } + if (ungrouped.length > 0) + groupedHosts.push({ folder: null, hosts: ungrouped }); + for (const [folder, fhosts] of folderMap) { + groupedHosts.push({ folder, hosts: fhosts }); + } + const handleAction = (action: () => void) => { action(); setIsOpen(false); @@ -283,145 +317,135 @@ export function CommandPalette({ {filteredHosts.length > 0 ? ( - filteredHosts.map((host, i) => { - const actions = [ - host.enableSsh && - host.enableTerminal !== false && { - type: "terminal", - icon: , - label: "Terminal", - }, - host.enableSsh && - host.enableFileManager && { - type: "files", - icon: , - label: "Files", - }, - host.enableSsh && - host.enableDocker && { - type: "docker", - icon: , - label: "Docker", - }, - host.enableSsh && - host.enableTunnel && { - type: "tunnel", - icon: , - label: "Tunnels", - }, - host.enableSsh && { - type: "stats", - icon: , - label: "Stats", - }, - host.enableRdp && { - type: "rdp", - icon: , - label: "RDP", - }, - host.enableVnc && { - type: "vnc", - icon: , - label: "VNC", - }, - host.enableTelnet && { - type: "telnet", - icon: , - label: "Telnet", - }, - ].filter(Boolean) as { - type: string; - icon: React.ReactNode; - label: string; - }[]; - - return ( - - handleAction(() => onOpenTab("terminal", host.name)) - } - className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" - > -
- + groupedHosts.map(({ folder, hosts: groupHosts }) => ( +
+ {folder && ( +
+ + {folder}
-
-
- - {host.name} - - {host.online && ( - - )} + )} + {groupHosts.map((host, i) => ( + + handleAction(() => onOpenTab("terminal", host.name)) + } + className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer" + > +
+
- - {host.username}@{host.ip} - -
-
- {actions.map((action) => ( - - ))} - - - - - - +
+ + {host.name} + + {host.online && ( + + )} +
+ + {host.username}@{host.ip} + +
+
+ {host.enableSsh && + getSshActions(host).map( + ({ type, icon: Icon, label }) => ( + + ), + )} + {host.enableSsh && + (host.enableRdp || + host.enableVnc || + host.enableTelnet) && ( +
+ )} + {host.enableRdp && ( +
- - ); - }) + + RDP + + )} + {host.enableVnc && ( + + )} + {host.enableTelnet && ( + + )} +
+ +
+ + ))} +
+ )) ) : (
No hosts found matching “{search}” @@ -432,29 +456,43 @@ export function CommandPalette({ -
+
window.open("https://github.com", "_blank")} + onSelect={() => + window.open( + "https://github.com/Termix-SSH/Termix", + "_blank", + ) + } className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer" > GitHub window.open("https://discord.com", "_blank")} + onSelect={() => + window.open( + "https://discord.com/invite/jVQGdvHDrf", + "_blank", + ) + } className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer" > Discord - + + window.open( + "https://github.com/Termix-SSH/Support/issues/new", + "_blank", + ) + } + className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer" + > Support - - - Donate -
diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 78790291..e5e3639e 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -20,6 +20,7 @@ import { ServerStats } from "@/features/server-stats/ServerStats"; import GuacamoleApp from "@/features/guacamole/GuacamoleApp"; import { DashboardTab } from "@/dashboard/DashboardTab"; import { TunnelTab } from "@/features/tunnel/TunnelTab"; +import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard"; import type { Tab, TabType, Host } from "@/types/ui-types"; import type { SSHHost } from "@/types"; @@ -103,6 +104,8 @@ export function tabIcon(type: TabType) { return ; case "tunnel": return ; + case "network_graph": + return ; } } @@ -198,6 +201,9 @@ export function renderTabContent( ); return ; + case "network_graph": + return ; + case "host-manager": case "user-profile": case "admin-settings":