@@ -16,6 +16,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
import { Graph, type GraphData } from "@/modules/graph";
|
import { Graph, type GraphData } from "@/modules/graph";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
import { adminApi } from "@/modules/admin/api/admin.api";
|
import { adminApi } from "@/modules/admin/api/admin.api";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -82,38 +83,88 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
);
|
);
|
||||||
}, [agents, searchQuery]);
|
}, [agents, searchQuery]);
|
||||||
|
|
||||||
const graphData: GraphData = useMemo(() => {
|
const [graphData, setGraphData] = useState<GraphData>({
|
||||||
|
nodes: [],
|
||||||
|
links: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchGraph = () => {
|
||||||
|
agentApiService
|
||||||
|
.getGraph()
|
||||||
|
.then((apiData) => {
|
||||||
const nodes: any[] = [];
|
const nodes: any[] = [];
|
||||||
const links: any[] = [];
|
const links: any[] = [];
|
||||||
|
|
||||||
|
// Build a map of service statuses from agents
|
||||||
|
const serviceStatusMap = new Map<string, "up" | "down">();
|
||||||
agents.forEach((agent) => {
|
agents.forEach((agent) => {
|
||||||
nodes.push({
|
const services = agent.services || [];
|
||||||
id: agent.label,
|
services.forEach((svc: string) => {
|
||||||
name: agent.label,
|
const parts = svc.split(":");
|
||||||
type: "agent" as const,
|
const svcName = parts[0];
|
||||||
val: 8,
|
const status = parts[1] === "down" ? "down" : "up";
|
||||||
description: `Агент: ${agent.label}`,
|
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
agent.services.forEach((service) => {
|
Object.entries(apiData.nodes || {}).forEach(
|
||||||
const serviceId = `${agent.label}-${service}`;
|
([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({
|
nodes.push({
|
||||||
id: serviceId,
|
id: serviceId,
|
||||||
name: service,
|
name: serviceName,
|
||||||
type: "service" as const,
|
type: "service" as const,
|
||||||
val: 12,
|
val: 12,
|
||||||
description: `Сервис: ${service}`,
|
description: `Сервис: ${serviceName}`,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
|
|
||||||
links.push({
|
links.push({
|
||||||
source: agent.label,
|
source: agentLabel,
|
||||||
target: serviceId,
|
target: serviceId,
|
||||||
type: "hosts",
|
type: "hosts",
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return { nodes, links };
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchGraph();
|
||||||
|
const interval = setInterval(fetchGraph, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
const handleCopyToken = () => {
|
const handleCopyToken = () => {
|
||||||
@@ -373,6 +424,11 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
style={{ borderColor: "var(--border)" }}
|
style={{ borderColor: "var(--border)" }}
|
||||||
>
|
>
|
||||||
{agent.services.map((service) => {
|
{agent.services.map((service) => {
|
||||||
|
// Parse "serviceName:up" or "serviceName:down"
|
||||||
|
const parts = service.split(":");
|
||||||
|
const serviceName = parts[0];
|
||||||
|
const isDown = parts[1] === "down";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={service}
|
key={service}
|
||||||
@@ -380,25 +436,31 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
style={{ color: "var(--text-secondary)" }}
|
style={{
|
||||||
|
color: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{service}
|
{serviceName}
|
||||||
</span>
|
</span>
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#4ade80",
|
backgroundColor: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "#4ade80",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-medium"
|
className="text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
color: "#4ade80",
|
color: isDown ? "#ef4444" : "#4ade80",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
run
|
{isDown ? "down" : "run"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
DeployResponse,
|
DeployResponse,
|
||||||
SystemMetrics,
|
SystemMetrics,
|
||||||
} from "../types/agent.types";
|
} from "../types/agent.types";
|
||||||
|
import type { GraphApiResponse } from "@/modules/graph/types";
|
||||||
|
|
||||||
class AgentApiService {
|
class AgentApiService {
|
||||||
private readonly basePath = "/agents";
|
private readonly basePath = "/agents";
|
||||||
@@ -170,6 +171,11 @@ class AgentApiService {
|
|||||||
);
|
);
|
||||||
return Array.isArray(response.data) ? response.data : [];
|
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();
|
export const agentApiService = new AgentApiService();
|
||||||
|
|||||||
@@ -90,6 +90,19 @@ export const ForceGraph = forwardRef<any, ForceGraphProps>(
|
|||||||
if (highlightNodes.has(node.id)) return "#fbbf24";
|
if (highlightNodes.has(node.id)) return "#fbbf24";
|
||||||
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
|
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) {
|
switch (node.type) {
|
||||||
case "service":
|
case "service":
|
||||||
return "#3b82f6";
|
return "#3b82f6";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface GraphNode {
|
|||||||
description?: string;
|
description?: string;
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
|
status?: "up" | "down";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphLink {
|
export interface GraphLink {
|
||||||
@@ -25,3 +26,25 @@ export interface ContextMenuState {
|
|||||||
node: GraphNode | null;
|
node: GraphNode | null;
|
||||||
link: GraphLink | 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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,56 +1,147 @@
|
|||||||
import { useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Graph,
|
Graph,
|
||||||
type GraphData,
|
type GraphData,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
type GraphLink,
|
type GraphLink,
|
||||||
} from "@/modules/graph";
|
} 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";
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
|
||||||
const buildGraphFromAgents = (): GraphData => {
|
const buildGraphFromApi = (apiData: any, agents: any[]): GraphData => {
|
||||||
const agents = useAgentStore.getState().agents;
|
|
||||||
const nodes: GraphNode[] = [];
|
const nodes: GraphNode[] = [];
|
||||||
const links: GraphLink[] = [];
|
const links: GraphLink[] = [];
|
||||||
|
|
||||||
|
// Build a map of service statuses from agents
|
||||||
|
const serviceStatusMap = new Map<string, "up" | "down">();
|
||||||
agents.forEach((agent) => {
|
agents.forEach((agent) => {
|
||||||
|
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({
|
nodes.push({
|
||||||
id: agent.label,
|
id: agentLabel,
|
||||||
name: agent.label,
|
name: agentLabel,
|
||||||
type: "agent",
|
type: "agent",
|
||||||
val: 8,
|
val: 8,
|
||||||
description: `Агент: ${agent.label}`,
|
description: `Агент: ${agentLabel}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Сервисы агента как узлы + связи
|
// Сервисы агента
|
||||||
agent.services.forEach((service) => {
|
const services = agentNode?.services || {};
|
||||||
const serviceId = `${agent.label}-${service}`;
|
Object.entries(services).forEach(
|
||||||
|
([serviceName, serviceNode]: [string, any]) => {
|
||||||
|
const serviceId = `${agentLabel}-${serviceName}`;
|
||||||
|
const status = serviceStatusMap.get(serviceId) || "up";
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: serviceId,
|
id: serviceId,
|
||||||
name: service,
|
name: serviceName,
|
||||||
type: "service",
|
type: "service",
|
||||||
val: 12,
|
val: 12,
|
||||||
description: `Сервис: ${service}`,
|
description: `Сервис: ${serviceName}`,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Связь агент → сервис
|
||||||
links.push({
|
links.push({
|
||||||
source: agent.label,
|
source: agentLabel,
|
||||||
target: serviceId,
|
target: serviceId,
|
||||||
type: "hosts",
|
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 };
|
return { nodes, links };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GraphsPage = () => {
|
export const GraphsPage = () => {
|
||||||
const agents = useAgentStore((s) => s.agents);
|
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(() => {
|
useEffect(() => {
|
||||||
return buildGraphFromAgents();
|
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]);
|
}, [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 (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<Graph initialData={graphData} />
|
<Graph initialData={graphData} />
|
||||||
|
|||||||
Reference in New Issue
Block a user