2026-01-24 19:49:42 -06:00
|
|
|
import React, {
|
|
|
|
|
useEffect,
|
|
|
|
|
useState,
|
|
|
|
|
useRef,
|
|
|
|
|
useCallback,
|
|
|
|
|
useMemo,
|
|
|
|
|
useReducer,
|
|
|
|
|
} from "react";
|
|
|
|
|
import CytoscapeComponent from "react-cytoscapejs";
|
|
|
|
|
import cytoscape from "cytoscape";
|
|
|
|
|
import {
|
|
|
|
|
getSSHHosts,
|
|
|
|
|
getNetworkTopology,
|
|
|
|
|
saveNetworkTopology,
|
|
|
|
|
type SSHHostWithStatus,
|
2026-05-06 15:12:07 -05:00
|
|
|
type NetworkTopologyEdge,
|
|
|
|
|
type NetworkTopologyNode,
|
2026-05-11 01:23:11 -05:00
|
|
|
} from "@/main-axios";
|
|
|
|
|
import { Button } from "@/components/button";
|
|
|
|
|
import { Badge } from "@/components/badge";
|
2026-01-24 19:49:42 -06:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogAction,
|
2026-05-11 01:23:11 -05:00
|
|
|
} from "@/components/alert-dialog";
|
2026-01-24 19:49:42 -06:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogFooter,
|
2026-05-11 01:23:11 -05:00
|
|
|
} from "@/components/dialog";
|
|
|
|
|
import { Input } from "@/components/input";
|
|
|
|
|
import { Label } from "@/components/label";
|
2026-01-24 19:49:42 -06:00
|
|
|
import {
|
|
|
|
|
Plus,
|
|
|
|
|
Trash2,
|
|
|
|
|
ZoomIn,
|
|
|
|
|
ZoomOut,
|
|
|
|
|
RotateCw,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
Download,
|
|
|
|
|
Upload,
|
|
|
|
|
Link2,
|
|
|
|
|
FolderPlus,
|
|
|
|
|
Edit,
|
|
|
|
|
FolderInput,
|
|
|
|
|
FolderMinus,
|
|
|
|
|
Terminal,
|
|
|
|
|
ArrowUp,
|
|
|
|
|
NetworkIcon,
|
|
|
|
|
FolderOpen,
|
|
|
|
|
Container,
|
|
|
|
|
Server,
|
|
|
|
|
Check,
|
|
|
|
|
ChevronsUpDown,
|
|
|
|
|
ArrowDownUp,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
2026-05-11 01:23:11 -05:00
|
|
|
import { useTabs } from "@/shell/TabContext";
|
2026-01-24 19:49:42 -06:00
|
|
|
import {
|
|
|
|
|
Command,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandInput,
|
|
|
|
|
CommandItem,
|
2026-05-11 01:23:11 -05:00
|
|
|
} from "@/components/command";
|
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
|
2026-01-24 19:49:42 -06:00
|
|
|
import { cn } from "@/lib/utils";
|
2026-05-11 01:23:11 -05:00
|
|
|
import { SimpleLoader } from "@/lib/SimpleLoader";
|
2026-01-24 19:49:42 -06:00
|
|
|
|
|
|
|
|
const AVAILABLE_COLORS = [
|
|
|
|
|
{ value: "#ef4444", label: "Red" },
|
|
|
|
|
{ value: "#f97316", label: "Orange" },
|
|
|
|
|
{ value: "#eab308", label: "Yellow" },
|
|
|
|
|
{ value: "#22c55e", label: "Green" },
|
|
|
|
|
{ value: "#3b82f6", label: "Blue" },
|
|
|
|
|
{ value: "#a855f7", label: "Purple" },
|
|
|
|
|
{ value: "#ec4899", label: "Pink" },
|
|
|
|
|
{ value: "#6b7280", label: "Gray" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
interface HostMap {
|
|
|
|
|
[key: string]: SSHHostWithStatus;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ContextMenuState {
|
|
|
|
|
visible: boolean;
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
targetId: string;
|
|
|
|
|
type: "node" | "group" | "edge" | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NetworkGraphCardProps {
|
|
|
|
|
isTopbarOpen?: boolean;
|
|
|
|
|
rightSidebarOpen?: boolean;
|
|
|
|
|
rightSidebarWidth?: number;
|
|
|
|
|
embedded?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 15:12:07 -05:00
|
|
|
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
|
|
|
|
|
2026-01-24 19:49:42 -06:00
|
|
|
export function NetworkGraphCard({
|
|
|
|
|
embedded = true,
|
|
|
|
|
}: NetworkGraphCardProps): React.ReactElement {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const { addTab } = useTabs();
|
|
|
|
|
|
2026-05-06 15:12:07 -05:00
|
|
|
const [elements, setElements] = useState<NetworkElement[]>([]);
|
2026-01-24 19:49:42 -06:00
|
|
|
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
|
|
|
|
const [hostMap, setHostMap] = useState<HostMap>({});
|
|
|
|
|
const hostMapRef = useRef<HostMap>({});
|
|
|
|
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
|
|
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const [showAddNodeDialog, setShowAddNodeDialog] = useState(false);
|
|
|
|
|
const [showAddEdgeDialog, setShowAddEdgeDialog] = useState(false);
|
|
|
|
|
const [showAddGroupDialog, setShowAddGroupDialog] = useState(false);
|
|
|
|
|
const [showEditGroupDialog, setShowEditGroupDialog] = useState(false);
|
|
|
|
|
const [showNodeDetail, setShowNodeDetail] = useState(false);
|
|
|
|
|
const [showMoveNodeDialog, setShowMoveNodeDialog] = useState(false);
|
|
|
|
|
|
|
|
|
|
const [selectedHostForAddNode, setSelectedHostForAddNode] =
|
|
|
|
|
useState<string>("");
|
|
|
|
|
const [selectedGroupForAddNode, setSelectedGroupForAddNode] =
|
|
|
|
|
useState<string>("ROOT");
|
|
|
|
|
const [newGroupName, setNewGroupName] = useState("");
|
|
|
|
|
const [newGroupColor, setNewGroupColor] = useState("#3b82f6");
|
|
|
|
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
|
|
|
|
const [selectedGroupForMove, setSelectedGroupForMove] =
|
|
|
|
|
useState<string>("ROOT");
|
|
|
|
|
const [selectedHostForEdge, setSelectedHostForEdge] = useState<string>("");
|
|
|
|
|
const [targetHostForEdge, setTargetHostForEdge] = useState<string>("");
|
|
|
|
|
const [selectedNodeForDetail, setSelectedNodeForDetail] =
|
|
|
|
|
useState<SSHHostWithStatus | null>(null);
|
|
|
|
|
|
|
|
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
|
|
|
|
visible: false,
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
targetId: "",
|
|
|
|
|
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<cytoscape.Core | null>(null);
|
|
|
|
|
const statusCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
hostMapRef.current = hostMap;
|
|
|
|
|
}, [hostMap]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadData();
|
|
|
|
|
const interval = setInterval(updateHostStatuses, 30000);
|
|
|
|
|
statusCheckIntervalRef.current = interval;
|
|
|
|
|
|
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
|
|
|
if (
|
|
|
|
|
contextMenuRef.current &&
|
|
|
|
|
!contextMenuRef.current.contains(e.target as Node)
|
|
|
|
|
) {
|
|
|
|
|
setContextMenu((prev) =>
|
|
|
|
|
prev.visible ? { ...prev, visible: false } : prev,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("mousedown", handleClickOutside, true);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (statusCheckIntervalRef.current)
|
|
|
|
|
clearInterval(statusCheckIntervalRef.current);
|
|
|
|
|
document.removeEventListener("mousedown", handleClickOutside, true);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadData = async () => {
|
|
|
|
|
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);
|
|
|
|
|
|
2026-05-06 15:12:07 -05:00
|
|
|
let nodes: NetworkTopologyNode[] = [];
|
|
|
|
|
let edges: NetworkTopologyEdge[] = [];
|
2026-01-24 19:49:42 -06:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const topologyData = await getNetworkTopology();
|
|
|
|
|
if (
|
|
|
|
|
topologyData &&
|
|
|
|
|
topologyData.nodes &&
|
|
|
|
|
Array.isArray(topologyData.nodes)
|
|
|
|
|
) {
|
2026-05-06 15:12:07 -05:00
|
|
|
nodes = topologyData.nodes.map((node) => {
|
2026-01-24 19:49:42 -06:00
|
|
|
const host = newHostMap[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 || [],
|
|
|
|
|
parent: node.data.parent,
|
|
|
|
|
color: node.data.color,
|
|
|
|
|
},
|
|
|
|
|
position: node.position || { x: 0, y: 0 },
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
edges = topologyData.edges || [];
|
|
|
|
|
}
|
2026-05-06 15:12:07 -05:00
|
|
|
} catch {
|
2026-01-24 19:49:42 -06:00
|
|
|
console.warn("Starting with empty topology");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 15:12:07 -05:00
|
|
|
const nodeIds = new Set(nodes.map((n) => n.data.id));
|
|
|
|
|
const validEdges = edges.filter((edge) => {
|
2026-01-24 19:49:42 -06:00
|
|
|
const sourceExists = nodeIds.has(edge.data.source);
|
|
|
|
|
const targetExists = nodeIds.has(edge.data.target);
|
|
|
|
|
return sourceExists && targetExists;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setElements([...nodes, ...validEdges]);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Failed to load topology:", err);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateHostStatuses = useCallback(async () => {
|
|
|
|
|
if (!cyRef.current) return;
|
|
|
|
|
try {
|
|
|
|
|
const updatedHosts = await getSSHHosts();
|
|
|
|
|
const updatedHostMap: HostMap = {};
|
|
|
|
|
updatedHosts.forEach((host) => {
|
|
|
|
|
updatedHostMap[String(host.id)] = host;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 || []);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
setHostMap(updatedHostMap);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Status update failed:", err);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const debouncedSave = useCallback(() => {
|
|
|
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
|
|
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
saveCurrentLayout();
|
|
|
|
|
}, 1000);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const saveCurrentLayout = async () => {
|
|
|
|
|
if (!cyRef.current) return;
|
|
|
|
|
try {
|
|
|
|
|
const nodes = cyRef.current.nodes().map((node) => ({
|
|
|
|
|
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"),
|
|
|
|
|
},
|
|
|
|
|
position: node.position(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const edges = cyRef.current.edges().map((edge) => ({
|
|
|
|
|
data: {
|
|
|
|
|
id: edge.data("id"),
|
|
|
|
|
source: edge.data("source"),
|
|
|
|
|
target: edge.data("target"),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
await saveNetworkTopology({ nodes, edges });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Save failed:", err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!cyRef.current || loading || elements.length === 0) return;
|
|
|
|
|
const hasPositions = elements.some(
|
2026-05-06 15:12:07 -05:00
|
|
|
(el) =>
|
|
|
|
|
"position" in el &&
|
|
|
|
|
el.position &&
|
|
|
|
|
(el.position.x !== 0 || el.position.y !== 0),
|
2026-01-24 19:49:42 -06:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!hasPositions) {
|
|
|
|
|
cyRef.current
|
|
|
|
|
.layout({
|
|
|
|
|
name: "cose",
|
|
|
|
|
animate: false,
|
|
|
|
|
randomize: true,
|
|
|
|
|
componentSpacing: 100,
|
|
|
|
|
nodeOverlap: 20,
|
|
|
|
|
})
|
|
|
|
|
.run();
|
|
|
|
|
} else {
|
|
|
|
|
cyRef.current.fit();
|
|
|
|
|
}
|
|
|
|
|
}, [loading]);
|
|
|
|
|
|
|
|
|
|
const handleNodeInit = useCallback(
|
|
|
|
|
(cy: cytoscape.Core) => {
|
|
|
|
|
cyRef.current = cy;
|
|
|
|
|
|
|
|
|
|
if (!embedded) {
|
|
|
|
|
cy.nodes().forEach((node) => {
|
|
|
|
|
node.grabify();
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
cy.nodes().forEach((node) => {
|
|
|
|
|
node.ungrabify();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) => `
|
|
|
|
|
<span style="
|
|
|
|
|
background-color:#f97316;
|
|
|
|
|
color:#ffffff;
|
|
|
|
|
padding:2px 8px;
|
|
|
|
|
border-radius:9999px;
|
|
|
|
|
font-size:9px;
|
|
|
|
|
font-weight:700;
|
|
|
|
|
margin:0 2px;
|
|
|
|
|
display:inline-block;
|
|
|
|
|
box-shadow:0 1px 2px rgba(0,0,0,0.3);
|
|
|
|
|
">${t}</span>`,
|
|
|
|
|
)
|
|
|
|
|
.join("");
|
|
|
|
|
|
|
|
|
|
const svg = `
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="90" viewBox="0 0 180 90">
|
|
|
|
|
<defs>
|
|
|
|
|
<filter id="shadow-${ele.id()}" x="-10%" y="-10%" width="120%" height="120%">
|
|
|
|
|
<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#000000" flood-opacity="0.25"/>
|
|
|
|
|
</filter>
|
|
|
|
|
</defs>
|
|
|
|
|
<rect x="3" y="3" width="174" height="84" rx="6"
|
|
|
|
|
fill="${bgColor}" stroke="${statusColor}" stroke-width="2" filter="url(#shadow-${ele.id()})"/>
|
|
|
|
|
<foreignObject x="8" y="8" width="164" height="74">
|
|
|
|
|
<div xmlns="http://www.w3.org/1999/xhtml"
|
|
|
|
|
style="color:${textColor};text-align:center;font-family:sans-serif;
|
|
|
|
|
height:100%;display:flex;flex-direction:column;justify-content:center;
|
|
|
|
|
align-items:center;line-height:1.2;">
|
|
|
|
|
<div style="font-weight:700;font-size:14px;margin-bottom:2px;
|
|
|
|
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:100%;">${name}</div>
|
|
|
|
|
<div style="font-weight:600;font-size:11px;color:${secondaryTextColor};margin-bottom:6px;
|
|
|
|
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:100%;">${ip}</div>
|
|
|
|
|
<div style="display:flex;flex-wrap:wrap;justify-content:center;align-items:center;">
|
|
|
|
|
${tagsHtml}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</foreignObject>
|
|
|
|
|
</svg>
|
|
|
|
|
`;
|
|
|
|
|
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",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cy.on("tap", "node", (evt) => {
|
|
|
|
|
const node = evt.target;
|
|
|
|
|
setContextMenu((prev) =>
|
|
|
|
|
prev.visible ? { ...prev, visible: false } : prev,
|
|
|
|
|
);
|
|
|
|
|
setSelectedEdgeId(null);
|
|
|
|
|
setSelectedNodeId(node.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,
|
|
|
|
|
);
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
setContextMenu({
|
|
|
|
|
visible: true,
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
targetId: nodeId,
|
|
|
|
|
type: isGroup ? "group" : "node",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cy.on("zoom pan", () => {
|
|
|
|
|
setContextMenu((prev) =>
|
|
|
|
|
prev.visible ? { ...prev, visible: false } : prev,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cy.on("free", "node", () => !embedded && debouncedSave());
|
|
|
|
|
|
|
|
|
|
cy.on("boxselect", "node", () => {
|
|
|
|
|
const selected = cy.$("node:selected");
|
|
|
|
|
if (selected.length === 1) setSelectedNodeId(selected[0].id());
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[debouncedSave, embedded],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleContextAction = (action: string) => {
|
|
|
|
|
setContextMenu((prev) => ({ ...prev, visible: false }));
|
|
|
|
|
const targetId = contextMenu.targetId;
|
|
|
|
|
if (!cyRef.current) return;
|
|
|
|
|
|
|
|
|
|
if (action === "details") {
|
|
|
|
|
const host = hostMap[targetId];
|
|
|
|
|
if (host) {
|
|
|
|
|
setSelectedNodeForDetail(host);
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
} else if (action === "move") {
|
|
|
|
|
setSelectedNodeId(targetId);
|
|
|
|
|
const node = cyRef.current.$id(targetId);
|
|
|
|
|
const parentId = node.data("parent");
|
|
|
|
|
setSelectedGroupForMove(parentId || "ROOT");
|
|
|
|
|
setShowMoveNodeDialog(true);
|
|
|
|
|
} else if (action === "removeFromGroup") {
|
|
|
|
|
const node = cyRef.current.$id(targetId);
|
|
|
|
|
node.move({ parent: null });
|
|
|
|
|
debouncedSave();
|
|
|
|
|
} else if (action === "editGroup") {
|
|
|
|
|
const node = cyRef.current.$id(targetId);
|
|
|
|
|
setEditingGroupId(targetId);
|
|
|
|
|
setNewGroupName(node.data("label"));
|
|
|
|
|
setNewGroupColor(node.data("color") || "#3b82f6");
|
|
|
|
|
setShowEditGroupDialog(true);
|
|
|
|
|
} else if (action === "addHostToGroup") {
|
|
|
|
|
setSelectedGroupForAddNode(targetId);
|
|
|
|
|
setSelectedHostForAddNode("");
|
|
|
|
|
setShowAddNodeDialog(true);
|
|
|
|
|
} else if (action === "delete") {
|
|
|
|
|
cyRef.current.$id(targetId).remove();
|
|
|
|
|
debouncedSave();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hasTunnelConnections = (host: SSHHostWithStatus | undefined) => {
|
|
|
|
|
if (!host?.tunnelConnections) return false;
|
|
|
|
|
try {
|
|
|
|
|
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
|
|
|
|
? host.tunnelConnections
|
|
|
|
|
: JSON.parse(host.tunnelConnections);
|
|
|
|
|
return Array.isArray(tunnelConnections) && tunnelConnections.length > 0;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAddNode = () => {
|
|
|
|
|
setSelectedHostForAddNode("");
|
|
|
|
|
setSelectedGroupForAddNode("ROOT");
|
|
|
|
|
setShowAddNodeDialog(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleConfirmAddNode = async () => {
|
|
|
|
|
if (!cyRef.current || !selectedHostForAddNode) return;
|
|
|
|
|
try {
|
|
|
|
|
if (cyRef.current.$id(selectedHostForAddNode).length > 0) {
|
|
|
|
|
setError(t("networkGraph.hostAlreadyExists"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const host = hostMap[selectedHostForAddNode];
|
|
|
|
|
const parent =
|
|
|
|
|
selectedGroupForAddNode === "ROOT"
|
|
|
|
|
? undefined
|
|
|
|
|
: selectedGroupForAddNode;
|
|
|
|
|
|
|
|
|
|
const newNode = {
|
|
|
|
|
data: {
|
|
|
|
|
id: selectedHostForAddNode,
|
|
|
|
|
label: host.name || `${host.ip}`,
|
|
|
|
|
ip: `${host.ip}:${host.port}`,
|
|
|
|
|
status: host.status,
|
|
|
|
|
tags: host.tags || [],
|
|
|
|
|
parent: parent,
|
|
|
|
|
},
|
|
|
|
|
position: { x: 100 + Math.random() * 50, y: 100 + Math.random() * 50 },
|
|
|
|
|
};
|
|
|
|
|
cyRef.current.add(newNode);
|
|
|
|
|
await saveCurrentLayout();
|
|
|
|
|
setElements([...cyRef.current.elements().jsons()]);
|
|
|
|
|
forceUpdate();
|
|
|
|
|
setShowAddNodeDialog(false);
|
2026-05-06 15:12:07 -05:00
|
|
|
} catch {
|
2026-01-24 19:49:42 -06:00
|
|
|
setError(t("networkGraph.failedToAddNode"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAddGroup = async () => {
|
|
|
|
|
if (!cyRef.current || !newGroupName) return;
|
|
|
|
|
const groupId = `group-${Date.now()}`;
|
|
|
|
|
cyRef.current.add({
|
|
|
|
|
data: { id: groupId, label: newGroupName, color: newGroupColor },
|
|
|
|
|
});
|
|
|
|
|
await saveCurrentLayout();
|
|
|
|
|
setElements([...cyRef.current.elements().jsons()]);
|
|
|
|
|
forceUpdate();
|
|
|
|
|
setShowAddGroupDialog(false);
|
|
|
|
|
setNewGroupName("");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleUpdateGroup = async () => {
|
|
|
|
|
if (!cyRef.current || !editingGroupId || !newGroupName) return;
|
|
|
|
|
const group = cyRef.current.$id(editingGroupId);
|
|
|
|
|
group.data("label", newGroupName);
|
|
|
|
|
group.data("color", newGroupColor);
|
|
|
|
|
await saveCurrentLayout();
|
|
|
|
|
setShowEditGroupDialog(false);
|
|
|
|
|
setEditingGroupId(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMoveNodeToGroup = async () => {
|
|
|
|
|
if (!cyRef.current || !selectedNodeId) return;
|
|
|
|
|
const node = cyRef.current.$id(selectedNodeId);
|
|
|
|
|
const parent =
|
|
|
|
|
selectedGroupForMove === "ROOT" ? null : selectedGroupForMove;
|
|
|
|
|
node.move({ parent: parent });
|
|
|
|
|
await saveCurrentLayout();
|
|
|
|
|
setShowMoveNodeDialog(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAddEdge = async () => {
|
|
|
|
|
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,
|
|
|
|
|
source: selectedHostForEdge,
|
|
|
|
|
target: targetHostForEdge,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await saveCurrentLayout();
|
|
|
|
|
setShowAddEdgeDialog(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRemoveSelected = () => {
|
|
|
|
|
if (!cyRef.current) return;
|
|
|
|
|
|
|
|
|
|
if (selectedNodeId) {
|
|
|
|
|
cyRef.current.$id(selectedNodeId).remove();
|
|
|
|
|
setSelectedNodeId(null);
|
|
|
|
|
} else if (selectedEdgeId) {
|
|
|
|
|
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 availableNodesForConnection = useMemo(() => {
|
|
|
|
|
return elements
|
|
|
|
|
.filter((el) => !el.data.source && !el.data.target)
|
|
|
|
|
.map((el) => ({
|
|
|
|
|
id: el.data.id,
|
|
|
|
|
label: el.data.label,
|
|
|
|
|
}));
|
|
|
|
|
}, [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)));
|
|
|
|
|
}, [hosts, elements]);
|
|
|
|
|
|
|
|
|
|
const handleOpenInNewTab = () => {
|
|
|
|
|
addTab({
|
|
|
|
|
type: "network_graph",
|
|
|
|
|
title: t("dashboard.networkGraph"),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!embedded) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full w-full flex flex-col bg-canvas p-4">
|
|
|
|
|
<div className="flex flex-row items-center justify-between mb-3">
|
|
|
|
|
<p className="text-xl font-semibold">{t("dashboard.networkGraph")}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AlertDialog open={!!error} onOpenChange={() => setError(null)}>
|
|
|
|
|
<AlertDialogContent className="bg-canvas border-2 border-edge">
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
|
|
|
<AlertDialogDescription className="text-foreground flex-1">
|
|
|
|
|
{error}
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<AlertDialogAction onClick={() => setError(null)}>
|
|
|
|
|
OK
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</div>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
|
|
|
|
<div className="mb-3 flex items-center gap-2 flex-wrap">
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleAddNode}
|
|
|
|
|
title={t("networkGraph.addHost")}
|
|
|
|
|
className="h-8 px-2"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
|
|
|
{t("networkGraph.addHost")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setNewGroupName("");
|
|
|
|
|
setNewGroupColor("#3b82f6");
|
|
|
|
|
setShowAddGroupDialog(true);
|
|
|
|
|
}}
|
|
|
|
|
title={t("networkGraph.addGroup")}
|
|
|
|
|
className="h-8 px-2"
|
|
|
|
|
>
|
|
|
|
|
<FolderPlus className="w-4 h-4 mr-1" />
|
|
|
|
|
{t("networkGraph.addGroup")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setShowAddEdgeDialog(true)}
|
|
|
|
|
title={t("networkGraph.addLink")}
|
|
|
|
|
className="h-8 px-2"
|
|
|
|
|
>
|
|
|
|
|
<Link2 className="w-4 h-4 mr-1" />
|
|
|
|
|
{t("networkGraph.addLink")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleRemoveSelected}
|
|
|
|
|
disabled={!selectedNodeId && !selectedEdgeId}
|
|
|
|
|
title={t("networkGraph.deleteSelected")}
|
|
|
|
|
className="h-8 px-2 text-red-400 hover:text-red-300 disabled:opacity-30"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-border" />
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => cyRef.current?.zoom(cyRef.current.zoom() * 1.2)}
|
|
|
|
|
title={t("networkGraph.zoomIn")}
|
|
|
|
|
className="h-8 w-8 p-0"
|
|
|
|
|
>
|
|
|
|
|
<ZoomIn className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => cyRef.current?.zoom(cyRef.current.zoom() / 1.2)}
|
|
|
|
|
title={t("networkGraph.zoomOut")}
|
|
|
|
|
className="h-8 w-8 p-0"
|
|
|
|
|
>
|
|
|
|
|
<ZoomOut className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => cyRef.current?.fit()}
|
|
|
|
|
title={t("networkGraph.resetView")}
|
|
|
|
|
className="h-8 w-8 p-0"
|
|
|
|
|
>
|
|
|
|
|
<RotateCw className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-border" />
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!cyRef.current) return;
|
|
|
|
|
const json = JSON.stringify(
|
|
|
|
|
cyRef.current.json().elements,
|
|
|
|
|
null,
|
|
|
|
|
2,
|
|
|
|
|
);
|
|
|
|
|
const blob = new Blob([json], { type: "application/json" });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = "network.json";
|
|
|
|
|
a.click();
|
|
|
|
|
}}
|
|
|
|
|
title={t("networkGraph.exportJSON")}
|
|
|
|
|
className="h-8 w-8 p-0"
|
|
|
|
|
>
|
|
|
|
|
<Download className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
title={t("networkGraph.importJSON")}
|
|
|
|
|
className="h-8 w-8 p-0"
|
|
|
|
|
>
|
|
|
|
|
<Upload className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
accept=".json"
|
|
|
|
|
onChange={async (e) => {
|
|
|
|
|
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 = "";
|
|
|
|
|
}
|
2026-05-06 15:12:07 -05:00
|
|
|
} catch {
|
2026-01-24 19:49:42 -06:00
|
|
|
setError(t("networkGraph.invalidFile"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
reader.readAsText(file);
|
|
|
|
|
}}
|
|
|
|
|
className="hidden"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-hidden relative rounded-md border-2 border-edge">
|
|
|
|
|
<SimpleLoader
|
|
|
|
|
visible={loading}
|
|
|
|
|
backgroundColor="rgba(0, 0, 0, 0.5)"
|
|
|
|
|
className="z-10"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{contextMenu.visible && (
|
|
|
|
|
<div
|
|
|
|
|
ref={contextMenuRef}
|
|
|
|
|
className="fixed z-[200] min-w-[180px] rounded-md shadow-2xl p-1 flex flex-col gap-0.5 bg-canvas border-2 border-edge"
|
|
|
|
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
|
|
|
|
>
|
|
|
|
|
{contextMenu.type === "node" && (
|
|
|
|
|
<>
|
|
|
|
|
{hostMap[contextMenu.targetId]?.enableTerminal && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("terminal")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<Terminal className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.terminal")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{hostMap[contextMenu.targetId]?.enableFileManager && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("file_manager")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<FolderOpen className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.fileManager")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{hostMap[contextMenu.targetId]?.enableTunnel &&
|
|
|
|
|
hasTunnelConnections(hostMap[contextMenu.targetId]) && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("tunnel")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<ArrowDownUp className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.tunnel")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{hostMap[contextMenu.targetId]?.enableDocker && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("docker")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<Container className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.docker")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("server_stats")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<Server className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.serverStats")}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{!embedded && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="h-px my-1 bg-border" />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleContextAction("move")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<FolderInput className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.moveToGroup")}
|
|
|
|
|
</button>
|
|
|
|
|
{cyRef.current?.$id(contextMenu.targetId).parent()
|
|
|
|
|
.length ? (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleContextAction("removeFromGroup")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<FolderMinus className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.removeFromGroup")}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
<div className="h-px my-1 bg-border" />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleContextAction("delete")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-red-400 hover:text-red-300 text-left w-full transition-colors hover:bg-red-950/30"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.delete")}
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{contextMenu.type === "group" && !embedded && (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleContextAction("addHostToGroup")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<FolderPlus className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.addHostHere")}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleContextAction("editGroup")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<Edit className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.editGroup")}
|
|
|
|
|
</button>
|
|
|
|
|
<div className="h-px my-1 bg-border" />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleContextAction("delete")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-red-400 hover:text-red-300 text-left w-full transition-colors hover:bg-red-950/30"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.delete")}
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<CytoscapeComponent
|
|
|
|
|
elements={elements}
|
|
|
|
|
style={{ width: "100%", height: "100%" }}
|
|
|
|
|
layout={{ name: "preset" }}
|
|
|
|
|
cy={handleNodeInit}
|
|
|
|
|
wheelSensitivity={2.0}
|
|
|
|
|
minZoom={0.2}
|
|
|
|
|
maxZoom={3}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Dialog open={showAddNodeDialog} onOpenChange={setShowAddNodeDialog}>
|
|
|
|
|
<DialogContent className="bg-canvas border-2 border-edge">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{t("networkGraph.addHost")}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="grid gap-4 py-4">
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<Label>{t("networkGraph.selectHost")}</Label>
|
|
|
|
|
<Popover open={hostComboOpen} onOpenChange={setHostComboOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={hostComboOpen}
|
|
|
|
|
className="justify-between border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{selectedHostForAddNode ? (
|
|
|
|
|
<div className="flex flex-col items-start">
|
|
|
|
|
<span>
|
|
|
|
|
{availableHostsForAdd.find(
|
|
|
|
|
(h) => String(h.id) === selectedHostForAddNode,
|
|
|
|
|
)?.name ||
|
|
|
|
|
availableHostsForAdd.find(
|
|
|
|
|
(h) => String(h.id) === selectedHostForAddNode,
|
|
|
|
|
)?.ip}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{
|
|
|
|
|
availableHostsForAdd.find(
|
|
|
|
|
(h) => String(h.id) === selectedHostForAddNode,
|
|
|
|
|
)?.username
|
|
|
|
|
}
|
|
|
|
|
@
|
|
|
|
|
{
|
|
|
|
|
availableHostsForAdd.find(
|
|
|
|
|
(h) => String(h.id) === selectedHostForAddNode,
|
|
|
|
|
)?.ip
|
|
|
|
|
}
|
|
|
|
|
:
|
|
|
|
|
{
|
|
|
|
|
availableHostsForAdd.find(
|
|
|
|
|
(h) => String(h.id) === selectedHostForAddNode,
|
|
|
|
|
)?.port
|
|
|
|
|
}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
t("networkGraph.chooseHost")
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0 w-full bg-canvas border-2 border-edge"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder={t("networkGraph.searchHost")}
|
|
|
|
|
/>
|
|
|
|
|
<CommandEmpty>
|
|
|
|
|
{t("networkGraph.noHostFound")}
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{availableHostsForAdd.map((h) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={h.id}
|
|
|
|
|
value={String(h.id)}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedHostForAddNode(String(h.id));
|
|
|
|
|
setHostComboOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
selectedHostForAddNode === String(h.id)
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span>{h.name || h.ip}</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{h.username}@{h.ip}:{h.port}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<Label>{t("networkGraph.parentGroup")}</Label>
|
|
|
|
|
<Popover open={groupComboOpen} onOpenChange={setGroupComboOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={groupComboOpen}
|
|
|
|
|
className="justify-between border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{selectedGroupForAddNode === "ROOT"
|
|
|
|
|
? t("networkGraph.noGroup")
|
|
|
|
|
: availableGroups.find(
|
|
|
|
|
(g) => g.id === selectedGroupForAddNode,
|
|
|
|
|
)?.label}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0 w-full bg-canvas border-2 border-edge"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder={t("networkGraph.searchGroup")}
|
|
|
|
|
/>
|
|
|
|
|
<CommandEmpty>
|
|
|
|
|
{t("networkGraph.noGroupFound")}
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
<CommandItem
|
|
|
|
|
value="ROOT"
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedGroupForAddNode("ROOT");
|
|
|
|
|
setGroupComboOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
selectedGroupForAddNode === "ROOT"
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{t("networkGraph.noGroup")}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
{availableGroups.map((g) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={g.id}
|
|
|
|
|
value={g.id}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedGroupForAddNode(g.id);
|
|
|
|
|
setGroupComboOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
selectedGroupForAddNode === g.id
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{g.label}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setShowAddNodeDialog(false)}
|
|
|
|
|
className="border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{t("common.cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleConfirmAddNode}
|
|
|
|
|
disabled={!selectedHostForAddNode}
|
|
|
|
|
>
|
|
|
|
|
{t("common.add")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
|
open={showAddGroupDialog || showEditGroupDialog}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setShowAddGroupDialog(false);
|
|
|
|
|
setShowEditGroupDialog(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent className="bg-canvas border-2 border-edge">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>
|
|
|
|
|
{showEditGroupDialog
|
|
|
|
|
? t("networkGraph.editGroup")
|
|
|
|
|
: t("networkGraph.createGroup")}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="grid gap-4 py-4">
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<Label>{t("networkGraph.groupName")}</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={newGroupName}
|
|
|
|
|
onChange={(e) => setNewGroupName(e.target.value)}
|
|
|
|
|
placeholder={t("networkGraph.groupName")}
|
|
|
|
|
className="border-2 border-edge"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<Label>{t("networkGraph.color")}</Label>
|
|
|
|
|
<div className="grid grid-cols-4 gap-2">
|
|
|
|
|
{AVAILABLE_COLORS.map((color) => (
|
|
|
|
|
<button
|
|
|
|
|
key={color.value}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setNewGroupColor(color.value)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-10 rounded border-2 transition-all",
|
|
|
|
|
newGroupColor === color.value
|
|
|
|
|
? "border-primary ring-2 ring-primary"
|
|
|
|
|
: "border-edge hover:border-muted",
|
|
|
|
|
)}
|
|
|
|
|
style={{ backgroundColor: color.value }}
|
|
|
|
|
title={color.label}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setShowAddGroupDialog(false);
|
|
|
|
|
setShowEditGroupDialog(false);
|
|
|
|
|
}}
|
|
|
|
|
className="border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{t("common.cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={
|
|
|
|
|
showEditGroupDialog ? handleUpdateGroup : handleAddGroup
|
|
|
|
|
}
|
|
|
|
|
disabled={!newGroupName}
|
|
|
|
|
>
|
|
|
|
|
{showEditGroupDialog ? t("common.update") : t("common.create")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog open={showMoveNodeDialog} onOpenChange={setShowMoveNodeDialog}>
|
|
|
|
|
<DialogContent className="bg-canvas border-2 border-edge">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{t("networkGraph.moveToGroup")}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="grid gap-4 py-4">
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<Label>{t("networkGraph.selectGroup")}</Label>
|
|
|
|
|
<Popover
|
|
|
|
|
open={moveGroupComboOpen}
|
|
|
|
|
onOpenChange={setMoveGroupComboOpen}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={moveGroupComboOpen}
|
|
|
|
|
className="justify-between border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{selectedGroupForMove === "ROOT"
|
|
|
|
|
? t("networkGraph.noGroup")
|
|
|
|
|
: availableGroups.find(
|
|
|
|
|
(g) => g.id === selectedGroupForMove,
|
|
|
|
|
)?.label}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0 w-full bg-canvas border-2 border-edge"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder={t("networkGraph.searchGroup")}
|
|
|
|
|
/>
|
|
|
|
|
<CommandEmpty>
|
|
|
|
|
{t("networkGraph.noGroupFound")}
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
<CommandItem
|
|
|
|
|
value="ROOT"
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedGroupForMove("ROOT");
|
|
|
|
|
setMoveGroupComboOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
selectedGroupForMove === "ROOT"
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{t("networkGraph.noGroup")}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
{availableGroups.map((g) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={g.id}
|
|
|
|
|
value={g.id}
|
|
|
|
|
disabled={g.id === selectedNodeId}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
if (g.id !== selectedNodeId) {
|
|
|
|
|
setSelectedGroupForMove(g.id);
|
|
|
|
|
setMoveGroupComboOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
selectedGroupForMove === g.id
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{g.label}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setShowMoveNodeDialog(false)}
|
|
|
|
|
className="border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{t("common.cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleMoveNodeToGroup}>
|
|
|
|
|
{t("networkGraph.move")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog open={showAddEdgeDialog} onOpenChange={setShowAddEdgeDialog}>
|
|
|
|
|
<DialogContent className="bg-canvas border-2 border-edge">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{t("networkGraph.addConnection")}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="grid gap-4 py-4">
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<Label>{t("networkGraph.source")}</Label>
|
|
|
|
|
<Popover
|
|
|
|
|
open={sourceComboOpen}
|
|
|
|
|
onOpenChange={setSourceComboOpen}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={sourceComboOpen}
|
|
|
|
|
className="justify-between border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{selectedHostForEdge
|
|
|
|
|
? availableNodesForConnection.find(
|
|
|
|
|
(el) => el.id === selectedHostForEdge,
|
|
|
|
|
)?.label
|
|
|
|
|
: t("networkGraph.selectSourcePlaceholder")}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0 w-full bg-canvas border-2 border-edge"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder={t("networkGraph.searchNode")}
|
|
|
|
|
/>
|
|
|
|
|
<CommandEmpty>
|
|
|
|
|
{t("networkGraph.noNodeFound")}
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{availableNodesForConnection.map((el) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={el.id}
|
|
|
|
|
value={el.id}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedHostForEdge(el.id);
|
|
|
|
|
setSourceComboOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
selectedHostForEdge === el.id
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{el.label}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<Label>{t("networkGraph.target")}</Label>
|
|
|
|
|
<Popover
|
|
|
|
|
open={targetComboOpen}
|
|
|
|
|
onOpenChange={setTargetComboOpen}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={targetComboOpen}
|
|
|
|
|
className="justify-between border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{targetHostForEdge
|
|
|
|
|
? availableNodesForConnection.find(
|
|
|
|
|
(el) => el.id === targetHostForEdge,
|
|
|
|
|
)?.label
|
|
|
|
|
: t("networkGraph.selectTargetPlaceholder")}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0 w-full bg-canvas border-2 border-edge"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder={t("networkGraph.searchNode")}
|
|
|
|
|
/>
|
|
|
|
|
<CommandEmpty>
|
|
|
|
|
{t("networkGraph.noNodeFound")}
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{availableNodesForConnection.map((el) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={el.id}
|
|
|
|
|
value={el.id}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setTargetHostForEdge(el.id);
|
|
|
|
|
setTargetComboOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
targetHostForEdge === el.id
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{el.label}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setShowAddEdgeDialog(false)}
|
|
|
|
|
className="border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{t("common.cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleAddEdge}>
|
|
|
|
|
{t("networkGraph.connect")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog open={showNodeDetail} onOpenChange={setShowNodeDetail}>
|
|
|
|
|
<DialogContent className="bg-canvas border-2 border-edge">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{t("networkGraph.hostDetails")}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
{selectedNodeForDetail && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
|
|
|
<span className="font-semibold">
|
|
|
|
|
{t("networkGraph.name")}:
|
|
|
|
|
</span>
|
|
|
|
|
<span>{selectedNodeForDetail.name}</span>
|
|
|
|
|
<span className="font-semibold">{t("networkGraph.ip")}:</span>
|
|
|
|
|
<span>{selectedNodeForDetail.ip}</span>
|
|
|
|
|
<span className="font-semibold">
|
|
|
|
|
{t("networkGraph.status")}:
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-sm capitalize">
|
|
|
|
|
{selectedNodeForDetail.status || t("networkGraph.unknown")}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-semibold">{t("networkGraph.id")}:</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{selectedNodeForDetail.id}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{selectedNodeForDetail.tags &&
|
|
|
|
|
selectedNodeForDetail.tags.length > 0 && (
|
|
|
|
|
<div className="flex gap-1 flex-wrap">
|
|
|
|
|
{selectedNodeForDetail.tags.map((t) => (
|
|
|
|
|
<Badge
|
|
|
|
|
key={t}
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="text-xs border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{t}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setShowNodeDetail(false)}
|
|
|
|
|
className="border-2 border-edge"
|
|
|
|
|
>
|
|
|
|
|
{t("common.close")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
|
|
|
|
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
|
|
|
|
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
|
|
|
|
<p className="text-xl font-semibold flex flex-row items-center">
|
|
|
|
|
<NetworkIcon className="mr-3" />
|
|
|
|
|
{t("dashboard.networkGraph")}
|
|
|
|
|
</p>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleOpenInNewTab}
|
|
|
|
|
className="flex items-center gap-2 h-8"
|
|
|
|
|
title={t("common.openInNewTab")}
|
|
|
|
|
>
|
|
|
|
|
<ArrowUp className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AlertDialog open={!!error} onOpenChange={() => setError(null)}>
|
|
|
|
|
<AlertDialogContent className="bg-canvas border-2 border-edge">
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
|
|
|
<AlertDialogDescription className="text-foreground flex-1">
|
|
|
|
|
{error}
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<AlertDialogAction onClick={() => setError(null)}>
|
|
|
|
|
OK
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</div>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-hidden relative rounded-md border-2 border-edge">
|
|
|
|
|
<SimpleLoader
|
|
|
|
|
visible={loading}
|
|
|
|
|
backgroundColor="rgba(0, 0, 0, 0.5)"
|
|
|
|
|
className="z-10"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{contextMenu.visible && (
|
|
|
|
|
<div
|
|
|
|
|
ref={contextMenuRef}
|
|
|
|
|
className="fixed z-[200] min-w-[180px] rounded-md shadow-2xl p-1 flex flex-col gap-0.5 bg-canvas border-2 border-edge"
|
|
|
|
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
|
|
|
|
>
|
|
|
|
|
{contextMenu.type === "node" && (
|
|
|
|
|
<>
|
|
|
|
|
{hostMap[contextMenu.targetId]?.enableTerminal && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("terminal")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<Terminal className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.terminal")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{hostMap[contextMenu.targetId]?.enableFileManager && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("file_manager")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<FolderOpen className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.fileManager")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{hostMap[contextMenu.targetId]?.enableTunnel &&
|
|
|
|
|
hasTunnelConnections(hostMap[contextMenu.targetId]) && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("tunnel")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<ArrowDownUp className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.tunnel")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{hostMap[contextMenu.targetId]?.enableDocker && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("docker")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<Container className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.docker")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleConnectAction("server_stats")}
|
|
|
|
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<Server className="w-3.5 h-3.5" />
|
|
|
|
|
{t("networkGraph.serverStats")}
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<CytoscapeComponent
|
|
|
|
|
elements={elements}
|
|
|
|
|
style={{ width: "100%", height: "100%" }}
|
|
|
|
|
layout={{ name: "preset" }}
|
|
|
|
|
cy={handleNodeInit}
|
|
|
|
|
wheelSensitivity={2.0}
|
|
|
|
|
minZoom={0.2}
|
|
|
|
|
maxZoom={3}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|