499 lines
18 KiB
JavaScript
Executable File
499 lines
18 KiB
JavaScript
Executable File
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); |