feat: graph 2
ci-front / build (push) Successful in 3m39s

This commit is contained in:
nikita
2026-04-05 09:19:39 +03:00
parent c175461634
commit 915aa7018a
6 changed files with 258 additions and 112 deletions
@@ -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<SidebarProps> = ({
);
}, [agents, searchQuery]);
const graphData: GraphData = useMemo(() => {
const nodes: any[] = [];
const links: any[] = [];
const [graphData, setGraphData] = useState<GraphData>({
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<string, "up" | "down">();
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<SidebarProps> = ({
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 (
<div
key={service}
@@ -380,25 +436,31 @@ export const Sidebar: React.FC<SidebarProps> = ({
>
<span
className="text-xs"
style={{ color: "var(--text-secondary)" }}
style={{
color: isDown
? "#ef4444"
: "var(--text-secondary)",
}}
>
{service}
{serviceName}
</span>
{/* Status indicator */}
<div className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{
backgroundColor: "#4ade80",
backgroundColor: isDown
? "#ef4444"
: "#4ade80",
}}
/>
<span
className="text-[10px] font-medium"
style={{
color: "#4ade80",
color: isDown ? "#ef4444" : "#4ade80",
}}
>
run
{isDown ? "down" : "run"}
</span>
</div>
</div>
@@ -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<GraphApiResponse> {
const response = await apiClient.get<GraphApiResponse>("/graph");
return response.data;
}
}
export const agentApiService = new AgentApiService();
@@ -90,6 +90,19 @@ export const ForceGraph = forwardRef<any, ForceGraphProps>(
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";
+23
View File
@@ -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<string, GraphServiceNode>;
}
export interface GraphApiResponse {
nodes: Record<string, GraphAgentNode>;
}
@@ -392,55 +392,6 @@ export const AgentDashboardPage = () => {
</BarChart>
</ResponsiveContainer>
</div>
{/* Disk History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
Disk Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={diskHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
stroke="var(--text-secondary)"
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--text-secondary)"
fontSize={12}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
fill="#f59e0b"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
+121 -30
View File
@@ -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<string, "up" | "down">();
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<GraphData>({
nodes: [],
links: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<FaSpinner
className="animate-spin mx-auto mb-4"
size={32}
style={{ color: "var(--accent)" }}
/>
<p style={{ color: "var(--text-secondary)" }}>Загрузка графа...</p>
</div>
</div>
);
}
if (error && graphData.nodes.length === 0) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p style={{ color: "var(--error-text)", marginBottom: "12px" }}>
{error}
</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Graph initialData={graphData} />