From 915aa7018a14755b6b11a363fbeb7e7cc3515e38 Mon Sep 17 00:00:00 2001 From: nikita Date: Sun, 5 Apr 2026 09:19:39 +0300 Subject: [PATCH] feat: graph 2 --- .../app/providers/layout/sidebar/sidebar.tsx | 128 +++++++++++---- .../modules/agent/api/agent.api.service.ts | 6 + .../modules/graph/components/ForceGraph.tsx | 13 ++ frontend/src/modules/graph/types.ts | 23 +++ frontend/src/pages/agent-dashboard.page.tsx | 49 ------ frontend/src/pages/graphs.page.tsx | 151 ++++++++++++++---- 6 files changed, 258 insertions(+), 112 deletions(-) diff --git a/frontend/src/app/providers/layout/sidebar/sidebar.tsx b/frontend/src/app/providers/layout/sidebar/sidebar.tsx index 6064324..e945d28 100644 --- a/frontend/src/app/providers/layout/sidebar/sidebar.tsx +++ b/frontend/src/app/providers/layout/sidebar/sidebar.tsx @@ -16,6 +16,7 @@ import { useNavigate } from "react-router-dom"; import { useAgentStore } from "@/app/providers/layout/store/agent.store"; import { useAuthStore } from "@/modules/auth/store/useAuthStore"; import { Graph, type GraphData } from "@/modules/graph"; +import { agentApiService } from "@/modules/agent/api/agent.api.service"; import { adminApi } from "@/modules/admin/api/admin.api"; interface SidebarProps { @@ -82,38 +83,88 @@ export const Sidebar: React.FC = ({ ); }, [agents, searchQuery]); - const graphData: GraphData = useMemo(() => { - const nodes: any[] = []; - const links: any[] = []; + const [graphData, setGraphData] = useState({ + nodes: [], + links: [], + }); - agents.forEach((agent) => { - nodes.push({ - id: agent.label, - name: agent.label, - type: "agent" as const, - val: 8, - description: `Агент: ${agent.label}`, - }); + useEffect(() => { + const fetchGraph = () => { + agentApiService + .getGraph() + .then((apiData) => { + const nodes: any[] = []; + const links: any[] = []; - agent.services.forEach((service) => { - const serviceId = `${agent.label}-${service}`; - nodes.push({ - id: serviceId, - name: service, - type: "service" as const, - val: 12, - description: `Сервис: ${service}`, + // Build a map of service statuses from agents + const serviceStatusMap = new Map(); + agents.forEach((agent) => { + const services = agent.services || []; + services.forEach((svc: string) => { + const parts = svc.split(":"); + const svcName = parts[0]; + const status = parts[1] === "down" ? "down" : "up"; + serviceStatusMap.set(`${agent.label}-${svcName}`, status); + }); + }); + + Object.entries(apiData.nodes || {}).forEach( + ([agentLabel, agentNode]: [string, any]) => { + nodes.push({ + id: agentLabel, + name: agentLabel, + type: "agent" as const, + val: 8, + description: `Агент: ${agentLabel}`, + }); + + const services = agentNode?.services || {}; + Object.entries(services).forEach( + ([serviceName, serviceNode]: [string, any]) => { + const serviceId = `${agentLabel}-${serviceName}`; + const status = serviceStatusMap.get(serviceId) || "up"; + + nodes.push({ + id: serviceId, + name: serviceName, + type: "service" as const, + val: 12, + description: `Сервис: ${serviceName}`, + status, + }); + + links.push({ + source: agentLabel, + target: serviceId, + type: "hosts", + }); + + const dependencies = serviceNode?.dependencies || []; + dependencies.forEach((dep: any) => { + const targetName = dep?.target?.name; + if (targetName) { + links.push({ + source: serviceId, + target: `${agentLabel}-${targetName}`, + type: dep.condition || "dependency", + }); + } + }); + }, + ); + }, + ); + + setGraphData({ nodes, links }); + }) + .catch((e) => { + console.error("Failed to fetch graph:", e); }); + }; - links.push({ - source: agent.label, - target: serviceId, - type: "hosts", - }); - }); - }); - - return { nodes, links }; + fetchGraph(); + const interval = setInterval(fetchGraph, 30000); + return () => clearInterval(interval); }, [agents]); const handleCopyToken = () => { @@ -373,6 +424,11 @@ export const Sidebar: React.FC = ({ style={{ borderColor: "var(--border)" }} > {agent.services.map((service) => { + // Parse "serviceName:up" or "serviceName:down" + const parts = service.split(":"); + const serviceName = parts[0]; + const isDown = parts[1] === "down"; + return (
= ({ > - {service} + {serviceName} {/* Status indicator */}
- run + {isDown ? "down" : "run"}
diff --git a/frontend/src/modules/agent/api/agent.api.service.ts b/frontend/src/modules/agent/api/agent.api.service.ts index 1a18464..296d95a 100644 --- a/frontend/src/modules/agent/api/agent.api.service.ts +++ b/frontend/src/modules/agent/api/agent.api.service.ts @@ -15,6 +15,7 @@ import type { DeployResponse, SystemMetrics, } from "../types/agent.types"; +import type { GraphApiResponse } from "@/modules/graph/types"; class AgentApiService { private readonly basePath = "/agents"; @@ -170,6 +171,11 @@ class AgentApiService { ); return Array.isArray(response.data) ? response.data : []; } + + async getGraph(): Promise { + const response = await apiClient.get("/graph"); + return response.data; + } } export const agentApiService = new AgentApiService(); diff --git a/frontend/src/modules/graph/components/ForceGraph.tsx b/frontend/src/modules/graph/components/ForceGraph.tsx index 49d2681..a47a0d9 100644 --- a/frontend/src/modules/graph/components/ForceGraph.tsx +++ b/frontend/src/modules/graph/components/ForceGraph.tsx @@ -90,6 +90,19 @@ export const ForceGraph = forwardRef( if (highlightNodes.has(node.id)) return "#fbbf24"; if (selectedNode?.id === node.id && isLinkMode) return "#f97316"; + if (node.type === "service" && node.status === "down") return "#ef4444"; + + if (node.type === "agent") { + // Проверяем, есть ли у агента хотя бы один упавший сервис + const hasDownService = data.nodes.some( + (n) => + n.type === "service" && + n.status === "down" && + n.id.startsWith(`${node.id}-`), + ); + if (hasDownService) return "#ef4444"; + } + switch (node.type) { case "service": return "#3b82f6"; diff --git a/frontend/src/modules/graph/types.ts b/frontend/src/modules/graph/types.ts index 2a5f950..0410b75 100644 --- a/frontend/src/modules/graph/types.ts +++ b/frontend/src/modules/graph/types.ts @@ -6,6 +6,7 @@ export interface GraphNode { description?: string; x?: number; y?: number; + status?: "up" | "down"; } export interface GraphLink { @@ -25,3 +26,25 @@ export interface ContextMenuState { node: GraphNode | null; link: GraphLink | null; } + +// API response types for GET /graph +export interface GraphDependencyTarget { + name: string; +} + +export interface GraphDependency { + condition: string; + target: GraphDependencyTarget; +} + +export interface GraphServiceNode { + dependencies: GraphDependency[]; +} + +export interface GraphAgentNode { + services: Record; +} + +export interface GraphApiResponse { + nodes: Record; +} diff --git a/frontend/src/pages/agent-dashboard.page.tsx b/frontend/src/pages/agent-dashboard.page.tsx index d7b00d6..f7e4019 100644 --- a/frontend/src/pages/agent-dashboard.page.tsx +++ b/frontend/src/pages/agent-dashboard.page.tsx @@ -392,55 +392,6 @@ export const AgentDashboardPage = () => { - - {/* Disk History */} -
-

- Disk Usage History (%) -

- - - - - - - `${v.toFixed(0)}%`, - }} - /> - - -
); diff --git a/frontend/src/pages/graphs.page.tsx b/frontend/src/pages/graphs.page.tsx index eca7466..59c7320 100644 --- a/frontend/src/pages/graphs.page.tsx +++ b/frontend/src/pages/graphs.page.tsx @@ -1,56 +1,147 @@ -import { useMemo } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Graph, type GraphData, type GraphNode, type GraphLink, } from "@/modules/graph"; +import { agentApiService } from "@/modules/agent/api/agent.api.service"; +import { FaSpinner } from "react-icons/fa"; + import { useAgentStore } from "@/app/providers/layout/store/agent.store"; -const buildGraphFromAgents = (): GraphData => { - const agents = useAgentStore.getState().agents; +const buildGraphFromApi = (apiData: any, agents: any[]): GraphData => { const nodes: GraphNode[] = []; const links: GraphLink[] = []; + // Build a map of service statuses from agents + const serviceStatusMap = new Map(); agents.forEach((agent) => { - // Агент как узел - nodes.push({ - id: agent.label, - name: agent.label, - type: "agent", - val: 8, - description: `Агент: ${agent.label}`, - }); - - // Сервисы агента как узлы + связи - agent.services.forEach((service) => { - const serviceId = `${agent.label}-${service}`; - nodes.push({ - id: serviceId, - name: service, - type: "service", - val: 12, - description: `Сервис: ${service}`, - }); - - links.push({ - source: agent.label, - target: serviceId, - type: "hosts", - }); + const services = agent.services || []; + services.forEach((svc: string) => { + // Parse "serviceName:up" or "serviceName:down" + const parts = svc.split(":"); + const svcName = parts[0]; + const status = parts[1] === "down" ? "down" : "up"; + serviceStatusMap.set(`${agent.label}-${svcName}`, status); }); }); + if (!apiData?.nodes) return { nodes, links }; + + Object.entries(apiData.nodes).forEach( + ([agentLabel, agentNode]: [string, any]) => { + // Агент как узел + nodes.push({ + id: agentLabel, + name: agentLabel, + type: "agent", + val: 8, + description: `Агент: ${agentLabel}`, + }); + + // Сервисы агента + const services = agentNode?.services || {}; + Object.entries(services).forEach( + ([serviceName, serviceNode]: [string, any]) => { + const serviceId = `${agentLabel}-${serviceName}`; + const status = serviceStatusMap.get(serviceId) || "up"; + + nodes.push({ + id: serviceId, + name: serviceName, + type: "service", + val: 12, + description: `Сервис: ${serviceName}`, + status, + }); + + // Связь агент → сервис + links.push({ + source: agentLabel, + target: serviceId, + type: "hosts", + }); + + // Зависимости между сервисами + const dependencies = serviceNode?.dependencies || []; + dependencies.forEach((dep: any) => { + const targetServiceName = dep?.target?.name; + if (targetServiceName) { + const targetServiceId = `${agentLabel}-${targetServiceName}`; + links.push({ + source: serviceId, + target: targetServiceId, + type: dep.condition || "dependency", + }); + } + }); + }, + ); + }, + ); + return { nodes, links }; }; export const GraphsPage = () => { const agents = useAgentStore((s) => s.agents); + const [graphData, setGraphData] = useState({ + nodes: [], + links: [], + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const graphData: GraphData = useMemo(() => { - return buildGraphFromAgents(); + useEffect(() => { + const fetchGraph = async () => { + setLoading(true); + setError(null); + try { + const apiData = await agentApiService.getGraph(); + const data = buildGraphFromApi(apiData, agents); + setGraphData(data); + } catch (e) { + console.error("Failed to fetch graph:", e); + setError(e instanceof Error ? e.message : "Failed to load graph"); + setGraphData({ nodes: [], links: [] }); + } finally { + setLoading(false); + } + }; + + fetchGraph(); + const interval = setInterval(fetchGraph, 30000); + return () => clearInterval(interval); }, [agents]); + if (loading) { + return ( +
+
+ +

Загрузка графа...

+
+
+ ); + } + + if (error && graphData.nodes.length === 0) { + return ( +
+
+

+ {error} +

+
+
+ ); + } + return (