From 1be5d20744a1a296dc177b5b7ee337597d54f575 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 13 May 2026 01:18:17 -0500 Subject: [PATCH] feat: improve network graph --- src/ui/dashboard/cards/NetworkGraphCard.tsx | 501 ++++++-------------- 1 file changed, 154 insertions(+), 347 deletions(-) diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index b625d514..5007e69a 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -54,21 +54,11 @@ import { FolderOpen, Container, Server, - Check, - ChevronsUpDown, ArrowDownUp, Loader2, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTabsSafe } from "@/shell/TabContext"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from "@/components/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover"; import { cn } from "@/lib/utils"; const AVAILABLE_COLORS = [ @@ -104,6 +94,23 @@ interface NetworkGraphCardProps { type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; +// Resolve a CSS variable to its actual computed color string for use in SVG +function resolveCssVar(varName: string, fallback: string): string { + const raw = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + if (!raw) return fallback; + const tmp = document.createElement("div"); + tmp.style.position = "absolute"; + tmp.style.visibility = "hidden"; + tmp.style.backgroundColor = `var(${varName})`; + document.body.appendChild(tmp); + const resolved = getComputedStyle(tmp).backgroundColor; + document.body.removeChild(tmp); + // getComputedStyle returns "" or "rgba(0,0,0,0)" if unresolvable + return resolved && resolved !== "rgba(0, 0, 0, 0)" ? resolved : fallback; +} + function buildNodeSvg( name: string, ip: string, @@ -118,43 +125,33 @@ function buildNodeSvg( ? "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 = resolveCssVar("--card", "#1e1e20"); + const border = resolveCssVar("--border", "#2a2a2c"); + const textPrimary = resolveCssVar("--card-foreground", "#f1f5f9"); + const textSecondary = resolveCssVar("--muted-foreground", "#94a3b8"); - 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 esc = (s: string) => + s.replace( + /[<>&"]/g, + (c) => + ({ "<": "<", ">": ">", "&": "&", '"': """ })[c] ?? c, + ); const tagsHtml = tags .slice(0, 2) .map( (tag) => - `${tag.replace(/[<>&"]/g, (c) => (c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : """))}`, + `${esc(tag)}`, ) .join(""); return ( "data:image/svg+xml;utf8," + encodeURIComponent(` - - - ${safeName.substring(0, 18)} - ${safeIp} + + + ${esc(name).substring(0, 18)} + ${esc(ip)} ${tagsHtml ? `
${tagsHtml}
` : ""}
`) ); @@ -204,11 +201,6 @@ export function NetworkGraphCard({ type: null, }); - const [hostComboOpen, setHostComboOpen] = useState(false); - const [groupComboOpen, setGroupComboOpen] = useState(false); - const [moveGroupComboOpen, setMoveGroupComboOpen] = useState(false); - const [sourceComboOpen, setSourceComboOpen] = useState(false); - const [targetComboOpen, setTargetComboOpen] = useState(false); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); const cyRef = useRef(null); @@ -216,6 +208,7 @@ export function NetworkGraphCard({ const saveTimeoutRef = useRef | null>(null); const contextMenuRef = useRef(null); const fileInputRef = useRef(null); + const cyContainerRef = useRef(null); useEffect(() => { hostMapRef.current = hostMap; @@ -370,7 +363,7 @@ export function NetworkGraphCard({ label: "", width: "180px", height: "80px", - shape: "round-rectangle", + shape: "rectangle", "border-width": "0px", "background-opacity": 0, "background-image": (ele) => @@ -397,7 +390,7 @@ export function NetworkGraphCard({ color: "#94a3b8", "font-size": "13px", "font-weight": "bold", - shape: "round-rectangle", + shape: "rectangle", padding: "12px", }) .selector("edge") @@ -472,8 +465,35 @@ export function NetworkGraphCard({ [applyStyle, debouncedSave, embedded], ); + // Zoom centered on the viewport midpoint — no panning + const zoomIn = useCallback(() => { + const cy = cyRef.current; + if (!cy || !cyContainerRef.current) return; + const rect = cyContainerRef.current.getBoundingClientRect(); + cy.zoom({ + level: cy.zoom() * 1.25, + renderedPosition: { x: rect.width / 2, y: rect.height / 2 }, + }); + }, []); + + const zoomOut = useCallback(() => { + const cy = cyRef.current; + if (!cy || !cyContainerRef.current) return; + const rect = cyContainerRef.current.getBoundingClientRect(); + cy.zoom({ + level: cy.zoom() / 1.25, + renderedPosition: { x: rect.width / 2, y: rect.height / 2 }, + }); + }, []); + const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false })); + const fireOpen = (hostId: string, type: string) => { + window.dispatchEvent( + new CustomEvent("termix:open-tab", { detail: { hostId, type } }), + ); + }; + const handleContextAction = (action: string) => { hideMenu(); const targetId = contextMenu.targetId; @@ -485,13 +505,7 @@ export function NetworkGraphCard({ setShowNodeDetail(true); } } else if (action === "connect") { - const h = hostMap[targetId]; - if (h) - addTab({ - type: "terminal", - title: h.name || `${h.username}@${h.ip}:${h.port}`, - hostConfig: h, - }); + fireOpen(targetId, "terminal"); } else if (action === "move") { setSelectedNodeId(targetId); const node = cyRef.current.$id(targetId); @@ -518,13 +532,7 @@ export function NetworkGraphCard({ const handleConnectAction = (appType: string) => { 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, - }); + fireOpen(contextMenu.targetId, appType); }; const hasTunnelConnections = (h: SSHHostWithStatus | undefined) => { @@ -696,7 +704,7 @@ export function NetworkGraphCard({ const contextMenuEl = contextMenu.visible ? (
{contextMenu.type === "node" && ( @@ -712,7 +720,7 @@ export function NetworkGraphCard({ )} {hostMap[contextMenu.targetId]?.enableFileManager && ( )} - - - - - {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} - - ))} - - - - +
@@ -1011,6 +918,7 @@ export function NetworkGraphCard({ + {/* Add / Edit Group dialog */} { @@ -1078,6 +986,7 @@ export function NetworkGraphCard({ + {/* Move to Group dialog */} @@ -1086,78 +995,20 @@ export function NetworkGraphCard({
- setSelectedGroupForMove(e.target.value)} > - - - - - - - - {t("networkGraph.noGroupFound")} - - - { - setSelectedGroupForMove("ROOT"); - setMoveGroupComboOpen(false); - }} - > - - {t("networkGraph.noGroup")} - - {availableGroups.map((g) => ( - { - if (g.id !== selectedNodeId) { - setSelectedGroupForMove(g.id); - setMoveGroupComboOpen(false); - } - }} - > - - {g.label} - - ))} - - - - + + {availableGroups + .filter((g) => g.id !== selectedNodeId) + .map((g) => ( + + ))} +
@@ -1174,83 +1025,47 @@ export function NetworkGraphCard({
+ {/* Add Edge dialog */} {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} - - ))} - - - - -
- ))} +
+ + +
+
+ + +
+ {/* Node Detail dialog */} @@ -1337,8 +1153,7 @@ export function NetworkGraphCard({ if (!embedded) { return ( -
- {/* toolbar */} +