fixed data interpolation and range allocation
test-org/trust-module-frontend/pipeline/pr-redesign This commit looks good Details

pull/29/head
DmitriyA 2025-04-09 07:47:51 -04:00
parent 46da90fbb6
commit b6b3b36f5a
4 changed files with 312 additions and 170 deletions

View File

@ -11,7 +11,6 @@ const LineChartComponent = ({
const [selectionArea, setSelectionArea] = useState(null);
const [isSelecting, setIsSelecting] = useState(false);
const chartRef = useRef(null);
const containerRef = useRef(null);
const allTimestamps = Object.values(chartData)
.flat()
@ -39,9 +38,32 @@ const LineChartComponent = ({
return point;
});
const displayData = filteredData || data;
const instanceKeys = displayData.length
? Object.keys(displayData[0]).filter(k => !['timestamp', 'time', 'fullTime'].includes(k))
: [];
// Функция для определения оптимального формата времени в зависимости от диапазона
const getTimeFormat = () => {
if (!data.length) return 'HH:mm:ss';
const first = data[0].timestamp;
const last = data[data.length - 1].timestamp;
const range = last - first;
// Если диапазон больше 24 часов - показываем дату
if (range > 86400000) {
return 'dd.MM HH:mm';
}
// Если больше 1 часа - показываем часы и минуты
if (range > 3600000) {
return 'HH:mm';
}
// Для коротких диапазонов - показываем время с секундами
return 'HH:mm:ss';
};
useEffect(() => {
const handleSelectStart = (e) => {
if (isSelecting) {
@ -49,47 +71,66 @@ const LineChartComponent = ({
}
};
document.addEventListener('selectstart', handleSelectStart);
return () => document.removeEventListener('selectstart', handleSelectStart);
}, [isSelecting]);
const handleMouseDown = (e) => {
if (!e || !e.activeLabel) return;
if (!e) return;
// Получаем индекс точки по координатам
const activeIndex = e.activeTooltipIndex;
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
setIsSelecting(true);
setSelectionArea({ start: e.activeLabel, end: null }); // activeLabel это timestamp
setSelectionArea({
start: data[activeIndex].timestamp,
end: null,
startIndex: activeIndex,
endIndex: null
});
};
const handleMouseMove = (e) => {
if (!selectionArea?.start || !e?.activeLabel) return;
setSelectionArea(prev => ({ ...prev, end: e.activeLabel }));
if (!isSelecting || !selectionArea?.start || !e) return;
const activeIndex = e.activeTooltipIndex;
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
setSelectionArea(prev => ({
...prev,
end: data[activeIndex].timestamp,
endIndex: activeIndex
}));
};
const handleMouseUp = () => {
setIsSelecting(false);
if (!selectionArea?.start || !selectionArea?.end) {
if (!isSelecting || !selectionArea?.start || !selectionArea?.end) {
setIsSelecting(false);
setSelectionArea(null);
return;
}
const startIndex = data.findIndex(point => point.timestamp === selectionArea.start);
const endIndex = data.findIndex(point => point.timestamp === selectionArea.end);
const startIndex = Math.min(selectionArea.startIndex, selectionArea.endIndex);
const endIndex = Math.max(selectionArea.startIndex, selectionArea.endIndex);
if (startIndex >= 0 && endIndex >= 0) {
onRangeSelect({
startIndex: Math.min(startIndex, endIndex),
endIndex: Math.max(startIndex, endIndex)
});
}
// Нормализуем индексы к диапазону [0, 1] для родительского компонента
const normalizedStart = startIndex / (data.length - 1);
const normalizedEnd = endIndex / (data.length - 1);
onRangeSelect({
startIndex: normalizedStart,
endIndex: normalizedEnd
});
setIsSelecting(false);
setSelectionArea(null);
};
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
const currentPoint = data.find(point => point.timestamp === label);
const displayTime = currentPoint?.fullTime || new Date(label).toLocaleString();
return (
<div style={{
backgroundColor: '#fff',
@ -98,7 +139,9 @@ const LineChartComponent = ({
borderRadius: '4px',
boxShadow: '0 2px 5px rgba(0,0,0,0.1)'
}}>
<p style={{ fontWeight: 'bold', marginBottom: '5px' }}>{displayTime}</p>
<p style={{ fontWeight: 'bold', marginBottom: '5px' }}>
{currentPoint?.fullTime || new Date(label).toLocaleString('ru-RU')}
</p>
{payload.map((item, index) => (
<p key={index} style={{ color: item.color }}>
{`Значение: ${item.value}`}
@ -110,26 +153,12 @@ const LineChartComponent = ({
return null;
};
if (!data.length) {
return <div style={{ padding: '20px', textAlign: 'center' }}>Нет данных для отображения</div>;
}
return (
<div
style={{ position: 'relative', height: '400px' }}
ref={containerRef}
className={isSelecting ? 'no-selection' : ''}
>
<style>
{`
.no-selection {
user-select: none;
-webkit-user-select: none;
}
`}
</style>
<div style={{ position: 'relative', height: '400px' }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={displayData}
@ -146,23 +175,39 @@ const LineChartComponent = ({
tick={{ fontSize: 12, angle: -45, textAnchor: 'end' }}
interval={Math.max(1, Math.floor(data.length / 10))}
tickFormatter={(timestamp) => {
const point = data.find(p => p.timestamp === timestamp);
return point?.fullTime || new Date(timestamp).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const date = new Date(timestamp);
const format = getTimeFormat();
if (format === 'dd.MM HH:mm') {
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} else if (format === 'HH:mm') {
return date.toLocaleString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
});
} else {
return date.toLocaleString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
}}
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip content={<CustomTooltip />} />
{Object.keys(chartData).map((instance, index) => (
{instanceKeys.map((instance, index) => (
<Line
key={instance}
type="monotone"
dataKey={instance}
name={instance}
stroke={colors[index % colors.length]}
strokeWidth={2}
dot={false}
@ -177,7 +222,6 @@ const LineChartComponent = ({
fill="#4a6baf"
/>
)}
</LineChart>
</ResponsiveContainer>
</div>

View File

@ -17,40 +17,57 @@ const PrometheusChart = ({ metricName }) => {
const [selectedGraphRange, setSelectedGraphRange] = useState(null);
const [filteredData, setFilteredData] = useState(null);
const [isSelectingRange, setIsSelectingRange] = useState(false);
const [lastCustomRange, setLastCustomRange] = useState(null);
const intervalRef = useRef(null);
const socketRef = useRef(null);
const debounceRef = useRef(null);
const formatTime = useCallback((timestamp, rangeSeconds) => {
const date = new Date(timestamp);
const showDate = rangeSeconds > 86400; // Показывать дату если диапазон > 24 часов
const ts = typeof timestamp === 'number' ? timestamp : Date.now();
const date = new Date(ts);
// Определяем формат в зависимости от диапазона
const showFullDate = rangeSeconds > 86400; // больше суток
const timeOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
const dateOptions = showFullDate ? {
month: '2-digit',
day: '2-digit',
...timeOptions
} : timeOptions;
return {
display: date.toLocaleString([], {
month: showDate ? '2-digit' : undefined,
day: showDate ? '2-digit' : undefined,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}),
fullDisplay: date.toLocaleString([], {
display: date.toLocaleString('ru-RU', dateOptions),
fullDisplay: date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
second: '2-digit',
hour12: false
}),
timestamp: timestamp
timestamp: ts
};
}, []);
const calculateStep = useCallback((start, end) => {
const range = end - start;
if (range <= 3600) return 5;
if (range <= 21600) return 30;
if (range <= 86400) return 120;
return 300;
if (range <= 60) return 1; // 1 мин
if (range <= 300) return 5; // 5 мин
if (range <= 1800) return 15; // 30 мин
if (range <= 3600) return 30; // 1 час
if (range <= 10800) return 60; // 3 часа
if (range <= 21600) return 120; // 6 часов
if (range <= 43200) return 300; // 12 часов
if (range <= 86400) return 600; // 24 часа
return 1800; // > 24 часов
}, []);
const fetchData = useCallback(() => {
@ -73,70 +90,52 @@ const PrometheusChart = ({ metricName }) => {
}
}, [metricName, selectedRange.value, isSelectingRange]);
const groupBySecond = (points) => {
const grouped = [];
const timeMap = {};
points.forEach(point => {
const timeKey = Math.floor(point.timestamp / 1000);
if (!timeMap[timeKey]) {
timeMap[timeKey] = { ...point, count: 1 };
grouped.push(timeMap[timeKey]);
} else {
timeMap[timeKey].value = (timeMap[timeKey].value * timeMap[timeKey].count + point.value) /
(timeMap[timeKey].count + 1);
timeMap[timeKey].count += 1;
}
});
return grouped;
};
const processMetricsData = useCallback((response) => {
console.log('Processing metrics data:', response);
if (response.metric !== metricName) return;
const dataArray = Array.isArray(response.data) ? response.data : [response.data];
if (!dataArray.length) return;
setChartData(prev => {
const newData = { ...(prev || {}) };
const rangeSeconds = useCustomRange
? (endDate.getTime() - startDate.getTime()) / 1000
: selectedRange.value;
dataArray.forEach(item => {
const instance = item.instance || 'default';
if (!newData[instance]) newData[instance] = [];
const timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000;
const value = parseFloat(item.value);
const formattedTime = formatTime(timestamp, selectedRange.value);
const existingPointIndex = newData[instance].findIndex(p => p.timestamp === timestamp);
if (existingPointIndex >= 0) {
newData[instance][existingPointIndex] = {
time: formattedTime.display,
fullTime: formattedTime.fullDisplay,
value: value,
timestamp: timestamp
};
// Унифицированная конвертация timestamp
let timestamp;
if (typeof item.timestamp === 'number') {
// Определяем, в секундах или миллисекундах пришел timestamp
timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000;
} else {
newData[instance].push({
time: formattedTime.display,
fullTime: formattedTime.fullDisplay,
value: value,
timestamp: timestamp
});
timestamp = Date.now();
}
const value = parseFloat(item.value);
const formattedTime = formatTime(timestamp, rangeSeconds);
newData[instance].push({
time: formattedTime.display,
fullTime: formattedTime.fullDisplay,
value: value,
timestamp: timestamp
});
});
// Сортируем и ограничиваем данные
Object.keys(newData).forEach(instance => {
newData[instance] = newData[instance]
.sort((a, b) => a.timestamp - b.timestamp)
.slice(-1000);
});
return Object.keys(newData).length ? newData : prev;
return newData;
});
}, [metricName, selectedRange.value, formatTime]);
}, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]);
const setupWebSocket = useCallback(() => {
if (socketRef.current) {
@ -190,7 +189,7 @@ const PrometheusChart = ({ metricName }) => {
const fetchCustomRangeData = useCallback(async () => {
const start = Math.floor(startDate.getTime() / 1000);
const end = Math.floor(endDate.getTime() / 1000);
const step = calculateStep(start, end);
const rangeSeconds = end - start;
try {
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/metrics`, {
@ -198,18 +197,21 @@ const PrometheusChart = ({ metricName }) => {
metric: metricName,
start,
end,
step
step: calculateStep(start, end)
}
});
if (response.data) { // Изменили условие, так как бэкенд возвращает массив напрямую
if (response.data?.length) {
// Преобразуем данные перед передачей в processMetricsData
const processedData = response.data.map(item => ({
...item,
timestamp: item.timestamp, // оставляем в секундах - processMetricsData конвертирует
value: item.value.toString()
}));
processMetricsData({
metric: metricName,
data: response.data.map(item => ({
...item,
timestamp: item.timestamp / 1000, // или item.timestamp если уже в секундах
value: item.value.toString() // преобразуем в строку, как ожидает processMetricsData
}))
data: processedData
});
}
} catch (error) {
@ -219,6 +221,12 @@ const PrometheusChart = ({ metricName }) => {
const handleRangeChange = useCallback((event) => {
// Очищаем текущий интервал
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const selectedValue = event.target.value;
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
@ -232,50 +240,131 @@ const PrometheusChart = ({ metricName }) => {
setEndDate(now);
setStartDate(new Date(now.getTime() - range.value * 1000));
// Переподключение сокета при возврате к стандартным диапазонам
// Переподключение сокета
if (!socketRef.current?.connected) {
socketRef.current?.connect();
}
}, []);
const handleCustomRangeChange = useCallback(() => {
// Отключаем WebSocket соединение
if (socketRef.current?.connected) {
socketRef.current.disconnect();
setConnectionStatus('disconnected');
}
// Очищаем интервал обновления
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setUseCustomRange(true);
setChartData(null);
setSelectedGraphRange(null);
setFilteredData(null);
}, []);
fetchCustomRangeData();
}, [fetchCustomRangeData]);
const handleResetZoom = useCallback(() => {
setSelectedGraphRange(null);
setFilteredData(null);
setIsSelectingRange(false);
fetchData();
}, [fetchData]);
if (useCustomRange) {
fetchCustomRangeData();
} else {
if (!socketRef.current?.connected) {
socketRef.current?.connect();
}
fetchData();
}
if (lastCustomRange) {
handleRangeSelect(lastCustomRange);
return;
}
}, [fetchData, fetchCustomRangeData, useCustomRange]);
const interpolateData = useCallback((data, targetPointCount) => {
if (!data || data.length < 2) return data;
if (data.length >= targetPointCount) return data;
const interpolated = [];
const step = (data.length - 1) / (targetPointCount - 1);
for (let i = 0; i < targetPointCount; i++) {
const index = i * step;
const lowerIndex = Math.floor(index);
const upperIndex = Math.ceil(index);
if (lowerIndex === upperIndex) {
interpolated.push(data[lowerIndex]);
continue;
}
const fraction = index - lowerIndex;
const interpolatedPoint = {};
Object.keys(data[lowerIndex]).forEach(key => {
if (key === 'timestamp') {
interpolatedPoint[key] = data[lowerIndex][key] +
fraction * (data[upperIndex][key] - data[lowerIndex][key]);
// Добавляем отображаемое время
const { display, fullDisplay } = formatTime(interpolatedPoint[key],
(endDate - startDate) / 1000);
interpolatedPoint.time = display;
interpolatedPoint.fullTime = fullDisplay;
} else if (typeof data[lowerIndex][key] === 'number') {
interpolatedPoint[key] = data[lowerIndex][key] +
fraction * (data[upperIndex][key] - data[lowerIndex][key]);
} else {
interpolatedPoint[key] = data[lowerIndex][key];
}
});
interpolated.push(interpolatedPoint);
}
return interpolated;
}, []);
const handleRangeSelect = useCallback((range) => {
if (range) {
// Начало выделения - останавливаем обновления
setIsSelectingRange(true);
setSelectedGraphRange(range);
setLastCustomRange(range);
if (!range || !chartData) return;
// Отключаем сокет
if (socketRef.current?.connected) {
socketRef.current.disconnect();
}
// Очищаем интервал
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
} else {
// Окончание выделения - возобновляем соединение
setIsSelectingRange(false);
setIsSelectingRange(true);
setSelectedGraphRange(range);
if (!useCustomRange && socketRef.current && !socketRef.current.connected) {
socketRef.current.connect();
}
// Отключаем автоматические обновления
if (socketRef.current?.connected) {
socketRef.current.disconnect();
}
}, [useCustomRange]);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Получаем все точки и сортируем по времени
const allPoints = Object.values(chartData).flat();
const sortedPoints = allPoints.sort((a, b) => a.timestamp - b.timestamp);
// Вычисляем абсолютные индексы
const startIndex = Math.floor(range.startIndex * (sortedPoints.length - 1));
const endIndex = Math.floor(range.endIndex * (sortedPoints.length - 1));
// Фильтруем точки по выбранному диапазону
const filtered = sortedPoints.slice(startIndex, endIndex + 1);
// Применяем интерполяцию только если точек меньше 100
const interpolated = filtered.length < 100 ?
interpolateData(filtered, Math.min(100, filtered.length * 3)) :
filtered;
setFilteredData(interpolated);
setIsSelectingRange(false);
}, [chartData, interpolateData, formatTime]);
useEffect(() => {
const socket = setupWebSocket();
@ -285,9 +374,30 @@ const PrometheusChart = ({ metricName }) => {
};
}, [setupWebSocket]);
// Обновим useEffect для кастомного диапазона
useEffect(() => {
if (useCustomRange && !isSelectingRange) {
// Очищаем предыдущий таймер
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// Устанавливаем новый таймер с задержкой 500 мс
debounceRef.current = setTimeout(() => {
fetchCustomRangeData();
}, 500);
}
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [useCustomRange, isSelectingRange, startDate, endDate, fetchCustomRangeData]);
useEffect(() => {
if (useCustomRange || isSelectingRange) return;
const fetchDataWrapper = () => {
try {
fetchData();
@ -295,16 +405,16 @@ const PrometheusChart = ({ metricName }) => {
console.error('Error in interval fetch:', error);
}
};
// Очищаем предыдущий интервал
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Запускаем сразу и затем по интервалу
fetchDataWrapper();
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
@ -313,33 +423,24 @@ const PrometheusChart = ({ metricName }) => {
}, [fetchData, selectedRange.interval, useCustomRange, isSelectingRange]);
useEffect(() => {
if (!chartData || !selectedGraphRange) {
if (!selectedGraphRange || !chartData) {
setFilteredData(null);
return;
}
const { startIndex, endIndex } = selectedGraphRange;
const allTimes = Object.values(chartData)
.flat()
.map(point => point.time)
.filter((time, index, self) => self.indexOf(time) === index);
const allPoints = Object.values(chartData).flat();
const sortedPoints = allPoints.sort((a, b) => a.timestamp - b.timestamp);
const data = allTimes.map(time => {
const point = { time };
Object.keys(chartData).forEach(key => {
const instanceData = chartData[key].find(p => p.time === time);
point[key] = instanceData ? instanceData.value : null;
});
return point;
}).sort((a, b) => {
const timeA = chartData[Object.keys(chartData)[0]].find(d => d.time === a.time)?.timestamp;
const timeB = chartData[Object.keys(chartData)[0]].find(d => d.time === b.time)?.timestamp;
return timeA - timeB;
});
const startIndex = Math.floor(selectedGraphRange.startIndex * (sortedPoints.length - 1));
const endIndex = Math.floor(selectedGraphRange.endIndex * (sortedPoints.length - 1));
const filtered = data.slice(startIndex, endIndex + 1);
setFilteredData(filtered);
}, [selectedGraphRange, chartData]);
const filtered = sortedPoints.slice(startIndex, endIndex + 1);
const interpolated = filtered.length > 100 ?
interpolateData(filtered, 100) :
filtered;
setFilteredData(interpolated);
}, [selectedGraphRange, chartData, interpolateData]);
if (chartData === null) {
return <div style={{ padding: '20px', textAlign: 'center' }}>Loading data...</div>;
@ -382,7 +483,7 @@ const PrometheusChart = ({ metricName }) => {
chartData={chartData}
metricName={metricName}
colors={COLORS}
onRangeSelect={handleRangeSelect} // Используем модифицированный обработчик
onRangeSelect={handleRangeSelect}
filteredData={filteredData}
/>
</div>

View File

@ -7,8 +7,6 @@ const getMetricName = (id) => {
return `zvks_apiforsnmp_measure_${id}`;
};
//!!!!!!!!!!Пофиксить вкладуи с eth4, во всех eth 1-4 открывается именно 4 !!!!!!!!!!!!!
// Функция для рекурсивного сбора всех id потомков
const getAllChildIds = (node) => {
let ids = [];

View File

@ -1,6 +1,5 @@
import SystemStatusChart from "../../Charts/SystemStatusChart";
import TreeTable from "../UI/TreeTable";
import FlowChart from "../TreeChart/FlowChart";
const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleOpenTab }) => {