trust-module-frontend/src/Charts/PrometheusChart.jsx

499 lines
18 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useEffect, useState, useRef, useCallback } from 'react';
import { webSocketManager } from './WebSocketManager';
import LineChartComponent from './Components/LineChartComponent';
import { TimeRangeSelector } from './Components/TimeRangeSelector';
import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator';
import { CurrentRangeDisplay } from './Components/CurrentRangeDisplay';
import { TIME_RANGES, COLORS, SECOND, MINUTE, HOUR, DAY } from './Components/constants';
import axios from 'axios';
import Skeleton from '@mui/material/Skeleton';
import Box from '@mui/material/Box';
// Компонент Skeleton для графика
const ChartSkeleton = () => (
<Box sx={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
position: 'relative'
}}>
<Box sx={{ position: 'absolute', right: '20px', top: '20px' }}>
<Skeleton variant="circular" width={16} height={16} />
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Skeleton variant="text" width="40%" height={30} />
<Skeleton variant="text" width="30%" height={30} />
</Box>
<Skeleton variant="rectangular" width="100%" height={300} />
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, gap: 2 }}>
{[1, 2, 3, 4].map((_, i) => (
<Skeleton key={i} variant="rounded" width={80} height={36} />
))}
</Box>
</Box>
);
const PrometheusChart = ({ metricName }) => {
const [chartData, setChartData] = useState(null);
const [selectedRange, setSelectedRange] = useState(TIME_RANGES[0]);
const [startDate, setStartDate] = useState(new Date());
const [endDate, setEndDate] = useState(new Date());
const [useCustomRange, setUseCustomRange] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('disconnected');
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 debounceRef = useRef(null);
const formatTime = useCallback((timestamp, rangeSeconds) => {
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('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',
hour12: false
}),
timestamp: ts
};
}, []);
const calculateStep = useCallback((start, end) => {
const range = end - start;
if (range <= MINUTE) return 1; // 1 мин
if (range <= MINUTE * 5) return 5; // 5 мин
if (range <= HOUR / 2) return 15; // 30 мин
if (range <= HOUR) return 30; // 1 час
if (range <= HOUR * 3) return 60; // 3 часа
if (range <= HOUR * 6) return 120; // 6 часов
if (range <= DAY / 2) return 300; // 12 часов
if (range <= DAY) return 600; // 24 часа
return 1800; // > 24 часов
}, []);
const processMetricsData = useCallback((response, replace = false) => {
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;
const newData = {};
const rangeSeconds = useCustomRange
? (endDate.getTime() - startDate.getTime()) / 1000
: selectedRange.value;
dataArray.forEach(item => {
const instance = item.instance || 'default';
if (!newData[instance]) newData[instance] = [];
let timestamp;
if (typeof item.timestamp === 'number') {
timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000;
} else {
timestamp = Date.now();
}
const value = parseFloat(item.value);
const formattedTime = formatTime(timestamp, rangeSeconds);
newData[instance].push({
time: formattedTime.display,
fullTime: formattedTime.fullDisplay,
value,
timestamp
});
});
Object.keys(newData).forEach(instance => {
newData[instance] = newData[instance]
.sort((a, b) => a.timestamp - b.timestamp)
.slice(-1000);
});
if (replace) {
setChartData(newData); // Заменяем полностью
} else {
setChartData(prev => {
const merged = { ...(prev || {}) };
Object.keys(newData).forEach(instance => {
if (!merged[instance]) merged[instance] = [];
merged[instance] = [...merged[instance], ...newData[instance]]
.sort((a, b) => a.timestamp - b.timestamp)
.slice(-1000);
});
return merged;
});
}
}, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]);
const fetchData = useCallback(() => {
if (isSelectingRange) return;
const now = Math.floor(Date.now() / 1000);
const start = now - selectedRange.value;
const end = now;
const step = calculateStep(start, end);
webSocketManager.getMetricsRange(metricName, start, end, step)
.then(data => {
processMetricsData({ metric: metricName, data });
})
.catch(error => {
console.error('Error fetching metrics:', error);
});
}, [metricName, selectedRange.value, isSelectingRange, calculateStep, processMetricsData]);
const fetchCustomRangeData = useCallback(async () => {
// Добавляем проверку на валидность дат
if (!startDate || !endDate || startDate >= endDate) {
console.error('Invalid date range');
return;
}
const start = Math.floor(startDate.getTime() / 1000);
const end = Math.ceil(endDate.getTime() / 1000); // Используем Math.ceil для конечной даты
const rangeSeconds = end - start;
try {
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics`, {
params: {
metric: metricName,
start,
end,
step: calculateStep(start, end)
}
});
if (response.data?.length) {
// Добавляем нормализацию timestamp
const processedData = response.data.map(item => ({
...item,
timestamp: item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000,
value: parseFloat(item.value)
}));
processMetricsData({
metric: metricName,
data: processedData
}, true);
}
} catch (error) {
console.error('Ошибка при получении кастомных данных:', error);
}
}, [metricName, startDate, endDate, calculateStep, processMetricsData]);
const handleRangeChange = useCallback(async (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));
// Полный сброс состояния перед загрузкой новых данных
setChartData(null);
setSelectedRange(range);
setUseCustomRange(false);
setSelectedGraphRange(null);
setFilteredData(null);
const now = new Date();
setEndDate(now);
setStartDate(new Date(now.getTime() - range.value * 1000));
// Ждем завершения обновления состояния перед загрузкой
await new Promise(resolve => setTimeout(resolve, 0));
fetchData();
}, [fetchData]);
const handleCustomRangeChange = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setUseCustomRange(true);
setChartData(null);
setSelectedGraphRange(null);
setFilteredData(null);
fetchCustomRangeData();
}, [fetchCustomRangeData]);
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) => {
setLastCustomRange(range);
if (!range || !chartData) return;
setIsSelectingRange(true);
setSelectedGraphRange(range);
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]);
const handleResetZoom = useCallback(() => {
setSelectedGraphRange(null);
setFilteredData(null);
setIsSelectingRange(false);
if (useCustomRange) {
fetchCustomRangeData();
} else {
fetchData();
}
if (lastCustomRange) {
handleRangeSelect(lastCustomRange);
}
}, [fetchData, fetchCustomRangeData, useCustomRange, lastCustomRange, handleRangeSelect]);
useEffect(() => {
// Обработчик данных с сервера
const handleMetricsData = (data) => {
if (!useCustomRange) {
processMetricsData({ metric: metricName, data });
}
};
// Подписываемся на обновления метрики
const unsubscribe = webSocketManager.subscribe(metricName, handleMetricsData);
// Подписываемся на изменения статуса соединения
const unsubscribeStatus = webSocketManager.onConnectionStatusChange(setConnectionStatus);
return () => {
// Отписываемся при размонтировании компонента
unsubscribe();
unsubscribeStatus();
// Очищаем интервал обновления
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [metricName, useCustomRange, processMetricsData]);
useEffect(() => {
if (useCustomRange && !isSelectingRange) {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
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();
} catch (error) {
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);
}
};
}, [fetchData, selectedRange.interval, useCustomRange, isSelectingRange]);
useEffect(() => {
if (!selectedGraphRange || !chartData) {
setFilteredData(null);
return;
}
const allPoints = Object.values(chartData).flat();
const sortedPoints = allPoints.sort((a, b) => a.timestamp - b.timestamp);
const startIndex = Math.floor(selectedGraphRange.startIndex * (sortedPoints.length - 1));
const endIndex = Math.floor(selectedGraphRange.endIndex * (sortedPoints.length - 1));
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 <ChartSkeleton />;
}
if (Object.keys(chartData).length === 0) {
return (
<Box sx={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
textAlign: 'center'
}}>
No data available
</Box>
);
}
return (
<div style={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
position: 'relative'
}}>
<ConnectionStatusIndicator connectionStatus={connectionStatus} />
<TimeRangeSelector
selectedRange={selectedRange}
handleRangeChange={handleRangeChange}
startDate={startDate}
setStartDate={setStartDate}
endDate={endDate}
setEndDate={setEndDate}
useCustomRange={useCustomRange}
handleCustomRangeChange={handleCustomRangeChange}
handleResetZoom={handleResetZoom}
/>
<CurrentRangeDisplay
useCustomRange={useCustomRange}
startDate={startDate}
endDate={endDate}
selectedRange={selectedRange}
/>
<LineChartComponent
chartData={chartData}
metricName={metricName}
colors={COLORS}
onRangeSelect={handleRangeSelect}
filteredData={filteredData}
/>
</div>
);
};
export default React.memo(PrometheusChart);