Compare commits

..

2 Commits

Author SHA1 Message Date
DmitriyA fc1db66288 redesign of graphs and visualizations
test-org/trust-module-frontend/pipeline/pr-rc There was a failure building this commit Details
2025-03-26 05:16:52 -04:00
DmitriyA c077449b2c redesign of graphs and visualizations 2025-03-26 05:16:43 -04:00
7 changed files with 268 additions and 127 deletions

View File

@ -1,17 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Line, ResponsiveContainer } from 'recharts'; import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer } from 'recharts';
const LineChartComponent = ({ chartData, metricName, colors, description, onRangeSelect, filteredData }) => { const LineChartComponent = ({ chartData, metricName, colors, description, onRangeSelect, filteredData }) => {
const [selectionStart, setSelectionStart] = useState(null); const [selectionStart, setSelectionStart] = useState(null);
const [selectionEnd, setSelectionEnd] = useState(null); const [selectionEnd, setSelectionEnd] = useState(null);
// Создаем массив уникальных временных меток
const allTimes = Object.values(chartData) const allTimes = Object.values(chartData)
.flat() .flat()
.map(point => point.time) .map(point => point.time)
.filter((time, index, self) => self.indexOf(time) === index); .filter((time, index, self) => self.indexOf(time) === index);
// Формируем данные для графика
const data = allTimes.map(time => { const data = allTimes.map(time => {
const point = { time }; const point = { time };
Object.keys(chartData).forEach(key => { Object.keys(chartData).forEach(key => {
@ -21,10 +19,8 @@ const LineChartComponent = ({ chartData, metricName, colors, description, onRang
return point; return point;
}); });
// Используем отфильтрованные данные, если они есть
const displayData = filteredData || data; const displayData = filteredData || data;
// Обработчик клика на графике
const handleClick = (e) => { const handleClick = (e) => {
if (!e || !e.activeLabel) return; if (!e || !e.activeLabel) return;
@ -45,40 +41,57 @@ const LineChartComponent = ({ chartData, metricName, colors, description, onRang
} }
}; };
// Кастомный Tooltip для отображения значения // Упрощенный Tooltip без указания instance
const CustomTooltip = ({ active, payload, label }) => { const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
return ( return (
<div className="custom-tooltip" style={{ padding: '10px' }}> <div className="custom-tooltip" style={{
<p>{`Время: ${label}`}</p> backgroundColor: '#fff',
{payload.map((entry, index) => ( padding: '10px',
<p key={index} style={{}}> border: '1px solid #ccc',
{`Значение: ${entry.value}`} borderRadius: '4px'
</p> }}>
))} <p style={{ fontWeight: 'bold', marginBottom: '5px' }}>{`Время: ${label}`}</p>
<p>{`Значение: ${payload[0].value}`}</p>
</div> </div>
); );
} }
return null; return null;
}; };
return ( return (
<div> <div>
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
<LineChart data={displayData} onClick={handleClick}> <LineChart
<CartesianGrid strokeDasharray="3 3" /> data={displayData}
<XAxis dataKey="time" /> onClick={handleClick}
<YAxis /> margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
<Tooltip content={<CustomTooltip />} /> >
<Legend /> <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="time"
tick={{ fill: '#666' }}
tickMargin={10}
/>
<YAxis
tick={{ fill: '#666' }}
tickMargin={10}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ stroke: '#ccc', strokeWidth: 1 }}
/>
{/* Убрали <Legend /> чтобы скрыть имена instance */}
{Object.keys(chartData).map((key, index) => ( {Object.keys(chartData).map((key, index) => (
<Line <Line
key={key} key={key}
type="monotone" type="monotone"
dataKey={key} dataKey={key}
stroke={colors[index % colors.length]} stroke={colors[index % colors.length]}
name={key} strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}
// Убрали name чтобы не отображалось в tooltip
/> />
))} ))}
</LineChart> </LineChart>

View File

@ -125,7 +125,7 @@ const PrometheusChart = ({ metricName }) => {
? date.toLocaleString([], { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) ? date.toLocaleString([], { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const key = `${m.instance}-${m.device || m.scrape_job}`; const key = m.instance;
if (!updatedData[key]) updatedData[key] = {}; if (!updatedData[key]) updatedData[key] = {};
updatedData[key][formattedTime] = m.value; updatedData[key][formattedTime] = m.value;
}); });
@ -217,41 +217,143 @@ const PrometheusChart = ({ metricName }) => {
}); });
return ( return (
<div> <div style={{
<div> backgroundColor: '#fff',
<label htmlFor="time-range">Выберите временной диапазон: </label> borderRadius: '8px',
<select id="time-range" value={selectedRange.value} onChange={handleRangeChange}> padding: '20px',
{TIME_RANGES.map(range => ( marginBottom: '20px'
<option key={range.value} value={range.value}>{range.label}</option> }}>
))} {/* Заголовок графика */}
</select> <h3 style={{ marginTop: 0, color: '#333' }}>
</div> </h3>
<div>
<label>Или выберите другой диапазон: </label> {/* Группа элементов управления */}
<div> <div style={{
<label>Начальная дата: </label> display: 'flex',
<DatePicker flexWrap: 'wrap',
selected={startDate} gap: '15px',
onChange={(date) => setStartDate(date)} alignItems: 'center',
showTimeSelect marginBottom: '15px'
timeFormat="HH:mm" }}>
timeIntervals={15} {/* Стандартные диапазоны */}
dateFormat="yyyy-MM-dd HH:mm" <div style={{ flex: '1 1 200px' }}>
/> <label htmlFor="time-range" style={{
display: 'block',
marginBottom: '5px',
fontWeight: '500',
color: '#555'
}}>Стандартные диапазоны:</label>
<select
id="time-range"
value={selectedRange.value}
onChange={handleRangeChange}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '4px',
border: '1px solid #ddd',
color: "#333",
backgroundColor: '#f9f9f9'
}}
>
{TIME_RANGES.map(range => (
<option key={range.value} value={range.value}>{range.label}</option>
))}
</select>
</div> </div>
<div>
<label>Конечная дата: </label> {/* Кастомный диапазон */}
<DatePicker <div style={{ flex: '1 1 300px' }}>
selected={endDate} <div style={{
onChange={(date) => setEndDate(date)} marginBottom: '10px',
showTimeSelect fontWeight: '500',
timeFormat="HH:mm" color: '#555'
timeIntervals={15} }}>
dateFormat="yyyy-MM-dd HH:mm" Или укажите свой диапазон:
/> </div>
<div style={{
display: 'flex',
gap: '10px',
flexWrap: 'wrap'
}}>
<div style={{ flex: '1 1 200px' }}>
<DatePicker
selected={startDate}
onChange={(date) => setStartDate(date)}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="yyyy-MM-dd HH:mm"
placeholderText="Начальная дата"
customInput={
<input style={{
backgroundColor: '#f9f9f9',
color: "#555",
width: '100%',
padding: '8px 12px',
borderRadius: '4px',
border: '1px solid #ddd'
}} />
}
/>
</div>
<div style={{ flex: '1 1 200px' }}>
<DatePicker
selected={endDate}
onChange={(date) => setEndDate(date)}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="yyyy-MM-dd HH:mm"
placeholderText="Конечная дата"
customInput={
<input style={{
backgroundColor: '#f9f9f9',
color: "#555",
width: '100%',
padding: '8px 12px',
borderRadius: '4px',
border: '1px solid #ddd'
}} />
}
/>
</div>
<button
onClick={handleCustomRangeChange}
style={{
padding: '8px 16px',
backgroundColor: '#4a6baf',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
transition: 'background-color 0.2s',
flex: '0 0 auto',
alignSelf: 'flex-end'
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#3a5a9f'}
onMouseOut={(e) => e.target.style.backgroundColor = '#4a6baf'}
>
Применить
</button>
</div>
</div> </div>
<button onClick={handleCustomRangeChange}>Использовать кастомный диапазон</button>
</div> </div>
{/* Индикатор текущего диапазона */}
<div style={{
margin: '10px 0',
padding: '8px 12px',
backgroundColor: '#f0f7ff',
borderRadius: '4px',
borderLeft: '3px solid #4a6baf'
}}>
Текущий диапазон: {useCustomRange
? `${startDate.toLocaleString()} - ${endDate.toLocaleString()}`
: selectedRange.label}
</div>
{/* График */}
<LineChartComponent <LineChartComponent
chartData={chartData} chartData={chartData}
metricName={metricName} metricName={metricName}

View File

@ -8,7 +8,7 @@ import { useDataParser } from './FlowChartComponents/DataParser';
import NodeWrapper from './FlowChartComponents/NodeWrapper'; import NodeWrapper from './FlowChartComponents/NodeWrapper';
const nodeTypes = { const nodeTypes = {
customNode: NodeWrapper // Должно совпадать с type в useDataParser customNode: NodeWrapper
}; };
const FlowChart = ({ data }) => { const FlowChart = ({ data }) => {
@ -40,11 +40,17 @@ const FlowChart = ({ data }) => {
setNodes(initialNodes); setNodes(initialNodes);
setEdges(initialEdges); setEdges(initialEdges);
// Автоматически сворачиваем узлы, которые являются родителями последнего уровня
if (!initialized.current && data) { if (!initialized.current && data) {
const findAndCollapseLastLevelParents = (items) => { const findAndCollapseLastLevelParents = (items) => {
items.forEach(item => { items.forEach(item => {
if (item.items?.length > 0) { if (item.items && item.items.length > 0) {
const hasGrandchildren = item.items.some(child => child.items?.length > 0); // Проверяем, есть ли у детей свои дети
const hasGrandchildren = item.items.some(child =>
child.items && child.items.length > 0
);
// Если у детей нет своих детей - это родители последнего уровня
if (!hasGrandchildren) { if (!hasGrandchildren) {
toggleNodeCollapse(item.id); toggleNodeCollapse(item.id);
} else { } else {
@ -53,6 +59,7 @@ const FlowChart = ({ data }) => {
} }
}); });
}; };
findAndCollapseLastLevelParents(data.items || []); findAndCollapseLastLevelParents(data.items || []);
initialized.current = true; initialized.current = true;
} }
@ -65,7 +72,9 @@ const FlowChart = ({ data }) => {
}; };
useEffect(() => { useEffect(() => {
return () => debouncedSetNodePositions.cancel(); return () => {
debouncedSetNodePositions.cancel();
};
}, [debouncedSetNodePositions]); }, [debouncedSetNodePositions]);
return ( return (

View File

@ -1,7 +1,34 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { isLeafNode } from './nodeUtils'; import { isLeafNode } from './nodeUtils';
import { getStatusColor } from '../dataUtils';
export const useDataParser = (nodePositions, collapsedNodes) => { export const useDataParser = (nodePositions, collapsedNodes) => {
const getNodeStyle = useCallback((item, isLeaf) => ({
width: isLeaf ? 60 : 70,
height: isLeaf ? 60 : 70,
borderRadius: '50%',
backgroundColor: getStatusColor(item.status),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'black',
border: '2px solid #fff',
fontSize: isLeaf ? '0.8rem' : '1rem'
}), []);
const getCenterNodeStyle = useCallback((item) => ({
width: 80,
height: 80,
borderRadius: '50%',
backgroundColor: getStatusColor(item.status),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'black',
border: '2px solid #fff',
fontSize: '1.2rem'
}), []);
const parseData = useCallback((data) => { const parseData = useCallback((data) => {
if (!data) return { nodes: [], edges: [] }; if (!data) return { nodes: [], edges: [] };
@ -12,7 +39,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
const baseLevelRadius = 150; const baseLevelRadius = 150;
const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI, parentRadius = 0) => { const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI, parentRadius = 0) => {
if (!item || collapsedNodes[parentId]) return; if (!item || collapsedNodes[parentId]) return; // Пропускаем свёрнутые узлы
const nodeId = item.id; const nodeId = item.id;
const items = item.items || []; const items = item.items || [];
@ -26,15 +53,14 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
const node = { const node = {
id: nodeId, id: nodeId,
type: 'customNode',
position, position,
type: 'customNode', // Важно для кастомного рендеринга
data: { data: {
...item,
label: item.title, label: item.title,
status: item.status, style: getNodeStyle(item, isLeaf), // Переносим стили в data
hasChildren: items.length > 0, hasChildren: items.length > 0,
collapsed: collapsedNodes[nodeId], collapsed: collapsedNodes[nodeId]
isLeaf,
isCenterNode: parentId === null // Центральный узел
} }
}; };
@ -45,16 +71,14 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
id: `${parentId}-${nodeId}`, id: `${parentId}-${nodeId}`,
source: parentId, source: parentId,
target: nodeId, target: nodeId,
style: { style: { stroke: isLeaf ? '#aaa' : '#666', strokeWidth: isLeaf ? 1 : 2 }
stroke: isLeaf ? '#aaa' : '#666',
strokeWidth: isLeaf ? 1 : 2
}
}); });
} }
if (!collapsedNodes[nodeId] && items.length > 0) { if (!collapsedNodes[nodeId] && items.length > 0) {
const spreadAngle = angleEnd - angleStart; const spreadAngle = angleEnd - angleStart;
items.forEach((child, index) => { items.forEach((child, index) => {
if (!child) return;
const itemAngleStart = angleStart + (index / items.length) * spreadAngle; const itemAngleStart = angleStart + (index / items.length) * spreadAngle;
const itemAngleEnd = angleStart + ((index + 1) / items.length) * spreadAngle; const itemAngleEnd = angleStart + ((index + 1) / items.length) * spreadAngle;
traverse(child, nodeId, level + 1, itemAngleStart, itemAngleEnd, parentRadius + baseLevelRadius); traverse(child, nodeId, level + 1, itemAngleStart, itemAngleEnd, parentRadius + baseLevelRadius);
@ -62,33 +86,27 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
} }
}; };
// Центральный узел
const centerNode = { const centerNode = {
id: data.id, id: data.id,
type: 'customNode', // Добавляем тип узла
position: nodePositions[data.id] || { x: centerX, y: centerY }, position: nodePositions[data.id] || { x: centerX, y: centerY },
type: 'customNode', style: getCenterNodeStyle(data),
data: { data: { label: data.title, hasChildren: data.items.length > 0, collapsed: collapsedNodes[data.id] }
label: data.title,
status: data.status,
hasChildren: data.items.length > 0,
collapsed: collapsedNodes[data.id],
isLeaf: false,
isCenterNode: true
}
}; };
nodes.push(centerNode); nodes.push(centerNode);
// Обработка дочерних узлов
if (!collapsedNodes[data.id] && data.items.length > 0) { if (!collapsedNodes[data.id] && data.items.length > 0) {
const angleStep = (2 * Math.PI) / data.items.length; const angleStep = (2 * Math.PI) / data.items.length;
data.items.forEach((child, index) => { data.items.forEach((child, index) => {
if (!child) return;
traverse(child, data.id, 1, index * angleStep, (index + 1) * angleStep, 0); traverse(child, data.id, 1, index * angleStep, (index + 1) * angleStep, 0);
}); });
} }
return { nodes, edges }; return { nodes, edges };
}, [nodePositions, collapsedNodes]); }, [nodePositions, collapsedNodes, getNodeStyle, getCenterNodeStyle]);
return { parseData }; return { parseData };
}; };

View File

@ -1,63 +1,63 @@
import React from 'react'; import React, { memo } from 'react';
import { getStatusColor } from '../dataUtils'; import { Handle } from 'reactflow';
const NodeWrapper = ({ id, data, selected }) => {
// Параметры стиля
const size = data.isCenterNode ? 80 : (data.isLeaf ? 60 : 70);
const fontSize = data.isCenterNode ? '1.2rem' : (data.isLeaf ? '0.8rem' : '1rem');
const backgroundColor = getStatusColor(data.status);
// Базовый стиль узла
const nodeStyle = {
width: size,
height: size,
borderRadius: '50%',
backgroundColor,
border: `2px solid ${selected ? '#1890ff' : '#fff'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize,
color: '#000',
position: 'relative',
boxShadow: selected ? '0 0 8px rgba(24, 144, 255, 0.5)' : 'none',
transition: 'all 0.2s ease'
};
const NodeWrapper = memo(({ id, data, selected }) => {
return ( return (
<div style={nodeStyle}> <div
style={{
...data.style,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden', // Чтобы текст не выходил за границы
textOverflow: 'ellipsis', // Добавляем многоточие если текст не помещается
whiteSpace: 'nowrap', // Запрещаем перенос строк
padding: '0 8px', // Горизонтальный padding для текста
boxSizing: 'border-box' // Учитываем padding в общей ширине
}}
title={data.label} // Простой tooltip при наведении
>
{/* Хендл для входящих соединений */}
<Handle
type="target"
position="top"
style={{ visibility: 'hidden' }}
/>
{/* Обёртка для текста с ограничением ширины */}
<div style={{ <div style={{
padding: '0 8px', maxWidth: '100%',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis'
whiteSpace: 'nowrap'
}}> }}>
{data.label} {data.label}
</div> </div>
{data.hasChildren && ( {data.hasChildren && (
<div style={{ <span style={{
position: 'absolute', position: 'absolute',
top: -8, top: 5,
right: -8, right: 5,
width: 20, fontSize: '12px',
height: 20,
borderRadius: '50%',
background: '#fff',
border: '1px solid #ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
fontWeight: 'bold',
cursor: 'pointer', cursor: 'pointer',
zIndex: 10 background: '#fff',
padding: '2px 5px',
borderRadius: '3px',
border: '1px solid #aaa'
}}> }}>
{data.collapsed ? '+' : '-'} {data.collapsed ? '+' : '-'}
</div> </span>
)} )}
{/* Хендл для исходящих соединений */}
<Handle
type="source"
position="bottom"
style={{ visibility: 'hidden' }}
/>
</div> </div>
); );
}; });
export default React.memo(NodeWrapper); export default NodeWrapper;

View File

@ -17,7 +17,6 @@ const LoginModal = ({ onLogin, onClose }) => {
try { try {
// Отправляем данные на бэкенд // Отправляем данные на бэкенд
console.log("Отправляем данные:", { username, password });
const response = await fetch('http://192.168.2.39:3000/auth/login', { const response = await fetch('http://192.168.2.39:3000/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: {

View File