Compare commits

...

4 Commits

14 changed files with 1225 additions and 959 deletions

View File

@ -23,7 +23,8 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/material": "^6.4.7",
"@mui/icons-material": "^6.4.8"
"@mui/icons-material": "^6.4.8",
"reactflow": "^11.11.4"
},
"devDependencies": {
"@eslint/js": "^9.17.0",

View File

@ -92,7 +92,7 @@ const PrometheusChart = ({ metricName }) => {
else if (range <= 86400) step = 120;
else step = 300;
const response = await axios.get('https://192.168.2.43:3000/metrics', {
const response = await axios.get('http://192.168.2.39:3000/metrics', {
params: { metric: metricName, start, end, step },
});

View File

@ -23,7 +23,7 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
}}
>
<List>
<h2 style={{ padding: "16px", fontWeight: "bold" }}>Меню</h2>
<h2 style={{ padding: "16px", fontWeight: "bold", }}>Меню</h2>
<MenuItem item={data} onSelectItem={handleSelectItem} />
</List>

View File

@ -1,5 +1,5 @@
import React from "react";
import { Drawer, List, ListItem, ListItemIcon, ListItemText, Collapse } from "@mui/material";
import { ListItem, ListItemIcon, ListItemText, Collapse, List } from "@mui/material";
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
// Функция для сбора всех потомков
@ -14,7 +14,7 @@ const getAllChildren = (node) => {
return children;
};
const MenuItem = ({ item, onSelectItem }) => {
const MenuItem = ({ item, onSelectItem, level = 0 }) => { // Добавлен параметр level для отслеживания уровня вложенности
const [isOpen, setIsOpen] = React.useState(false);
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
@ -30,20 +30,67 @@ const MenuItem = ({ item, onSelectItem }) => {
return (
<>
<ListItem component="div" onClick={handleToggle}>
<ListItem
component="div"
onClick={hasChildren ? handleToggle : handleOpenTab}
sx={{
cursor: "pointer", // Курсор pointer везде
pl: 2 + level * 2, // Сдвиг в зависимости от уровня вложенности
"&:hover": {
backgroundColor: "#f5f5f5", // Подсветка при наведении на весь элемент
},
}}
>
<ListItemIcon>
<div onClick={handleOpenTab} style={{ cursor: "pointer" }}>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</div>
{hasChildren ? (
<div
onClick={handleOpenTab}
style={{
cursor: "pointer",
borderRadius: "4px", // Скругление углов
padding: "4px", // Отступы для увеличения области hover
"&:hover": {
backgroundColor: "#e0e0e0", // Подсветка при наведении на иконку
// transform: 2,
},
}}
>
{isOpen ? <FolderOpen /> : <Folder />}
</div>
) : (
<div
onClick={handleOpenTab}
style={{
cursor: "pointer",
borderRadius: "4px", // Скругление углов
padding: "4px", // Отступы для увеличения области hover
"&:hover": {
backgroundColor: "#e0e0e0", // Подсветка при наведении на иконку
},
}}
>
{/* Здесь можно добавить другую иконку или оставить пустым */}
</div>
)}
</ListItemIcon>
<ListItemText primary={item.title} />
<ListItemText
primary={item.title}
sx={{ cursor: "pointer" }} // Курсор pointer для текста
/>
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
</ListItem>
{hasChildren && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.items.map((child, index) => (
<MenuItem key={index} item={child} onSelectItem={onSelectItem} />
<MenuItem
key={index}
item={child}
onSelectItem={onSelectItem}
level={level + 1} // Увеличиваем уровень вложенности
/>
))}
</List>
</Collapse>

View File

@ -0,0 +1,177 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import ReactFlow, { Controls, Background, useNodesState, useEdgesState } from 'reactflow';
import 'reactflow/dist/style.css';
import { statusManager1, getStatusColor } from './dataUtils';
const FlowChart = ({ data }) => {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [collapsedNodes, setCollapsedNodes] = useState(new Set()); // Состояние для свернутых узлов
const [nodePositions, setNodePositions] = useState({}); // Состояние для сохранения позиций узлов
// Обновление статусов
useEffect(() => {
const updateStatuses = (data) => {
statusManager1.updateStatuses(data);
};
updateStatuses(data); // Обновляем статусы только при изменении данных
}, [data]); // Зависимость от data
// Функция для построения радиального графа
const parseData = useCallback(
(data) => {
const nodes = [];
const edges = [];
const centerX = 500; // Центр графа по X
const centerY = 400; // Центр графа по Y
const levelRadius = 150; // Расстояние между уровнями
// Основная рекурсивная функция
const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI) => {
const nodeId = item.id;
// Угол узла на круге
const angle = (angleStart + angleEnd) / 2; // Угол для текущего узла
const nodeX = centerX + Math.cos(angle) * level * levelRadius;
const nodeY = centerY + Math.sin(angle) * level * levelRadius;
// Используем сохраненную позицию, если она есть
const savedPosition = nodePositions[nodeId];
const position = savedPosition || { x: nodeX, y: nodeY };
const node = {
id: nodeId,
position,
style: {
width: 50,
height: 50,
borderRadius: '50%', // Делаем круги
backgroundColor: getStatusColor(item.status),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
border: '2px solid #fff',
},
data: { label: item.title },
};
nodes.push(node);
// Создаем ребро к родителю
if (parentId) {
edges.push({ id: `${parentId}-${nodeId}`, source: parentId, target: nodeId });
}
// Дочерние узлы (если узел не свернут)
if (item.items && item.items.length > 0 && !collapsedNodes.has(nodeId)) {
const angleStep = (angleEnd - angleStart) / item.items.length; // Шаг угла для дочерних узлов
item.items.forEach((child, index) => {
// Равномерно распределяем дочерние узлы вокруг родителя
traverse(
child,
nodeId,
level + 1,
angleStart + index * angleStep, // Начальный угол для дочернего узла
angleStart + (index + 1) * angleStep // Конечный угол для дочернего узла
);
});
}
};
// Начинаем с центрального узла
const centerNode = {
id: data.id,
position: { x: centerX, y: centerY },
style: {
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: getStatusColor(data.status),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
border: '2px solid #fff',
},
data: { label: data.title },
};
nodes.push(centerNode);
// Обрабатываем дочерние узлы центрального узла
if (data.items && data.items.length > 0 && !collapsedNodes.has(data.id)) {
const angleStep = (2 * Math.PI) / data.items.length; // Равномерно распределяем вокруг центра
data.items.forEach((child, index) => {
traverse(
child,
data.id,
1, // Уровень 1 (первый круг вокруг центра)
index * angleStep, // Начальный угол
(index + 1) * angleStep // Конечный угол
);
});
}
return { nodes, edges };
},
[collapsedNodes, nodePositions] // Зависимость от collapsedNodes и nodePositions
);
// Обработчик клика по узлу
const onNodeClick = useCallback(
(event, node) => {
const nodeId = node.id;
const newCollapsedNodes = new Set(collapsedNodes);
if (newCollapsedNodes.has(nodeId)) {
newCollapsedNodes.delete(nodeId); // Разворачиваем узел
} else {
newCollapsedNodes.add(nodeId); // Сворачиваем узел
}
setCollapsedNodes(newCollapsedNodes); // Обновляем состояние
},
[collapsedNodes]
);
// Обработчик завершения перетаскивания узла
const onNodeDragStop = useCallback(
(event, node) => {
// Сохраняем новую позицию узла
setNodePositions((prevPositions) => ({
...prevPositions,
[node.id]: node.position,
}));
},
[]
);
// Обновляем узлы и ребра при изменении данных
useEffect(() => {
const { nodes: initialNodes, edges: initialEdges } = parseData(data);
setNodes(initialNodes);
setEdges(initialEdges);
}, [data, parseData, setNodes, setEdges]);
return (
<div style={{ height: '100vh', width: '100%' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick} // Добавляем обработчик клика
onNodeDragStop={onNodeDragStop} // Добавляем обработчик завершения перетаскивания
fitView
>
<Background />
<Controls />
</ReactFlow>
</div>
);
};
export default React.memo(FlowChart); // Оптимизация рендера

View File

@ -1,110 +1,55 @@
import React, { useRef, useEffect, useMemo } from "react";
import React, { useRef, useEffect, useMemo, useState } from "react";
import * as d3 from "d3";
import "../../Style/TreeChart.css";
import { calculateNodePositions } from "./TreeChartComponents/NodePosition";
import { getStatusColor } from "./dataUtils";
const TreeChart = ({ data, onNodeClick }) => {
const TreeChart = ({ data }) => {
const chartRef = useRef();
const nodePositions = useRef(new Map());
const [treeData, setTreeData] = useState(data);
// Пересчитываем позиции узлов при изменении данных
const { root, nodes, links } = useMemo(() => {
if (!data || !data.items) return { root: null, nodes: [], links: [] };
const root = d3.hierarchy(data, (d) => d.items);
const maxDepth = d3.max(root.descendants(), (d) => d.depth);
// Фильтруем узлы, исключая последний уровень
const nodes = root.descendants().filter((d) => d.depth < maxDepth);
// Фильтруем связи
const links = nodes.filter((d) => d.parent).map((d) => ({
source: d.parent,
target: d,
}));
// Размещаем узлы иерархически
const center = { x: 0, y: 0 }; // Центральная точка
const baseRadius = 150; // Базовый радиус для 1-го уровня
const branchOffset = 80; // Смещение узлов вдоль ветки
const angleOffset = Math.PI / 4; // Угол смещения для дочерних ветвей
const spreadFactor = 1.5; // Коэффициент растяжения для последних узлов
nodes.forEach((node) => {
const prev = nodePositions.current.get(node.data.id);
if (prev) {
node.x = prev.x;
node.y = prev.y;
} else {
if (node.depth === 0) {
// Центральный узел
node.x = center.x;
node.y = center.y;
} else if (node.depth === 1) {
// Первый уровень - равномерно по окружности
const parent = node.parent;
const index = parent.children.indexOf(node);
const totalSiblings = parent.children.length;
const radius = baseRadius * node.depth;
const sectorAngle = (Math.PI * 2) / totalSiblings;
const angle = index * sectorAngle;
node.x = parent.x + radius * Math.cos(angle);
node.y = parent.y + radius * Math.sin(angle);
node.angle = angle; // Запоминаем угол для веток
} else {
// Второй уровень и дальше - ветка растет в направлении родителя
const parent = node.parent;
const siblings = parent.children || [];
const index = siblings.indexOf(node);
const totalSiblings = siblings.length;
const direction = parent.angle || 0;
const offsetAngle = ((index - (totalSiblings - 1) / 2) * angleOffset) / totalSiblings;
let distance = branchOffset;
if (!node.children || node.children.length === 0) {
// Если это последний узел, увеличиваем расстояние
distance *= spreadFactor + node.depth * 0.2; // Чем глубже, тем больше разброс
}
node.x = parent.x + distance * Math.cos(direction + offsetAngle);
node.y = parent.y + distance * Math.sin(direction + offsetAngle);
node.angle = direction + offsetAngle;
}
}
nodePositions.current.set(node.data.id, { x: node.x, y: node.y });
});
return { root, nodes, links };
}, [data]);
return calculateNodePositions(treeData, nodePositions.current);
}, [treeData]);
useEffect(() => {
if (!chartRef.current) return;
// Удаляем старый граф перед отрисовкой нового
d3.select(chartRef.current).selectAll("*").remove();
// Определяем границы графа
const xMin = d3.min(nodes, (d) => d.x) || -500;
const xMax = d3.max(nodes, (d) => d.x) || 500;
const yMin = d3.min(nodes, (d) => d.y) || -500;
const yMax = d3.max(nodes, (d) => d.y) || 500;
const width = xMax - xMin + 200;
const height = yMax - yMin + 200;
const svg = d3.select(chartRef.current)
.attr("width", 2000)
.attr("height", 2000)
.attr("viewBox", [-500, -500, 1500, 1500])
.attr("style", "max-width: 100%; height: auto;");
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", `${xMin - 100} ${yMin - 100} ${width} ${height}`)
.attr("style", "max-width: 100%; height: auto;")
.call(d3.zoom()
.scaleExtent([0.5, 5])
.on("zoom", (event) => {
svg.select("g").attr("transform", event.transform);
})
);
svg.append("g").attr("class", "links");
svg.append("g").attr("class", "nodes");
svg.append("g").attr("class", "labels");
const g = svg.append("g");
// Очищаем предыдущие элементы
svg.selectAll(".links line").remove();
svg.selectAll(".nodes circle").remove();
svg.selectAll(".labels text").remove();
g.append("g").attr("class", "links");
g.append("g").attr("class", "nodes");
g.append("g").attr("class", "labels");
// Рисуем связи
const linkGroup = svg.select(".links");
const link = linkGroup
g.select(".links")
.selectAll("line")
.data(links, (d) => `${d.source.data.id}-${d.target.data.id}`)
.data(links)
.join("line")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
@ -114,40 +59,33 @@ const TreeChart = ({ data, onNodeClick }) => {
.attr("y2", (d) => d.target.y);
// Рисуем узлы
const nodeGroup = svg.select(".nodes");
const node = nodeGroup
g.select(".nodes")
.selectAll("circle")
.data(nodes, (d) => d.data.id)
.data(nodes)
.join("circle")
.attr("fill", (d) => getStatusColor(d.data.status))
.attr("stroke", "#fff")
.attr("r", 7)
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.on("click", (event, d) => toggleNode(d))
.call(drag());
node.on("click", (event, d) => {
if (onNodeClick) {
onNodeClick(d.data.id, d.data.title);
}
});
// Рисуем текстовые метки
const labelGroup = svg.select(".labels");
const text = labelGroup
g.select(".labels")
.selectAll("text")
.data(nodes, (d) => d.data.id)
.data(nodes)
.join("text")
.text((d) => (nodes.length > 50 ? "" : d.data.title)) // Скрываем текст, если узлов много
.text((d) => (nodes.length > 50 ? "" : d.data.title))
.attr("dx", 12)
.attr("dy", 4)
.style("user-select", "none") // Запрет выделения текста
.style("pointer-events", "none") // Запрет взаимодействия с текстом
.style("fill", "var(--TreeChart-text-color)") // Используем переменную для цвета текста
.style("user-select", "none")
.style("pointer-events", "none")
.style("fill", "var(--TreeChart-text-color)")
.attr("x", (d) => d.x + 12)
.attr("y", (d) => d.y + 4);
}, [root, links, nodes, onNodeClick]);
}, [root, links, nodes]);
const drag = () => {
function dragstarted(event, d) {
@ -159,13 +97,11 @@ const TreeChart = ({ data, onNodeClick }) => {
d.y = event.y;
d3.select(this).attr("cx", d.x).attr("cy", d.y);
// Обновляем текстовую метку
d3.select(this.parentNode)
.select("text")
.attr("x", d.x + 12)
.attr("y", d.y + 4);
// Обновляем связи
d3.select(chartRef.current)
.selectAll(".links line")
.filter((link) => link.source === d || link.target === d)
@ -183,6 +119,20 @@ const TreeChart = ({ data, onNodeClick }) => {
return d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended);
};
const toggleNode = (d) => {
d.data.collapsed = !d.data.collapsed;
if (d.data.collapsed) {
d._children = d.data.children;
d.data.children = [];
} else {
d.data.children = d._children;
d._children = null;
}
setTreeData({ ...treeData });
};
return <svg ref={chartRef} />;
};

View File

@ -0,0 +1,21 @@
import React from "react";
const Label = ({ node }) => {
return (
<text
x={node.x + 12}
y={node.y + 4}
dx={12}
dy={4}
style={{
userSelect: "none",
pointerEvents: "none",
fill: "var(--TreeChart-text-color)",
}}
>
{node.data.title}
</text>
);
};
export default Label;

View File

@ -0,0 +1,16 @@
import React from "react";
const Link = ({ link }) => {
return (
<line
x1={link.source.x}
y1={link.source.y}
x2={link.target.x}
y2={link.target.y}
stroke="#999"
strokeOpacity={0.6}
/>
);
};
export default Link;

View File

@ -0,0 +1,19 @@
import React from "react";
import * as d3 from "d3";
import { getStatusColor } from "../dataUtils";
const Node = ({ node, onNodeClick, drag }) => {
return (
<circle
cx={node.x}
cy={node.y}
r={7}
fill={getStatusColor(node.data.status)}
stroke="#fff"
onMouseDown={(event) => drag(event, node)}
onClick={() => onNodeClick(node.data.id, node.data.title)}
/>
);
};
export default Node;

View File

@ -0,0 +1,30 @@
import * as d3 from "d3";
export const calculateNodePositions = (data, nodePositions) => {
if (!data || !data.items) return { root: null, nodes: [], links: [] };
const root = d3.hierarchy(data, (d) => d.items);
const treeLayout = d3.tree().size([4000 * Math.PI, 300]); // Угловое распределение (радиан, радиус)
treeLayout(root); // Заполняем координаты
const nodes = root.descendants();
const links = nodes
.filter((d) => d.parent)
.map((d) => ({
source: d.parent,
target: d,
}));
// Преобразуем полярные координаты в декартовые
nodes.forEach((node) => {
const angle = node.x; // x теперь угол (радианы)
const radius = node.y; // y теперь радиус
nodePositions.set(node.data.id, {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
});
});
return { root, nodes, links };
};

File diff suppressed because it is too large Load Diff

View File

@ -222,9 +222,13 @@ const TreeTable = ({ data }) => {
<tr className="tree-table-row">{renderData(filteredData)}</tr>
</tbody>
</table>
<button onClick={() => setIsLogVisible(!isLogVisible)} className="toggle-log-button">
{isLogVisible ? "Скрыть лог" : "Показать лог"}
</button>
<button
onClick={() => setIsLogVisible(!isLogVisible)}
className="toggle-log-button"
style={{ marginTop: "10px" }}
>
{isLogVisible ? "Скрыть лог" : "Показать лог"}
</button>
{isLogVisible && (
<div className="status-log">
<h3>Лог статусов</h3>

View File

@ -1,6 +1,7 @@
import SystemStatusChart from "../../Charts/SystemStatusChart";
import TreeTable from "../UI/TreeTable";
import TreeChart from "../TreeChart/TreeChart";
import FlowChart from "../TreeChart/FlowChart";
const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleOpenTab }) => {
if (activeTab === "Главная") {
@ -22,7 +23,7 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO
</div>
);
} else if (activeTab === "Визуализация") {
return <TreeChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
return <FlowChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
} else {
const tabData = tabContent[activeTab];
return tabData ? tabData.content : <p>Нет данных</p>;

View File

@ -12,7 +12,7 @@
--modal-background: #2d2d2d;
--modal--btn-background: #333333;
--modal-text: #FFFFFF;
--table-border: #444444;
--table-border: #c70a0a;
--table-header-background: #2d2d2d;
--table-cell-background: #333333;
--table-text-color: #E0E0E0;