@@ -10,10 +10,11 @@ import {
|
|||||||
FaChevronDown,
|
FaChevronDown,
|
||||||
FaProjectDiagram,
|
FaProjectDiagram,
|
||||||
FaTrash,
|
FaTrash,
|
||||||
|
FaArrowLeft,
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
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";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
@@ -24,13 +25,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
isOpen = true,
|
isOpen = true,
|
||||||
onToggle,
|
onToggle,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { agents, isLoading, error, fetchAgents, removeAgent } =
|
const { agents, isLoading, error, fetchAgents, removeAgent } =
|
||||||
useAgentStore();
|
useAgentStore();
|
||||||
const { token } = useAuthStore();
|
const { token } = useAuthStore();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||||
|
const [showGraphs, setShowGraphs] = useState(false);
|
||||||
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
|
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
|
||||||
new Set(agents.map((a) => a.name)),
|
new Set(agents.map((a) => a.name)),
|
||||||
);
|
);
|
||||||
@@ -54,6 +55,40 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
);
|
);
|
||||||
}, [agents, searchQuery]);
|
}, [agents, searchQuery]);
|
||||||
|
|
||||||
|
const graphData: GraphData = useMemo(() => {
|
||||||
|
const nodes = [];
|
||||||
|
const links = [];
|
||||||
|
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
nodes.push({
|
||||||
|
id: agent.name,
|
||||||
|
name: agent.name,
|
||||||
|
type: "agent" as const,
|
||||||
|
val: 8,
|
||||||
|
description: `Агент: ${agent.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.services.forEach((service) => {
|
||||||
|
const serviceId = `${agent.name}-${service.name}`;
|
||||||
|
nodes.push({
|
||||||
|
id: serviceId,
|
||||||
|
name: service.name,
|
||||||
|
type: "service" as const,
|
||||||
|
val: 12,
|
||||||
|
description: `Сервис: ${service.name} (${service.status})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
source: agent.name,
|
||||||
|
target: serviceId,
|
||||||
|
type: "hosts",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes, links };
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
const handleCopyToken = () => {
|
const handleCopyToken = () => {
|
||||||
if (token) {
|
if (token) {
|
||||||
navigator.clipboard.writeText(token);
|
navigator.clipboard.writeText(token);
|
||||||
@@ -87,10 +122,12 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={`fixed md:relative w-72 h-screen z-50 transition-transform duration-300 ease-in-out flex flex-col ${
|
className={`fixed md:relative z-50 transition-all duration-300 ease-in-out flex flex-col ${
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
|
width: showGraphs ? "500px" : "288px",
|
||||||
|
height: "100vh",
|
||||||
backgroundColor: "var(--card-bg)",
|
backgroundColor: "var(--card-bg)",
|
||||||
borderRight: "1px solid var(--border)",
|
borderRight: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
@@ -127,6 +164,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Контент — либо список агентов, либо графы */}
|
||||||
|
{showGraphs ? (
|
||||||
|
<div className="flex-1 overflow-hidden relative">
|
||||||
|
<Graph initialData={graphData} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Поиск */}
|
{/* Поиск */}
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<input
|
<input
|
||||||
@@ -159,7 +203,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
className="animate-spin mb-3"
|
className="animate-spin mb-3"
|
||||||
style={{ color: "var(--accent)", fontSize: "20px" }}
|
style={{ color: "var(--accent)", fontSize: "20px" }}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
Загрузка агентов...
|
Загрузка агентов...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,8 +273,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
{/* Статус-индикатор агента (сколько сервисов запущено) */}
|
{/* Статус-индикатор агента (сколько сервисов запущено) */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{agent.services.filter((s) => s.status === "running")
|
{agent.services.filter(
|
||||||
.length > 0 && (
|
(s) => s.status === "running",
|
||||||
|
).length > 0 && (
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-full"
|
className="w-2 h-2 rounded-full"
|
||||||
style={{ backgroundColor: "#4ade80" }}
|
style={{ backgroundColor: "#4ade80" }}
|
||||||
@@ -238,8 +286,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
agent.services.filter((s) => s.status === "running")
|
agent.services.filter(
|
||||||
.length
|
(s) => s.status === "running",
|
||||||
|
).length
|
||||||
}
|
}
|
||||||
/{agent.services.length}
|
/{agent.services.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -249,7 +298,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (
|
if (
|
||||||
window.confirm(`Удалить агента "${agent.name}"?`)
|
window.confirm(
|
||||||
|
`Удалить агента "${agent.name}"?`,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
removeAgent(agent.name);
|
removeAgent(agent.name);
|
||||||
}
|
}
|
||||||
@@ -265,7 +316,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.color = "var(--text-muted)";
|
e.currentTarget.style.color = "var(--text-muted)";
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"transparent";
|
||||||
}}
|
}}
|
||||||
title="Удалить агента"
|
title="Удалить агента"
|
||||||
>
|
>
|
||||||
@@ -341,6 +393,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer с кнопками */}
|
{/* Footer с кнопками */}
|
||||||
<div
|
<div
|
||||||
@@ -350,9 +404,29 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
backgroundColor: "var(--card-bg)",
|
backgroundColor: "var(--card-bg)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Кнопка Графы */}
|
{showGraphs ? (
|
||||||
|
/* Кнопка назад к агентам */
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/graphs")}
|
onClick={() => setShowGraphs(false)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowLeft size={10} />К агентам
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
/* Кнопка Графы */
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGraphs(true)}
|
||||||
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-secondary)",
|
backgroundColor: "var(--bg-secondary)",
|
||||||
@@ -369,6 +443,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<FaProjectDiagram size={10} />
|
<FaProjectDiagram size={10} />
|
||||||
Графы
|
Графы
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTokenModal(true)}
|
onClick={() => setShowTokenModal(true)}
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
|
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GraphControls,
|
GraphControls,
|
||||||
GraphContextMenu,
|
GraphContextMenu,
|
||||||
GraphStatusBar,
|
GraphStatusBar,
|
||||||
|
GraphStats,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
|
||||||
interface GraphProps {
|
interface GraphProps {
|
||||||
@@ -25,7 +26,6 @@ export const Graph: React.FC<GraphProps> = ({
|
|||||||
onDataChange,
|
onDataChange,
|
||||||
}) => {
|
}) => {
|
||||||
const fgRef = useRef<any>(null);
|
const fgRef = useRef<any>(null);
|
||||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
|
||||||
const data = useGraphStore((s) => s.data);
|
const data = useGraphStore((s) => s.data);
|
||||||
@@ -38,25 +38,6 @@ export const Graph: React.FC<GraphProps> = ({
|
|||||||
if (initialData) setData(initialData);
|
if (initialData) setData(initialData);
|
||||||
}, [initialData, setData]);
|
}, [initialData, setData]);
|
||||||
|
|
||||||
// Отслеживаем размеры контейнера
|
|
||||||
useEffect(() => {
|
|
||||||
const container = fgRef.current?.parentElement;
|
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Закрыть контекстное меню по клику вне
|
// Закрыть контекстное меню по клику вне
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = () => setContextMenu(null);
|
const handleClickOutside = () => setContextMenu(null);
|
||||||
@@ -89,16 +70,14 @@ export const Graph: React.FC<GraphProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
<div className="bg-gray-900 p-4 h-full flex flex-col">
|
||||||
<div
|
{/* Статистика сверху */}
|
||||||
className="border border-gray-800 rounded-lg overflow-hidden relative"
|
<GraphStats data={data} />
|
||||||
style={{
|
|
||||||
height: "calc(100vh - 200px)",
|
{/* Граф */}
|
||||||
position: "relative",
|
<div className="flex-1 border border-gray-800 rounded-lg overflow-hidden relative mt-2">
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ForceGraph
|
<ForceGraph
|
||||||
|
ref={fgRef}
|
||||||
data={data}
|
data={data}
|
||||||
onNodeRightClick={handleNodeRightClick}
|
onNodeRightClick={handleNodeRightClick}
|
||||||
onLinkRightClick={handleLinkRightClick}
|
onLinkRightClick={handleLinkRightClick}
|
||||||
@@ -113,6 +92,7 @@ export const Graph: React.FC<GraphProps> = ({
|
|||||||
<GraphStatusBar isLinkMode={isLinkMode} selectedNode={selectedNode} />
|
<GraphStatusBar isLinkMode={isLinkMode} selectedNode={selectedNode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки снизу */}
|
||||||
<GraphControls
|
<GraphControls
|
||||||
fgRef={fgRef}
|
fgRef={fgRef}
|
||||||
onExport={onExport}
|
onExport={onExport}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import React, { useRef, useEffect, useCallback } from "react";
|
import React, {
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
import ForceGraph2D from "react-force-graph-2d";
|
import ForceGraph2D from "react-force-graph-2d";
|
||||||
import type { GraphData, GraphNode, GraphLink } from "../types";
|
import type { GraphData, GraphNode, GraphLink } from "../types";
|
||||||
import { useGraphStore } from "../store/useGraphStore";
|
import { useGraphStore } from "../store/useGraphStore";
|
||||||
@@ -9,19 +15,35 @@ interface ForceGraphProps {
|
|||||||
onLinkRightClick: (link: GraphLink, event: MouseEvent) => void;
|
onLinkRightClick: (link: GraphLink, event: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ForceGraph: React.FC<ForceGraphProps> = ({
|
export const ForceGraph = forwardRef<any, ForceGraphProps>(
|
||||||
data,
|
({ data, onNodeRightClick, onLinkRightClick }, ref) => {
|
||||||
onNodeRightClick,
|
|
||||||
onLinkRightClick,
|
|
||||||
}) => {
|
|
||||||
const fgRef = useRef<any>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 480, height: 600 });
|
||||||
|
|
||||||
const highlightNodes = useGraphStore((s) => s.highlightNodes);
|
const highlightNodes = useGraphStore((s) => s.highlightNodes);
|
||||||
const highlightLinks = useGraphStore((s) => s.highlightLinks);
|
const highlightLinks = useGraphStore((s) => s.highlightLinks);
|
||||||
const selectedNode = useGraphStore((s) => s.selectedNode);
|
const selectedNode = useGraphStore((s) => s.selectedNode);
|
||||||
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
||||||
|
|
||||||
|
// ResizeObserver для корректного отслеживания размеров
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
setDimensions({
|
||||||
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleNodeClick = useCallback((node: GraphNode) => {
|
const handleNodeClick = useCallback((node: GraphNode) => {
|
||||||
const store = useGraphStore.getState();
|
const store = useGraphStore.getState();
|
||||||
if (store.isLinkMode) {
|
if (store.isLinkMode) {
|
||||||
@@ -52,7 +74,9 @@ export const ForceGraph: React.FC<ForceGraphProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useGraphStore.getState().setHighlight(newHighlightNodes, newHighlightLinks);
|
useGraphStore
|
||||||
|
.getState()
|
||||||
|
.setHighlight(newHighlightNodes, newHighlightLinks);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNodeColor = (node: GraphNode) => {
|
const getNodeColor = (node: GraphNode) => {
|
||||||
@@ -116,21 +140,21 @@ export const ForceGraph: React.FC<ForceGraphProps> = ({
|
|||||||
|
|
||||||
// Fit zoom on engine stop
|
// Fit zoom on engine stop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fgRef.current) {
|
if (ref && typeof ref === "object" && ref.current) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fgRef.current?.zoomToFit(400);
|
ref.current?.zoomToFit(400);
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data, ref]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="w-full h-full relative">
|
<div ref={containerRef} className="w-full h-full relative">
|
||||||
<ForceGraph2D
|
<ForceGraph2D
|
||||||
ref={fgRef}
|
ref={ref}
|
||||||
graphData={data}
|
graphData={data}
|
||||||
width={containerRef.current?.clientWidth}
|
width={dimensions.width}
|
||||||
height={containerRef.current?.clientHeight || 600}
|
height={dimensions.height}
|
||||||
nodeCanvasObject={renderNode}
|
nodeCanvasObject={renderNode}
|
||||||
nodeLabel={(node: GraphNode) => {
|
nodeLabel={(node: GraphNode) => {
|
||||||
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
|
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
|
||||||
@@ -159,4 +183,7 @@ export const ForceGraph: React.FC<ForceGraphProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ForceGraph.displayName = "ForceGraph";
|
||||||
|
|||||||
@@ -68,29 +68,11 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mt-4 text-sm text-gray-400">
|
<div className="flex items-center justify-end gap-2 mt-2">
|
||||||
<div className="flex gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
Агенты: {data.nodes.filter((n) => n.type === "agent").length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 bg-gray-500 rounded-sm"></div>
|
|
||||||
<span>Связи: {data.links.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{/* Режим создания связи */}
|
{/* Режим создания связи */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleLinkMode}
|
onClick={toggleLinkMode}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
className={`flex w-full items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||||
isLinkMode
|
isLinkMode
|
||||||
? "bg-green-600 hover:bg-green-700 text-white"
|
? "bg-green-600 hover:bg-green-700 text-white"
|
||||||
: "bg-gray-800 hover:bg-gray-700 text-gray-300"
|
: "bg-gray-800 hover:bg-gray-700 text-gray-300"
|
||||||
@@ -102,15 +84,6 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Добавить узел */}
|
|
||||||
<button
|
|
||||||
onClick={handleAddNode}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
|
||||||
>
|
|
||||||
<FiPlus />
|
|
||||||
<span className="text-sm">Узел</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Зум + */}
|
{/* Зум + */}
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomIn}
|
onClick={handleZoomIn}
|
||||||
@@ -144,6 +117,5 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
|
|||||||
<span className="text-sm">Экспорт</span>
|
<span className="text-sm">Экспорт</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { GraphData } from "../types";
|
||||||
|
|
||||||
|
interface GraphStatsProps {
|
||||||
|
data: GraphData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphStats: React.FC<GraphStatsProps> = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 text-xs text-gray-400">
|
||||||
|
<span>
|
||||||
|
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Агенты: {data.nodes.filter((n) => n.type === "agent").length}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2 h-2 bg-gray-500 rounded-sm"></div>
|
||||||
|
<span>Связи: {data.links.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,3 +2,4 @@ export { ForceGraph } from "./ForceGraph";
|
|||||||
export { GraphControls } from "./GraphControls";
|
export { GraphControls } from "./GraphControls";
|
||||||
export { GraphContextMenu } from "./GraphContextMenu";
|
export { GraphContextMenu } from "./GraphContextMenu";
|
||||||
export { GraphStatusBar } from "./GraphStatusBar";
|
export { GraphStatusBar } from "./GraphStatusBar";
|
||||||
|
export { GraphStats } from "./GraphStats";
|
||||||
|
|||||||
Reference in New Issue
Block a user