Files
Termix/src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx

1262 lines
42 KiB
TypeScript

import React, {
useEffect,
useState,
useRef,
useCallback,
useMemo,
} from "react";
import CytoscapeComponent from "react-cytoscapejs";
import cytoscape from "cytoscape";
import {
getSSHHosts,
getNetworkTopology,
saveNetworkTopology,
type SSHHostWithStatus,
} from "@/ui/main-axios";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
Trash2,
Move3D,
ZoomIn,
ZoomOut,
RotateCw,
Loader2,
AlertCircle,
Download,
Upload,
Link2,
FolderPlus,
Edit,
FolderInput,
FolderMinus,
Settings2,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
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;
}
export function NetworkGraphCard({}: NetworkGraphCardProps): React.ReactElement {
const { t } = useTranslation();
const { addTab } = useTabs();
const [elements, setElements] = useState<any[]>([]);
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 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(() => {
if (!cyRef.current) return;
const canvas = document.createElement("canvas");
const container = cyRef.current.container();
if (!container) return;
canvas.style.position = "absolute";
canvas.style.top = "0";
canvas.style.left = "0";
canvas.style.pointerEvents = "none";
canvas.style.zIndex = "999";
container.appendChild(canvas);
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationFrame: number;
let startTime = Date.now();
const animate = () => {
if (!cyRef.current || !ctx) return;
const canvasWidth = cyRef.current.width();
const canvasHeight = cyRef.current.height();
canvas.width = canvasWidth;
canvas.height = canvasHeight;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
const elapsed = Date.now() - startTime;
const cycle = (elapsed % 2000) / 2000;
const zoom = cyRef.current.zoom();
const baseRadius = 4;
const pulseAmount = 3;
const pulseRadius =
(baseRadius + Math.sin(cycle * Math.PI * 2) * pulseAmount) * zoom;
const pulseOpacity = 0.6 - Math.sin(cycle * Math.PI * 2) * 0.4;
cyRef.current.nodes('[status="online"]').forEach((node) => {
if (node.isParent()) return;
const pos = node.renderedPosition();
const nodeWidth = 180 * zoom;
const nodeHeight = 90 * zoom;
const dotX = pos.x + nodeWidth / 2 - 10 * zoom;
const dotY = pos.y - nodeHeight / 2 + 10 * zoom;
ctx.beginPath();
ctx.arc(dotX, dotY, pulseRadius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(16, 185, 129, ${pulseOpacity})`;
ctx.fill();
ctx.beginPath();
ctx.arc(dotX, dotY, baseRadius * zoom, 0, Math.PI * 2);
ctx.fillStyle = "#10b981";
ctx.fill();
});
cyRef.current.nodes('[status="offline"]').forEach((node) => {
if (node.isParent()) return;
const pos = node.renderedPosition();
const nodeWidth = 180 * zoom;
const nodeHeight = 90 * zoom;
const dotX = pos.x + nodeWidth / 2 - 10 * zoom;
const dotY = pos.y - nodeHeight / 2 + 10 * zoom;
ctx.beginPath();
ctx.arc(dotX, dotY, 4 * zoom, 0, Math.PI * 2);
ctx.fillStyle = "#ef4444";
ctx.fill();
});
animationFrame = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
if (container && canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}, [elements]);
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);
let nodes: any[] = [];
let edges: any[] = [];
try {
const topologyData = await getNetworkTopology();
if (
topologyData &&
topologyData.nodes &&
Array.isArray(topologyData.nodes)
) {
nodes = topologyData.nodes.map((node: any) => {
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 || [];
}
} catch (topologyError) {
console.warn("Starting with empty topology");
}
setElements([...nodes, ...edges]);
} catch (err) {
console.error("Failed to load topology:", err);
setError("Failed to load data");
} 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(
(el: any) => el.position && (el.position.x !== 0 || el.position.y !== 0),
);
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;
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
? "#10b981"
: isOffline
? "#ef4444"
: "#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="#09090b" 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:#f1f5f9;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:#94a3b8;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": -20,
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 x = evt.originalEvent.clientX;
const y = evt.originalEvent.clientY;
setContextMenu({
visible: true,
x,
y,
targetId: node.id(),
type: node.isParent() ? "group" : "node",
});
});
cy.on("zoom pan", () => {
setContextMenu((prev) =>
prev.visible ? { ...prev, visible: false } : prev,
);
});
cy.on("free", "node", () => debouncedSave());
cy.on("boxselect", "node", () => {
const selected = cy.$("node:selected");
if (selected.length === 1) setSelectedNodeId(selected[0].id());
});
},
[debouncedSave],
);
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 handleAddNode = () => {
setSelectedHostForAddNode("");
setSelectedGroupForAddNode("ROOT");
setShowAddNodeDialog(true);
};
const handleConfirmAddNode = async () => {
if (!cyRef.current || !selectedHostForAddNode) return;
try {
if (cyRef.current.$id(selectedHostForAddNode).length > 0) {
setError("Host is already in the topology");
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();
setShowAddNodeDialog(false);
} catch (err) {
setError("Failed to add node");
}
};
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();
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("Source and target must be different");
const edgeId = `${selectedHostForEdge}-${targetHostForEdge}`;
if (cyRef.current.$id(edgeId).length > 0)
return setError("Connection exists");
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]);
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
{t("dashboard.networkGraph")}
</p>
{error && (
<div className="mb-2 flex items-center gap-2 p-3 text-red-100 text-sm rounded border-2 border-red-600 bg-red-950/50">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto hover:text-white"
>
</button>
</div>
)}
<div className="mb-2 flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleAddNode}
title="Add Host"
className="h-8 px-2"
>
<Plus className="w-4 h-4 mr-1" />
Host
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setNewGroupName("");
setNewGroupColor("#3b82f6");
setShowAddGroupDialog(true);
}}
title="Add Group"
className="h-8 px-2"
>
<FolderPlus className="w-4 h-4 mr-1" />
Group
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAddEdgeDialog(true)}
title="Add Link"
className="h-8 px-2"
>
<Link2 className="w-4 h-4 mr-1" />
Link
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRemoveSelected}
disabled={!selectedNodeId && !selectedEdgeId}
title="Delete Selected"
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="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() =>
cyRef.current?.layout({ name: "cose", animate: true }).run()
}
title="Auto Layout"
className="h-8 w-8 p-0"
>
<Move3D className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => cyRef.current?.zoom(cyRef.current.zoom() * 1.2)}
title="Zoom In"
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="Zoom Out"
className="h-8 w-8 p-0"
>
<ZoomOut className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => cyRef.current?.fit()}
title="Reset View"
className="h-8 w-8 p-0"
>
<RotateCw className="w-4 h-4" />
</Button>
</div>
<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="Export JSON"
className="h-8 w-8 p-0"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title="Import JSON"
className="h-8 w-8 p-0"
>
<Upload className="w-4 h-4" />
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const json = JSON.parse(evt.target?.result as string);
saveNetworkTopology({
nodes: json.nodes,
edges: json.edges,
}).then(() => loadData());
} catch (err) {
setError("Invalid File");
}
};
reader.readAsText(file);
}}
className="hidden"
/>
</div>
</div>
<div className="flex-1 overflow-hidden relative rounded-md border-2 border-edge">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
)}
{contextMenu.visible && (
<div
ref={contextMenuRef}
className="fixed z-[100] 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" && (
<>
<button
onClick={() => handleContextAction("connect")}
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 text-green-400" /> Connect
to Host
</button>
<button
onClick={() => handleContextAction("details")}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium rounded text-left w-full transition-colors hover:bg-muted"
>
<Settings2 className="w-3.5 h-3.5 text-blue-400" /> Host
Details
</button>
<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 text-yellow-400" /> Move
to Group...
</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 text-orange-400" />{" "}
Remove from Group
</button>
) : null}
</>
)}
{contextMenu.type === "group" && (
<>
<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 text-green-400" /> Add
Host Here
</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 text-blue-400" /> Edit Group
</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" /> Delete
</button>
</div>
)}
<CytoscapeComponent
elements={elements}
style={{ width: "100%", height: "100%" }}
layout={{ name: "preset" }}
cy={handleNodeInit}
wheelSensitivity={0.1}
minZoom={0.2}
maxZoom={3}
/>
</div>
<Dialog open={showAddNodeDialog} onOpenChange={setShowAddNodeDialog}>
<DialogContent className="bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle>Add Host</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Select Host</Label>
<Select
value={selectedHostForAddNode}
onValueChange={setSelectedHostForAddNode}
>
<SelectTrigger className="border-2 border-edge">
<SelectValue placeholder="Choose a host..." />
</SelectTrigger>
<SelectContent className="bg-canvas border-2 border-edge">
{availableHostsForAdd.length > 0 ? (
availableHostsForAdd.map((h) => (
<SelectItem key={h.id} value={String(h.id)}>
{h.name || h.ip}
</SelectItem>
))
) : (
<SelectItem value="NONE" disabled>
No available hosts
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Parent Group</Label>
<Select
value={selectedGroupForAddNode}
onValueChange={setSelectedGroupForAddNode}
>
<SelectTrigger className="border-2 border-edge">
<SelectValue placeholder="No Group (Root)" />
</SelectTrigger>
<SelectContent className="bg-canvas border-2 border-edge">
<SelectItem value="ROOT">No Group</SelectItem>
{availableGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowAddNodeDialog(false)}
className="border-2 border-edge"
>
Cancel
</Button>
<Button
onClick={handleConfirmAddNode}
disabled={!selectedHostForAddNode}
>
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 ? "Edit Group" : "Create Group"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Group Name</Label>
<Input
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="e.g. Cluster A"
className="border-2 border-edge"
/>
</div>
<div className="grid gap-2">
<Label>Color</Label>
<div className="flex gap-2 items-center p-2 rounded border-2 border-edge">
<input
type="color"
value={newGroupColor}
onChange={(e) => setNewGroupColor(e.target.value)}
className="w-8 h-8 p-0 border-0 rounded cursor-pointer bg-transparent"
/>
<span className="text-sm text-muted-foreground uppercase">
{newGroupColor}
</span>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowAddGroupDialog(false);
setShowEditGroupDialog(false);
}}
className="border-2 border-edge"
>
Cancel
</Button>
<Button
onClick={
showEditGroupDialog ? handleUpdateGroup : handleAddGroup
}
disabled={!newGroupName}
>
{showEditGroupDialog ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showMoveNodeDialog} onOpenChange={setShowMoveNodeDialog}>
<DialogContent className="bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle>Move to Group</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Select Group</Label>
<Select
value={selectedGroupForMove}
onValueChange={setSelectedGroupForMove}
>
<SelectTrigger className="border-2 border-edge">
<SelectValue placeholder="Select group..." />
</SelectTrigger>
<SelectContent className="bg-canvas border-2 border-edge">
<SelectItem value="ROOT">(No Group)</SelectItem>
{availableGroups.map((g) => (
<SelectItem
key={g.id}
value={g.id}
disabled={g.id === selectedNodeId}
>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowMoveNodeDialog(false)}
className="border-2 border-edge"
>
Cancel
</Button>
<Button onClick={handleMoveNodeToGroup}>Move</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showAddEdgeDialog} onOpenChange={setShowAddEdgeDialog}>
<DialogContent className="bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle>Add Connection</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Source</Label>
<Select
value={selectedHostForEdge}
onValueChange={setSelectedHostForEdge}
>
<SelectTrigger className="border-2 border-edge">
<SelectValue placeholder="Select Source..." />
</SelectTrigger>
<SelectContent className="bg-canvas border-2 border-edge">
{availableNodesForConnection.map((el) => (
<SelectItem key={el.id} value={el.id}>
{el.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Target</Label>
<Select
value={targetHostForEdge}
onValueChange={setTargetHostForEdge}
>
<SelectTrigger className="border-2 border-edge">
<SelectValue placeholder="Select Target..." />
</SelectTrigger>
<SelectContent className="bg-canvas border-2 border-edge">
{availableNodesForConnection.map((el) => (
<SelectItem key={el.id} value={el.id}>
{el.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowAddEdgeDialog(false)}
className="border-2 border-edge"
>
Cancel
</Button>
<Button onClick={handleAddEdge}>Connect</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showNodeDetail} onOpenChange={setShowNodeDetail}>
<DialogContent className="bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle>Host Details</DialogTitle>
</DialogHeader>
{selectedNodeForDetail && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2 text-sm">
<span className="font-semibold">Name:</span>
<span>{selectedNodeForDetail.name}</span>
<span className="font-semibold">IP:</span>
<span>{selectedNodeForDetail.ip}</span>
<span className="font-semibold">Status:</span>
<span
className={
selectedNodeForDetail.status === "online"
? "text-green-500"
: "text-red-500"
}
>
{selectedNodeForDetail.status}
</span>
<span className="font-semibold">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"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}