From aac3fa3758585f981ddf3c1bf7245c0cc2ac3d4c Mon Sep 17 00:00:00 2001 From: nikita Date: Sat, 4 Apr 2026 12:14:17 +0300 Subject: [PATCH] feat: graph-page --- frontend/.qwen/settings.json | 3 +- .../app/providers/layout/sidebar/sidebar.tsx | 3 + .../src/app/providers/routing/routing.tsx | 6 +- frontend/src/modules/graph/Graph.tsx | 125 ++++ .../modules/graph/components/ForceGraph.tsx | 162 ++++++ .../graph/components/GraphContextMenu.tsx | 87 +++ .../graph/components/GraphControls.tsx | 149 +++++ .../graph/components/GraphStatusBar.tsx | 24 + .../src/modules/graph/components/index.ts | 4 + frontend/src/modules/graph/index.ts | 3 + .../src/modules/graph/store/useGraphStore.ts | 113 ++++ frontend/src/modules/graph/types.ts | 27 + frontend/src/pages/graphs.page.tsx | 54 ++ frontend/src/pages/test2.page.tsx | 534 ------------------ 14 files changed, 757 insertions(+), 537 deletions(-) create mode 100644 frontend/src/modules/graph/Graph.tsx create mode 100644 frontend/src/modules/graph/components/ForceGraph.tsx create mode 100644 frontend/src/modules/graph/components/GraphContextMenu.tsx create mode 100644 frontend/src/modules/graph/components/GraphControls.tsx create mode 100644 frontend/src/modules/graph/components/GraphStatusBar.tsx create mode 100644 frontend/src/modules/graph/components/index.ts create mode 100644 frontend/src/modules/graph/index.ts create mode 100644 frontend/src/modules/graph/store/useGraphStore.ts create mode 100644 frontend/src/modules/graph/types.ts create mode 100644 frontend/src/pages/graphs.page.tsx delete mode 100644 frontend/src/pages/test2.page.tsx diff --git a/frontend/.qwen/settings.json b/frontend/.qwen/settings.json index 627a679..e8c96d2 100644 --- a/frontend/.qwen/settings.json +++ b/frontend/.qwen/settings.json @@ -7,7 +7,8 @@ "Bash(type *)", "Bash(dir)", "Bash(move *)", - "Bash(findstr *)" + "Bash(findstr *)", + "Bash(del *)" ] }, "$version": 3 diff --git a/frontend/src/app/providers/layout/sidebar/sidebar.tsx b/frontend/src/app/providers/layout/sidebar/sidebar.tsx index bc22ebe..5685785 100644 --- a/frontend/src/app/providers/layout/sidebar/sidebar.tsx +++ b/frontend/src/app/providers/layout/sidebar/sidebar.tsx @@ -11,6 +11,7 @@ import { FaProjectDiagram, FaTrash, } from "react-icons/fa"; +import { useNavigate } from "react-router-dom"; import { useAgentStore } from "@/app/providers/layout/store/agent.store"; import { useAuthStore } from "@/modules/auth/store/useAuthStore"; @@ -23,6 +24,7 @@ export const Sidebar: React.FC = ({ isOpen = true, onToggle, }) => { + const navigate = useNavigate(); const { agents, isLoading, error, fetchAgents, removeAgent } = useAgentStore(); const { token } = useAuthStore(); @@ -350,6 +352,7 @@ export const Sidebar: React.FC = ({ > {/* Кнопка Графы */} + + + )} + {menu.link && ( + <> +
+ Связь:{" "} + {typeof menu.link.source === "string" + ? menu.link.source + : (menu.link.source as any).name || (menu.link.source as any).id}{" "} + →{" "} + {typeof menu.link.target === "string" + ? menu.link.target + : (menu.link.target as any).name || (menu.link.target as any).id} +
+ + + )} + + ); +}; diff --git a/frontend/src/modules/graph/components/GraphControls.tsx b/frontend/src/modules/graph/components/GraphControls.tsx new file mode 100644 index 0000000..5dd52cd --- /dev/null +++ b/frontend/src/modules/graph/components/GraphControls.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { + FiDownload, + FiZoomIn, + FiZoomOut, + FiMove, + FiPlus, + FiLink, +} from "react-icons/fi"; +import { useGraphStore } from "../store/useGraphStore"; +import type { GraphData } from "../types"; + +interface GraphControlsProps { + fgRef: React.RefObject; + onExport?: () => void; + onDataChange?: (data: GraphData) => void; +} + +export const GraphControls: React.FC = ({ + fgRef, + onExport, + onDataChange, +}) => { + const isLinkMode = useGraphStore((s) => s.isLinkMode); + const selectedNode = useGraphStore((s) => s.selectedNode); + const data = useGraphStore((s) => s.data); + const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode); + const addNode = useGraphStore((s) => s.addNode); + const exportData = useGraphStore((s) => s.exportData); + + const handleAddNode = () => { + const newNodeName = prompt( + "Введите имя узла:", + `Node ${data.nodes.length + 1}`, + ); + if (newNodeName) { + const isService = window.confirm( + "Выберите тип: OK - Сервис, Отмена - Агент", + ); + addNode({ + id: `node-${Date.now()}`, + name: newNodeName, + type: isService ? "service" : "agent", + val: isService ? 12 : 8, + description: "Новый узел", + }); + } + }; + + const handleZoomIn = () => { + if (fgRef.current) { + const currentZoom = fgRef.current.zoom(); + fgRef.current.zoom(currentZoom * 1.2); + } + }; + + const handleZoomOut = () => { + if (fgRef.current) { + const currentZoom = fgRef.current.zoom(); + fgRef.current.zoom(currentZoom / 1.2); + } + }; + + const handleFit = () => { + if (fgRef.current) { + fgRef.current.zoomToFit(400); + } + }; + + return ( +
+
+
+ + Сервисы: {data.nodes.filter((n) => n.type === "service").length} + +
+
+ + Агенты: {data.nodes.filter((n) => n.type === "agent").length} + +
+
+
+ Связи: {data.links.length} +
+
+ +
+ {/* Режим создания связи */} + + + {/* Добавить узел */} + + + {/* Зум + */} + + + {/* Зум - */} + + + {/* Fit */} + + + {/* Экспорт */} + +
+
+ ); +}; diff --git a/frontend/src/modules/graph/components/GraphStatusBar.tsx b/frontend/src/modules/graph/components/GraphStatusBar.tsx new file mode 100644 index 0000000..e8cbc14 --- /dev/null +++ b/frontend/src/modules/graph/components/GraphStatusBar.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { FiLink } from "react-icons/fi"; +import type { GraphNode } from "../types"; + +interface GraphStatusBarProps { + isLinkMode: boolean; + selectedNode: GraphNode | null; +} + +export const GraphStatusBar: React.FC = ({ + isLinkMode, + selectedNode, +}) => { + if (!isLinkMode) return null; + + return ( +
+ Режим создания связей: кликните на два узла для соединения + {selectedNode && ( + Выбран: {selectedNode.name} + )} +
+ ); +}; diff --git a/frontend/src/modules/graph/components/index.ts b/frontend/src/modules/graph/components/index.ts new file mode 100644 index 0000000..7fd486a --- /dev/null +++ b/frontend/src/modules/graph/components/index.ts @@ -0,0 +1,4 @@ +export { ForceGraph } from "./ForceGraph"; +export { GraphControls } from "./GraphControls"; +export { GraphContextMenu } from "./GraphContextMenu"; +export { GraphStatusBar } from "./GraphStatusBar"; diff --git a/frontend/src/modules/graph/index.ts b/frontend/src/modules/graph/index.ts new file mode 100644 index 0000000..2de88fb --- /dev/null +++ b/frontend/src/modules/graph/index.ts @@ -0,0 +1,3 @@ +export { Graph } from "./Graph"; +export { useGraphStore } from "./store/useGraphStore"; +export type { GraphData, GraphNode, GraphLink } from "./types"; diff --git a/frontend/src/modules/graph/store/useGraphStore.ts b/frontend/src/modules/graph/store/useGraphStore.ts new file mode 100644 index 0000000..4207af4 --- /dev/null +++ b/frontend/src/modules/graph/store/useGraphStore.ts @@ -0,0 +1,113 @@ +import { create } from "zustand"; +import type { GraphData, GraphNode, GraphLink } from "../types"; + +interface GraphState { + data: GraphData; + highlightNodes: Set; + highlightLinks: Set; + isLinkMode: boolean; + selectedNode: GraphNode | null; + + // Действия с данными + setData: (data: GraphData) => void; + addNode: (node: GraphNode) => void; + removeNode: (nodeId: string) => void; + addLink: (link: GraphLink) => void; + removeLink: (link: GraphLink) => void; + + // Подсветка + setHighlight: (nodeIds: Set, links: Set) => void; + + // Режим связи + toggleLinkMode: () => void; + setSelectedNode: (node: GraphNode | null) => void; + createLink: (sourceId: string, targetId: string) => void; + + // Экспорт + exportData: () => void; +} + +export const useGraphStore = create((set, get) => ({ + data: { nodes: [], links: [] }, + highlightNodes: new Set(), + highlightLinks: new Set(), + isLinkMode: false, + selectedNode: null, + + setData: (data) => set({ data }), + + addNode: (node) => { + set((state) => ({ + data: { + ...state.data, + nodes: [...state.data.nodes, node], + }, + })); + }, + + removeNode: (nodeId) => { + set((state) => ({ + data: { + nodes: state.data.nodes.filter((n) => n.id !== nodeId), + links: state.data.links.filter( + (l) => l.source !== nodeId && l.target !== nodeId, + ), + }, + })); + }, + + addLink: (link) => { + set((state) => ({ + data: { + ...state.data, + links: [...state.data.links, link], + }, + })); + }, + + removeLink: (linkToRemove) => { + set((state) => ({ + data: { + ...state.data, + links: state.data.links.filter((l) => l !== linkToRemove), + }, + })); + }, + + setHighlight: (nodeIds, links) => + set({ highlightNodes: nodeIds, highlightLinks: links }), + + toggleLinkMode: () => + set((state) => ({ + isLinkMode: !state.isLinkMode, + selectedNode: null, + })), + + setSelectedNode: (node) => set({ selectedNode: node }), + + createLink: (sourceId, targetId) => { + const { data, addLink } = get(); + + const linkExists = data.links.some( + (link) => + (link.source === sourceId && link.target === targetId) || + (link.source === targetId && link.target === sourceId), + ); + + if (!linkExists) { + addLink({ source: sourceId, target: targetId, type: "custom" }); + } + }, + + exportData: () => { + const { data } = get(); + const dataStr = JSON.stringify(data, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "graph-data.json"; + link.click(); + URL.revokeObjectURL(url); + }, +})); diff --git a/frontend/src/modules/graph/types.ts b/frontend/src/modules/graph/types.ts new file mode 100644 index 0000000..2a5f950 --- /dev/null +++ b/frontend/src/modules/graph/types.ts @@ -0,0 +1,27 @@ +export interface GraphNode { + id: string; + name: string; + type: "agent" | "service"; + val?: number; + description?: string; + x?: number; + y?: number; +} + +export interface GraphLink { + source: string; + target: string; + type?: string; +} + +export interface GraphData { + nodes: GraphNode[]; + links: GraphLink[]; +} + +export interface ContextMenuState { + x: number; + y: number; + node: GraphNode | null; + link: GraphLink | null; +} diff --git a/frontend/src/pages/graphs.page.tsx b/frontend/src/pages/graphs.page.tsx new file mode 100644 index 0000000..5879abd --- /dev/null +++ b/frontend/src/pages/graphs.page.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { Graph, type GraphData, type GraphNode, type GraphLink } from "@/modules/graph"; +import { useAgentStore } from "@/app/providers/layout/store/agent.store"; + +const buildGraphFromAgents = (): GraphData => { + const agents = useAgentStore.getState().agents; + const nodes: GraphNode[] = []; + const links: GraphLink[] = []; + + agents.forEach((agent) => { + // Агент как узел + nodes.push({ + id: agent.name, + name: agent.name, + type: "agent", + val: 8, + description: `Агент: ${agent.name}`, + }); + + // Сервисы агента как узлы + связи + agent.services.forEach((service) => { + const serviceId = `${agent.name}-${service.name}`; + nodes.push({ + id: serviceId, + name: service.name, + type: "service", + val: 12, + description: `Сервис: ${service.name} (${service.status})`, + }); + + links.push({ + source: agent.name, + target: serviceId, + type: "hosts", + }); + }); + }); + + return { nodes, links }; +}; + +export const GraphsPage = () => { + const agents = useAgentStore((s) => s.agents); + + const graphData: GraphData = useMemo(() => { + return buildGraphFromAgents(); + }, [agents]); + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/test2.page.tsx b/frontend/src/pages/test2.page.tsx deleted file mode 100644 index 13bf7a9..0000000 --- a/frontend/src/pages/test2.page.tsx +++ /dev/null @@ -1,534 +0,0 @@ -import React, { useRef, useState, useEffect } from "react"; -import ForceGraph2D from "react-force-graph-2d"; -import { - FiDownload, - FiZoomIn, - FiZoomOut, - FiMove, - FiCpu, - FiServer, - FiPlus, - FiTrash2, - FiLink, - FiMinusCircle, -} from "react-icons/fi"; - -interface GraphNode { - id: string; - name: string; - type: "agent" | "service"; - val?: number; - description?: string; - x?: number; - y?: number; -} - -interface GraphLink { - source: string; - target: string; - type?: string; -} - -export interface GraphData { - nodes: GraphNode[]; - links: GraphLink[]; -} - -interface CustomGraphProps { - data: GraphData; - onExport?: () => void; - onDataChange?: (newData: GraphData) => void; -} - -export const Test2Page: React.FC = ({ - data: initialData, - onExport, - onDataChange, -}) => { - const fgRef = useRef(null); - const containerRef = useRef(null); - const [data, setData] = useState(initialData); - const [highlightNodes, setHighlightNodes] = useState>(new Set()); - const [highlightLinks, setHighlightLinks] = useState>(new Set()); - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - const [isLinkMode, setIsLinkMode] = useState(false); - const [selectedNode, setSelectedNode] = useState(null); - const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - node: GraphNode | null; - link: GraphLink | null; - } | null>(null); - - useEffect(() => { - if (initialData) setData(initialData); - }, [initialData]); - - // Отслеживаем размеры контейнера через ResizeObserver - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const updateDimensions = () => { - setDimensions({ - width: container.clientWidth, - height: container.clientHeight || window.innerHeight - 160, - }); - }; - - updateDimensions(); - - const observer = new ResizeObserver(updateDimensions); - observer.observe(container); - return () => observer.disconnect(); - }, []); - - // Функция для подсветки связанных элементов - const handleNodeHover = (node: GraphNode | null) => { - const newHighlightNodes = new Set(); - const newHighlightLinks = new Set(); - - if (node) { - newHighlightNodes.add(node.id); - data.links.forEach((link) => { - if (link.source === node.id || link.target === node.id) { - newHighlightLinks.add(link); - newHighlightNodes.add(link.source as string); - newHighlightNodes.add(link.target as string); - } - }); - } - - setHighlightNodes(newHighlightNodes); - setHighlightLinks(newHighlightLinks); - }; - - // Обработчик клика по узлу для создания связей - const handleNodeClick = (node: GraphNode) => { - if (isLinkMode) { - if (selectedNode === null) { - setSelectedNode(node); - } else if (selectedNode.id !== node.id) { - const newLink: GraphLink = { - source: selectedNode.id, - target: node.id, - type: "custom", - }; - - const linkExists = data.links.some( - (link) => - (link.source === selectedNode.id && link.target === node.id) || - (link.source === node.id && link.target === selectedNode.id), - ); - - if (!linkExists) { - const newData = { - nodes: [...data.nodes], - links: [...data.links, newLink], - }; - setData(newData); - onDataChange?.(newData); - } - setSelectedNode(null); - setIsLinkMode(false); - } else { - setSelectedNode(null); - } - } - }; - - // УДАЛЕНИЕ СВЯЗИ - const handleDeleteLink = (linkToDelete: GraphLink) => { - const filteredLinks = data.links.filter((link) => link !== linkToDelete); - const newData = { - nodes: [...data.nodes], - links: filteredLinks, - }; - setData(newData); - onDataChange?.(newData); - setContextMenu(null); - }; - - // УДАЛЕНИЕ УЗЛА - const handleDeleteNode = (nodeToDelete: GraphNode) => { - const filteredNodes = data.nodes.filter( - (node) => node.id !== nodeToDelete.id, - ); - const filteredLinks = data.links.filter( - (link) => - link.source !== nodeToDelete.id && link.target !== nodeToDelete.id, - ); - const newData = { - nodes: filteredNodes, - links: filteredLinks, - }; - setData(newData); - onDataChange?.(newData); - setContextMenu(null); - }; - - // Добавление нового узла - const handleAddNode = () => { - const newNodeName = prompt( - "Введите имя узла:", - `Node ${data.nodes.length + 1}`, - ); - if (newNodeName) { - const isService = window.confirm( - "Выберите тип: OK - Сервис, Отмена - Агент", - ); - const newNode: GraphNode = { - id: `node-${Date.now()}`, - name: newNodeName, - type: isService ? "service" : "agent", - val: isService ? 12 : 8, - description: "Новый узел", - }; - - const newData = { - nodes: [...data.nodes, newNode], - links: [...data.links], - }; - setData(newData); - onDataChange?.(newData); - } - }; - - // Открытие контекстного меню - const openContextMenu = ( - e: React.MouseEvent, - node?: GraphNode, - link?: GraphLink, - ) => { - e.preventDefault(); - e.stopPropagation(); - - if (node) { - setContextMenu({ x: e.clientX, y: e.clientY, node, link: null }); - } else if (link) { - setContextMenu({ x: e.clientX, y: e.clientY, node: null, link }); - } - }; - - // Закрыть контекстное меню - useEffect(() => { - const handleClickOutside = () => setContextMenu(null); - document.addEventListener("click", handleClickOutside); - return () => document.removeEventListener("click", handleClickOutside); - }, []); - - // Функция для определения цвета узла - const getNodeColor = (node: GraphNode) => { - if (highlightNodes.has(node.id)) return "#fbbf24"; - if (selectedNode?.id === node.id && isLinkMode) return "#f97316"; - - switch (node.type) { - case "service": - return "#3b82f6"; - case "agent": - return "#8b5cf6"; - default: - return "#6b7280"; - } - }; - - // Функция для размера узла - const getNodeSize = (node: GraphNode) => { - switch (node.type) { - case "service": - return 3; - case "agent": - return 3; - default: - return 5; - } - }; - - // Кастомный рендер узла - const renderNode = ( - node: GraphNode, - ctx: CanvasRenderingContext2D, - globalScale: number, - ) => { - const size = getNodeSize(node); - const color = getNodeColor(node); - - if (!node.x || !node.y) return; - - ctx.beginPath(); - ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); - ctx.fillStyle = color; - ctx.fill(); - - ctx.fillStyle = "#ffffff"; - ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - if (node.type === "service") { - ctx.fillText("S", node.x, node.y); - } else if (node.type === "agent") { - ctx.fillText("A", node.x, node.y); - } - - if (globalScale > 0.5) { - ctx.fillStyle = "#e5e7eb"; - ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`; - ctx.textAlign = "center"; - ctx.fillText(node.name, node.x, node.y + size + 8); - } - }; - - const handleExport = () => { - if (onExport) { - onExport(); - } else { - const dataStr = JSON.stringify(data, null, 2); - const blob = new Blob([dataStr], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "graph-data.json"; - link.click(); - URL.revokeObjectURL(url); - } - }; - - const handleZoomIn = () => { - if (fgRef.current) { - const currentZoom = fgRef.current.zoom(); - fgRef.current.zoom(currentZoom * 1.2); - } - }; - - const handleZoomOut = () => { - if (fgRef.current) { - const currentZoom = fgRef.current.zoom(); - fgRef.current.zoom(currentZoom / 1.2); - } - }; - - const handleFit = () => { - if (fgRef.current) { - fgRef.current.zoomToFit(400); - } - }; - - if (!data || data.nodes.length === 0) { - return ( -
-
-
-

Нет данных для отображения

- -
-
-
- ); - } - - return ( -
-
- { - return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`; - }} - linkLabel={(link: GraphLink) => { - // ВОЗВРАЩАЕМ СТРОКУ - const sourceName = - data.nodes.find((n) => n.id === link.source)?.name || link.source; - const targetName = - data.nodes.find((n) => n.id === link.target)?.name || link.target; - return `Связь: ${sourceName} → ${targetName}\nПКМ для удаления`; - }} - linkColor={(link: any) => { - return highlightLinks.has(link) ? "#fbbf24" : "#4b5563"; - }} - linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)} - linkDirectionalParticles={0} - onNodeClick={handleNodeClick} - onNodeRightClick={(node, event) => - openContextMenu(event as any, node, undefined) - } - onLinkRightClick={(link, event) => - openContextMenu(event as any, undefined, link) - } - onNodeHover={handleNodeHover} - cooldownTicks={50} - cooldownTime={2000} - d3AlphaDecay={0.03} - d3VelocityDecay={0.4} - warmupTicks={50} - onEngineStop={() => { - if (fgRef.current) { - setTimeout(() => { - if (fgRef.current) { - fgRef.current.zoomToFit(400); - } - }, 100); - } - }} - /> - - {contextMenu && ( -
e.stopPropagation()} - > - {contextMenu.node && ( - <> -
- {contextMenu.node.name} -
- - - - )} - {contextMenu.link && ( - <> -
- Связь:{" "} - {typeof contextMenu.link.source === "string" - ? contextMenu.link.source - : (contextMenu.link.source as any).name || - (contextMenu.link.source as any).id}{" "} - →{" "} - {typeof contextMenu.link.target === "string" - ? contextMenu.link.target - : (contextMenu.link.target as any).name || - (contextMenu.link.target as any).id} -
- - - )} -
- )} - - {isLinkMode && ( -
- Режим создания связей: кликните на два узла для - соединения - {selectedNode && ( - Выбран: {selectedNode.name} - )} -
- )} -
- -
-
-
- - - Сервисы: {data.nodes.filter((n) => n.type === "service").length} - -
-
- - - Агенты: {data.nodes.filter((n) => n.type === "agent").length} - -
-
-
- Связи: {data.links.length} -
-
-
- - - - - - - - - - - -
-
-
- ); -};