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 = () => ( {[1, 2, 3, 4].map((_, i) => ( ))} ); 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 ; } if (Object.keys(chartData).length === 0) { return ( No data available ); } return (
); }; export default React.memo(PrometheusChart);