508 lines
20 KiB
JavaScript
Executable File
508 lines
20 KiB
JavaScript
Executable File
import React, { useEffect, useState, useRef } from 'react';
|
||
import DatePicker from 'react-datepicker';
|
||
import 'react-datepicker/dist/react-datepicker.css';
|
||
import LineChartComponent from './Components/LineChartComponent';
|
||
import { io } from 'socket.io-client';
|
||
|
||
const MAX_POINTS = 20;
|
||
const COLORS = ['#3e95cd', '#8e5ea2', '#3cba9f', '#e8c3b9', '#c45850'];
|
||
const TIME_RANGES = [
|
||
{ label: '1 минута', value: 60, interval: 3000 },
|
||
{ label: '5 минут', value: 300, interval: 15000 },
|
||
{ label: '30 минут', value: 1800, interval: 90000 },
|
||
{ label: '1 час', value: 3600, interval: 180000 },
|
||
{ label: '3 часа', value: 10800, interval: 540000 },
|
||
{ label: '6 часов', value: 21600, interval: 1080000 },
|
||
{ label: '12 часов', value: 43200, interval: 2160000 },
|
||
{ label: '24 часа', value: 86400, interval: 4320000 },
|
||
{ label: '2 дня', value: 172800, interval: 8640000 },
|
||
{ label: '7 дней', value: 604800, interval: 30240000 },
|
||
{ label: '30 дней', value: 2592000, interval: 129600000 },
|
||
{ label: '90 дней', value: 7776000, interval: 388800000 },
|
||
{ label: '6 месяцев', value: 15552000, interval: 777600000 },
|
||
{ label: '9 месяцев', value: 23328000, interval: 1166400000 },
|
||
{ label: '1 год', value: 31536000, interval: 1576800000 },
|
||
];
|
||
|
||
const PrometheusChart = ({ metricName }) => {
|
||
const [chartData, setChartData] = useState({});
|
||
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 [selectedGraphRange, setSelectedGraphRange] = useState(null);
|
||
const [filteredData, setFilteredData] = useState(null);
|
||
const [connectionStatus, setConnectionStatus] = useState('disconnected');
|
||
const intervalRef = useRef(null);
|
||
const socketRef = useRef(null);
|
||
|
||
const setupWebSocket = () => {
|
||
const socket = io('http://192.168.2.39:3000/metrics-ws', {
|
||
path: '/socket.io',
|
||
transports: ['websocket'],
|
||
reconnection: true,
|
||
reconnectionAttempts: 5,
|
||
reconnectionDelay: 1000,
|
||
});
|
||
|
||
socketRef.current = socket;
|
||
|
||
socket.on('connect', () => {
|
||
setConnectionStatus('connected');
|
||
fetchData();
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
setConnectionStatus('disconnected');
|
||
});
|
||
|
||
socket.on('connect_error', (err) => {
|
||
setConnectionStatus('error');
|
||
});
|
||
|
||
socket.on('metrics-data', (response) => {
|
||
processMetricsData(response);
|
||
});
|
||
|
||
return socket;
|
||
};
|
||
|
||
const calculateStep = (start, end) => {
|
||
const range = end - start;
|
||
if (range <= 3600) return 5;
|
||
if (range <= 21600) return 30;
|
||
if (range <= 86400) return 120;
|
||
return 300;
|
||
};
|
||
|
||
const fetchData = () => {
|
||
try {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
let start = useCustomRange
|
||
? Math.floor(startDate.getTime() / 1000)
|
||
: now - selectedRange.value;
|
||
let end = useCustomRange
|
||
? Math.floor(endDate.getTime() / 1000)
|
||
: now;
|
||
|
||
// Проверка на корректность диапазона
|
||
if (start >= end) {
|
||
console.error('Invalid time range: start >= end');
|
||
return;
|
||
}
|
||
|
||
const step = calculateStep(start, end);
|
||
|
||
if (socketRef.current?.connected) {
|
||
socketRef.current.emit('get-metrics', {
|
||
metric: metricName,
|
||
start,
|
||
end,
|
||
step
|
||
});
|
||
} else {
|
||
console.error('WebSocket is not connected');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error in fetchData:', error);
|
||
}
|
||
};
|
||
|
||
const processMetricsData = (response) => {
|
||
const { metric, data } = response;
|
||
if (metric !== metricName) return;
|
||
|
||
let metrics = Array.isArray(data) ? data : [];
|
||
let start, end;
|
||
|
||
if (metrics.length === 0) {
|
||
console.warn('Received empty metrics data');
|
||
return;
|
||
}
|
||
|
||
if (useCustomRange) {
|
||
start = Math.floor(startDate.getTime() / 1000);
|
||
end = Math.floor(endDate.getTime() / 1000);
|
||
} else {
|
||
end = Math.floor(Date.now() / 1000);
|
||
start = end - selectedRange.value;
|
||
}
|
||
|
||
const step = calculateStep(start, end);
|
||
const range = end - start;
|
||
|
||
// 1. Генерируем ВСЕ ожидаемые временные точки
|
||
const timePoints = [];
|
||
for (let t = start; t <= end; t += step) {
|
||
const date = new Date(t * 1000);
|
||
const formattedTime = range > 86400
|
||
? 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' });
|
||
timePoints.push(formattedTime);
|
||
}
|
||
|
||
// 2. Создаем карту "время -> значение" для каждого инстанса
|
||
const timeValueMap = {};
|
||
metrics.forEach(m => {
|
||
const date = new Date(m.timestamp);
|
||
const formattedTime = range > 86400
|
||
? 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' });
|
||
|
||
const key = m.instance;
|
||
if (!timeValueMap[key]) timeValueMap[key] = {};
|
||
timeValueMap[key][formattedTime] = m.value;
|
||
});
|
||
|
||
// 3. Строим финальные данные, гарантируя все временные точки
|
||
const newChartData = {};
|
||
Object.keys(timeValueMap).forEach(instance => {
|
||
newChartData[instance] = timePoints.map(time => ({
|
||
time,
|
||
value: timeValueMap[instance][time] ?? null // null если данных нет
|
||
}));
|
||
});
|
||
|
||
setChartData({ ...newChartData }); // Форсируем обновление
|
||
};
|
||
|
||
const interpolateData = (data, minPoints = 15) => {
|
||
if (data.length >= minPoints) return data;
|
||
|
||
const interpolatedData = [];
|
||
for (let i = 0; i < data.length - 1; i++) {
|
||
interpolatedData.push(data[i]);
|
||
|
||
const currentPoint = data[i];
|
||
const nextPoint = data[i + 1];
|
||
const currentTime = new Date(currentPoint.time).getTime();
|
||
const nextTime = new Date(nextPoint.time).getTime();
|
||
const timeDiff = nextTime - currentTime;
|
||
|
||
const steps = Math.ceil((minPoints - data.length) / (data.length - 1));
|
||
for (let j = 1; j <= steps; j++) {
|
||
const interpolatedTime = new Date(currentTime + (timeDiff * j) / (steps + 1)).toLocaleString();
|
||
const interpolatedPoint = { time: interpolatedTime };
|
||
|
||
Object.keys(currentPoint).forEach(key => {
|
||
if (key !== 'time') {
|
||
const currentValue = currentPoint[key];
|
||
const nextValue = nextPoint[key];
|
||
interpolatedPoint[key] = currentValue + ((nextValue - currentValue) * j) / (steps + 1);
|
||
}
|
||
});
|
||
|
||
interpolatedData.push(interpolatedPoint);
|
||
}
|
||
}
|
||
|
||
interpolatedData.push(data[data.length - 1]);
|
||
return interpolatedData.slice(0, minPoints);
|
||
};
|
||
|
||
const handleRangeChange = (event) => {
|
||
const selectedValue = event.target.value;
|
||
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
|
||
|
||
// Сбрасываем данные и состояние
|
||
setChartData({});
|
||
setFilteredData(null);
|
||
setSelectedGraphRange(null);
|
||
|
||
// Обновляем диапазон и даты
|
||
setSelectedRange(range);
|
||
setUseCustomRange(false);
|
||
|
||
const now = new Date();
|
||
setEndDate(now);
|
||
setStartDate(new Date(now.getTime() - range.value * 1000));
|
||
|
||
// Принудительно запрашиваем новые данные
|
||
// Используем setTimeout для гарантированного обновления состояния перед запросом
|
||
setTimeout(() => {
|
||
fetchData();
|
||
}, 0);
|
||
};
|
||
|
||
const handleCustomRangeChange = () => {
|
||
// Сбрасываем данные и состояние
|
||
setChartData({});
|
||
setFilteredData(null);
|
||
setSelectedGraphRange(null);
|
||
|
||
setUseCustomRange(true);
|
||
|
||
// Принудительно запрашиваем новые данные
|
||
setTimeout(() => {
|
||
fetchData();
|
||
}, 0);
|
||
};
|
||
|
||
const handleResetZoom = () => {
|
||
setSelectedGraphRange(null);
|
||
setFilteredData(null);
|
||
fetchData();
|
||
};
|
||
|
||
useEffect(() => {
|
||
const socket = setupWebSocket();
|
||
|
||
// Первоначальная загрузка данных
|
||
fetchData();
|
||
|
||
// Настраиваем интервал обновления
|
||
const intervalId = setInterval(() => {
|
||
fetchData();
|
||
}, selectedRange.interval);
|
||
|
||
intervalRef.current = intervalId;
|
||
|
||
return () => {
|
||
clearInterval(intervalRef.current);
|
||
socket.disconnect();
|
||
};
|
||
}, [metricName, selectedRange.value]);
|
||
|
||
useEffect(() => {
|
||
// При изменении диапазона или дат перезапускаем интервал
|
||
if (socketRef.current?.connected) {
|
||
clearInterval(intervalRef.current);
|
||
fetchData();
|
||
|
||
intervalRef.current = setInterval(() => {
|
||
fetchData();
|
||
}, selectedRange.interval);
|
||
}
|
||
}, [selectedRange, useCustomRange, startDate, endDate, metricName]);
|
||
|
||
useEffect(() => {
|
||
if (selectedGraphRange) {
|
||
const { startIndex, endIndex } = selectedGraphRange;
|
||
const allTimes = Object.values(chartData)
|
||
.flat()
|
||
.map(point => point.time)
|
||
.filter((time, index, self) => self.indexOf(time) === index);
|
||
|
||
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;
|
||
});
|
||
|
||
const filtered = data.slice(startIndex, endIndex + 1);
|
||
const interpolated = interpolateData(filtered, 15);
|
||
setFilteredData(interpolated);
|
||
} else {
|
||
setFilteredData(null);
|
||
}
|
||
}, [selectedGraphRange, chartData]);
|
||
|
||
if (!Object.keys(chartData).length) return <p>Loading...</p>;
|
||
|
||
const allTimes = Object.values(chartData)
|
||
.flat()
|
||
.map(point => point.time)
|
||
.filter((time, index, self) => self.indexOf(time) === index);
|
||
|
||
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;
|
||
});
|
||
|
||
return (
|
||
<div style={{
|
||
backgroundColor: '#fff',
|
||
borderRadius: '8px',
|
||
padding: '20px',
|
||
marginBottom: '20px',
|
||
position: 'relative'
|
||
}}>
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: '10px',
|
||
right: '10px',
|
||
padding: '5px 10px',
|
||
borderRadius: '4px',
|
||
backgroundColor: connectionStatus === 'connected' ? '#4CAF50' :
|
||
connectionStatus === 'error' ? '#F44336' : '#FFC107',
|
||
color: 'white',
|
||
fontSize: '12px'
|
||
}}>
|
||
{connectionStatus === 'connected' ? 'Online' :
|
||
connectionStatus === 'error' ? 'Connection Error' : 'Offline'}
|
||
</div>
|
||
{/* Заголовок графика */}
|
||
<h3 style={{ marginTop: 0, color: '#333' }}>
|
||
</h3>
|
||
|
||
{/* Группа элементов управления */}
|
||
<div style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '15px',
|
||
alignItems: 'center',
|
||
marginBottom: '15px'
|
||
}}>
|
||
{/* Стандартные диапазоны */}
|
||
<div style={{ flex: '1 1 200px', display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
|
||
<div style={{ flex: '1' }}>
|
||
<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>
|
||
|
||
{/* Кнопка сброса */}
|
||
<button
|
||
onClick={handleResetZoom}
|
||
style={{
|
||
padding: '8px 16px',
|
||
backgroundColor: '#f0f0f0',
|
||
color: '#333',
|
||
border: '1px solid #ddd',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.2s',
|
||
height: '36px',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
onMouseOver={(e) => e.target.style.backgroundColor = '#e0e0e0'}
|
||
onMouseOut={(e) => e.target.style.backgroundColor = '#f0f0f0'}
|
||
>
|
||
Сбросить
|
||
</button>
|
||
</div>
|
||
|
||
|
||
|
||
{/* Кастомный диапазон */}
|
||
<div style={{ flex: '1 1 300px' }}>
|
||
<div style={{
|
||
marginBottom: '10px',
|
||
fontWeight: '500',
|
||
color: '#555'
|
||
}}>
|
||
Или укажите свой диапазон:
|
||
</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>
|
||
|
||
{/* Индикатор текущего диапазона */}
|
||
<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
|
||
chartData={chartData}
|
||
metricName={metricName}
|
||
colors={COLORS}
|
||
description={metricName}
|
||
onRangeSelect={setSelectedGraphRange}
|
||
filteredData={filteredData}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PrometheusChart; |