fixed a bug with multiple web socket connection

authorization-token
DmitriyA 2025-04-21 02:55:11 -04:00
parent d83f05e2b5
commit c7ebbcaf5c
12 changed files with 817 additions and 139 deletions

View File

@ -27,7 +27,8 @@
"reactflow": "^11.11.4",
"vite-plugin-svgr": "^4.3.0",
"react-scripts": "^5.0.1",
"socket.io-client": "^4.8.1"
"socket.io-client": "^4.8.1",
"antd": "^5.24.7"
},
"devDependencies": {
"@eslint/js": "^9.17.0",

View File

@ -203,7 +203,7 @@ const LineChartComponent = ({
{instanceKeys.map((instance, index) => (
<Line
key={instance}
type="monotone"
type=""
dataKey={instance}
name={instance}
stroke={colors[index % colors.length]}

View File

@ -0,0 +1,153 @@
// src/utils/metricsUtils.js
import { MINUTE, HOUR, DAY } from './constants';
export function formatTime(timestamp, rangeSeconds) {
const ts = typeof timestamp === 'number' ? timestamp : Date.now();
const date = new Date(ts);
const timeOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
const dateOptions = rangeSeconds > 86400 ? {
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',
...timeOptions
}),
timestamp: ts
};
}
export function calculateStep(start, end) {
const rangeSeconds = end - start;
if (rangeSeconds <= MINUTE) return 1;
if (rangeSeconds <= 5 * MINUTE) return 5;
if (rangeSeconds <= 15 * MINUTE) return 15;
if (rangeSeconds <= HOUR) return 30;
if (rangeSeconds <= 3 * HOUR) return 2 * MINUTE;
if (rangeSeconds <= 6 * HOUR) return 5 * MINUTE;
if (rangeSeconds <= 12 * HOUR) return 10 * MINUTE;
if (rangeSeconds <= DAY) return 15 * MINUTE;
if (rangeSeconds <= 3 * DAY) return HOUR;
return 2 * HOUR;
}
export function processMetricsData(metricName, responseData, prevData, rangeSeconds) {
if (!responseData) {
console.error('No data received for processing');
return prevData || {};
}
// Добавим обработку случая, когда данные приходят в формате {metric, data, metadata}
const rawData = responseData.data || (Array.isArray(responseData) ? responseData : [responseData]);
const newData = { ...(prevData || {}) };
rawData.forEach(item => {
try {
const instance = item.instance || item.metric?.instance || 'default';
if (!newData[instance]) newData[instance] = [];
// Обработка timestamp
let timestamp = item.timestamp;
if (typeof timestamp !== 'number') {
timestamp = Date.now();
} else if (timestamp < 1e12) { // Если timestamp в секундах
timestamp *= 1000;
}
// Обработка value
let value = item.value;
if (value === undefined && item.metric?.value !== undefined) {
value = item.metric.value;
}
if (typeof value !== 'number') {
value = parseFloat(value);
if (isNaN(value)) {
console.warn('Invalid value, using 0 as fallback:', item);
value = 0;
}
}
const formattedTime = formatTime(timestamp, rangeSeconds);
newData[instance].push({
time: formattedTime.display,
fullTime: formattedTime.fullDisplay,
value: value,
timestamp: timestamp,
meta: {
description: item.description || item.metric?.description,
type: item.type || item.metric?.type,
status: item.status || item.metric?.status
}
});
} catch (error) {
console.error('Error processing metric item:', item, error);
}
});
// Сортировка и ограничение данных
Object.keys(newData).forEach(instance => {
newData[instance] = newData[instance]
.sort((a, b) => a.timestamp - b.timestamp)
.slice(-1000);
});
return newData;
}
export function interpolateData(data, targetPointCount, timeRangeSeconds) {
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 lower = data[lowerIndex];
const upper = data[upperIndex];
const interpolatedPoint = {
time: '',
fullTime: '',
value: lower.value + fraction * (upper.value - lower.value),
timestamp: lower.timestamp + fraction * (upper.timestamp - lower.timestamp)
};
// Форматирование времени
const formatted = formatTime(interpolatedPoint.timestamp, timeRangeSeconds || DAY);
interpolatedPoint.time = formatted.display;
interpolatedPoint.fullTime = formatted.fullDisplay;
interpolated.push(interpolatedPoint);
console.log('Item:', item.value, timestamp, formattedTime.display);
}
return interpolated;
}

291
src/Charts/MetricChart.jsx Normal file
View File

@ -0,0 +1,291 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceArea } from 'recharts';
import io from 'socket.io-client';
import axios from 'axios';
import { Select, Button, Space, DatePicker, Spin, Alert } from 'antd';
import moment from 'moment';
const { Option } = Select;
const { RangePicker } = DatePicker;
const timeRanges = [
{ label: '1 мин', value: 1 },
{ label: '5 мин', value: 5 },
{ label: '30 мин', value: 30 },
{ label: '1 час', value: 60 },
{ label: '3 часа', value: 180 },
{ label: '6 часов', value: 360 },
{ label: '12 часов', value: 720 },
{ label: '24 часа', value: 1440 },
];
const getStatusColor = (status) => {
if (!status) return '#1890ff';
switch (status.toUpperCase()) {
case 'OK': return '#52c41a';
case 'WARNING': return '#faad14';
case 'CRITICAL': return '#f5222d';
default: return '#1890ff';
}
};
const MetricChart = ({ metricName, title }) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedRange, setSelectedRange] = useState(timeRanges[0]);
const [customRange, setCustomRange] = useState([]); // Используем массив вместо null для RangePicker
const [isLive, setIsLive] = useState(true);
const [refAreaLeft, setRefAreaLeft] = useState(null);
const [refAreaRight, setRefAreaRight] = useState(null);
const socketRef = useRef(null);
const dataRef = useRef([]);
// Форматирование данных для графика
const formatData = useCallback((rawData) => {
if (!Array.isArray(rawData)) {
console.error('Expected array but received:', rawData);
return [];
}
return rawData.map(item => ({
timestamp: item.timestamp,
time: moment(item.timestamp).format('HH:mm:ss'),
value: parseFloat(item.value) || 0,
status: item.status
}));
}, []);
// Загрузка исторических данных
const fetchHistoricalData = useCallback(async (start, end) => {
setLoading(true);
setError(null);
try {
const duration = moment.duration(end.diff(start)).asMinutes();
const step = Math.max(1, Math.floor(duration / 100)) + 's';
const response = await axios.get(`${import.meta.env.VITE_BACK_HTTP_URL}/metrics`, {
params: {
metric: metricName,
start: start.valueOf(),
end: end.valueOf(),
step: step
},
headers: {
'Accept': 'application/json' // Убедимся, что получаем JSON
}
});
if (response.headers['content-type'].includes('text/html')) {
throw new Error('Server returned HTML instead of JSON. Check your API endpoint.');
}
const formattedData = formatData(response.data);
dataRef.current = formattedData;
setData(formattedData);
setIsLive(false);
} catch (err) {
setError(err.response?.data?.message || err.message || 'Failed to fetch data');
console.error('Error fetching historical data:', err);
} finally {
setLoading(false);
}
}, [metricName, formatData]);
// Подключение к WebSocket и загрузка начальных данных
const connectWebSocket = useCallback(() => {
if (socketRef.current) {
socketRef.current.disconnect();
}
socketRef.current = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
transports: ['websocket'],
reconnectionAttempts: 5
});
socketRef.current.on('connect', () => {
console.log('WebSocket connected');
socketRef.current.emit('subscribe-metric', {
metric: metricName,
interval: 5000
});
});
socketRef.current.on('metrics-data', (response) => {
if (response.metric === metricName && response.data) {
try {
const newDataPoint = formatData([response.data])[0]; // Оборачиваем в массив
if (newDataPoint) {
dataRef.current = [...dataRef.current, newDataPoint].slice(-1000);
if (isLive) {
const now = moment();
const cutoff = now.subtract(selectedRange.value, 'minutes');
setData(dataRef.current.filter(item => moment(item.timestamp).isAfter(cutoff)));
}
}
} catch (e) {
console.error('Error processing WebSocket data:', e);
}
}
});
socketRef.current.on('error', (err) => {
setError(err.message || 'WebSocket error');
});
return () => {
if (socketRef.current) {
socketRef.current.emit('unsubscribe-metric');
socketRef.current.disconnect();
}
};
}, [metricName, formatData, isLive, selectedRange.value]);
// Обработчики изменения диапазона
const handleRangeChange = (value) => {
const range = timeRanges.find(r => r.value === value);
if (!range) return;
setSelectedRange(range);
setCustomRange([]); // Сбрасываем кастомный диапазон
setIsLive(true);
const now = moment();
const cutoff = now.subtract(range.value, 'minutes');
setData(dataRef.current.filter(item => moment(item.timestamp).isAfter(cutoff)));
};
const handleCustomRange = (dates) => {
if (!dates || dates.length !== 2) {
setCustomRange([]);
setIsLive(true);
return;
}
const [start, end] = dates;
setCustomRange(dates);
fetchHistoricalData(start, end);
};
// Эффекты
useEffect(() => {
if (isLive) {
const cleanup = connectWebSocket();
// Загружаем начальные данные
const end = moment();
const start = end.clone().subtract(selectedRange.value, 'minutes');
fetchHistoricalData(start, end);
return cleanup;
}
}, [isLive, connectWebSocket, selectedRange.value, fetchHistoricalData]);
// Обработчики для zoom на графике
const handleMouseDown = (e) => {
if (!e || !e.activeLabel) return;
setRefAreaLeft(e.activeLabel);
setRefAreaRight(e.activeLabel);
};
const handleMouseMove = (e) => {
if (!refAreaLeft || !e.activeLabel) return;
setRefAreaRight(e.activeLabel);
};
const handleMouseUp = () => {
if (!refAreaLeft || !refAreaRight) return;
const leftIdx = data.findIndex(d => d.time === refAreaLeft);
const rightIdx = data.findIndex(d => d.time === refAreaRight);
if (leftIdx !== -1 && rightIdx !== -1) {
const start = moment(Math.min(data[leftIdx].timestamp, data[rightIdx].timestamp));
const end = moment(Math.max(data[leftIdx].timestamp, data[rightIdx].timestamp));
fetchHistoricalData(start, end);
}
setRefAreaLeft(null);
setRefAreaRight(null);
};
const handleBackToLive = () => {
setIsLive(true);
setCustomRange([]);
setSelectedRange(timeRanges[0]);
};
return (
<div style={{ width: '100%', height: 400 }}>
<h3>{title}</h3>
<Space style={{ marginBottom: 16 }}>
<Select
value={selectedRange.value}
onChange={handleRangeChange}
disabled={!isLive}
style={{ width: 120 }}
>
{timeRanges.map(range => (
<Option key={range.value} value={range.value}>{range.label}</Option>
))}
</Select>
<RangePicker
showTime={{ format: 'HH:mm' }}
format="YYYY-MM-DD HH:mm"
onChange={handleCustomRange}
disabled={!isLive}
value={customRange} // Устанавливаем значение для избежания предупреждения
/>
{!isLive && (
<Button onClick={handleBackToLive}>Реальное время</Button>
)}
</Space>
{error && <Alert message={error} type="error" showIcon />}
{loading && <Spin tip="Loading..." size="large" />}
<ResponsiveContainer width="100%" height={300}>
<AreaChart
data={data}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis />
<Tooltip
formatter={(value, name, props) => [
`${value} (${props.payload?.status || 'N/A'})`,
name
]}
labelFormatter={(label) => {
// Исправляем предупреждение Moment.js
if (!label) return '';
return moment(label, 'HH:mm:ss').isValid()
? moment(label, 'HH:mm:ss').format('YYYY-MM-DD HH:mm:ss')
: label;
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="#1890ff"
fill="#1890ff"
isAnimationActive={false}
/>
{refAreaLeft && refAreaRight && (
<ReferenceArea
x1={refAreaLeft}
x2={refAreaRight}
strokeOpacity={0.3}
/>
)}
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default React.memo(MetricChart);

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { io } from 'socket.io-client';
import { webSocketManager } from './WebSocketManager';
import LineChartComponent from './Components/LineChartComponent';
import { TimeRangeSelector } from './Components/TimeRangeSelector';
import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator';
@ -19,7 +19,6 @@ const PrometheusChart = ({ metricName }) => {
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) => {
@ -70,25 +69,6 @@ const PrometheusChart = ({ metricName }) => {
return 1800; // > 24 часов
}, []);
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);
if (socketRef.current?.connected) {
socketRef.current.emit('get-metrics', {
metric: metricName,
start,
end,
step,
_t: Date.now()
});
}
}, [metricName, selectedRange.value, isSelectingRange]);
const processMetricsData = useCallback((response) => {
console.log('Processing metrics data:', response);
@ -137,58 +117,32 @@ const PrometheusChart = ({ metricName }) => {
});
}, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]);
const setupWebSocket = useCallback(() => {
if (socketRef.current) {
// Если соединение уже существует, возвращаем его
if (socketRef.current.connected) return socketRef.current;
// Если соединение в процессе переподключения, тоже возвращаем
if (socketRef.current.reconnecting) return socketRef.current;
}
//VITE_BACK_WS_URL
const socket = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
const fetchData = useCallback(() => {
if (isSelectingRange) return;
socketRef.current = socket;
const now = Math.floor(Date.now() / 1000);
const start = now - selectedRange.value;
const end = now;
const step = calculateStep(start, end);
socket.on('connect', () => {
console.log('WebSocket connected');
setConnectionStatus('connected');
fetchData();
});
socket.on('disconnect', (reason) => {
console.log('WebSocket disconnected:', reason);
setConnectionStatus('disconnected');
if (reason === 'io server disconnect') socket.connect();
});
socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error);
setConnectionStatus('error');
setTimeout(() => socket.connect(), 1000);
});
socket.on('metrics-data', (response) => {
console.log('Received raw metrics data:', response);
processMetricsData(response);
});
socket.on('metrics-error', (error) => {
console.error('Metrics error:', error);
setConnectionStatus('error');
});
return socket;
}, []);
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.floor(endDate.getTime() / 1000);
const end = Math.ceil(endDate.getTime() / 1000); // Используем Math.ceil для конечной даты
const rangeSeconds = end - start;
try {
@ -202,11 +156,11 @@ const PrometheusChart = ({ metricName }) => {
});
if (response.data?.length) {
// Преобразуем данные перед передачей в processMetricsData
// Добавляем нормализацию timestamp
const processedData = response.data.map(item => ({
...item,
timestamp: item.timestamp, // оставляем в секундах - processMetricsData конвертирует
value: item.value.toString()
timestamp: item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000,
value: parseFloat(item.value)
}));
processMetricsData({
@ -220,8 +174,7 @@ const PrometheusChart = ({ metricName }) => {
}, [metricName, startDate, endDate, calculateStep, processMetricsData]);
const handleRangeChange = useCallback((event) => {
// Очищаем текущий интервал
const handleRangeChange = useCallback(async (event) => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
@ -230,9 +183,10 @@ const PrometheusChart = ({ metricName }) => {
const selectedValue = event.target.value;
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
// Полный сброс состояния перед загрузкой новых данных
setChartData(null);
setSelectedRange(range);
setUseCustomRange(false);
setChartData(null);
setSelectedGraphRange(null);
setFilteredData(null);
@ -240,20 +194,12 @@ const PrometheusChart = ({ metricName }) => {
setEndDate(now);
setStartDate(new Date(now.getTime() - range.value * 1000));
// Переподключение сокета
if (!socketRef.current?.connected) {
socketRef.current?.connect();
}
}, []);
// Ждем завершения обновления состояния перед загрузкой
await new Promise(resolve => setTimeout(resolve, 0));
fetchData();
}, [fetchData]);
const handleCustomRangeChange = useCallback(() => {
// Отключаем WebSocket соединение
if (socketRef.current?.connected) {
socketRef.current.disconnect();
setConnectionStatus('disconnected');
}
// Очищаем интервал обновления
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
@ -266,25 +212,7 @@ const PrometheusChart = ({ metricName }) => {
fetchCustomRangeData();
}, [fetchCustomRangeData]);
const handleResetZoom = useCallback(() => {
setSelectedGraphRange(null);
setFilteredData(null);
setIsSelectingRange(false);
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;
@ -337,10 +265,6 @@ const PrometheusChart = ({ metricName }) => {
setIsSelectingRange(true);
setSelectedGraphRange(range);
// Отключаем автоматические обновления
if (socketRef.current?.connected) {
socketRef.current.disconnect();
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
@ -366,23 +290,52 @@ const PrometheusChart = ({ metricName }) => {
setIsSelectingRange(false);
}, [chartData, interpolateData, formatTime]);
useEffect(() => {
const socket = setupWebSocket();
return () => {
clearInterval(intervalRef.current);
socket.disconnect();
};
}, [setupWebSocket]);
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) => {
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, processMetricsData]);
// Обновим useEffect для кастомного диапазона
useEffect(() => {
if (useCustomRange && !isSelectingRange) {
// Очищаем предыдущий таймер
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// Устанавливаем новый таймер с задержкой 500 мс
debounceRef.current = setTimeout(() => {
fetchCustomRangeData();
}, 500);
@ -406,12 +359,11 @@ const PrometheusChart = ({ metricName }) => {
}
};
// Очищаем предыдущий интервал
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Запускаем сразу и затем по интервалу
fetchDataWrapper();
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
@ -490,5 +442,4 @@ const PrometheusChart = ({ metricName }) => {
);
};
export default React.memo(PrometheusChart);
export default React.memo(PrometheusChart);

View File

@ -0,0 +1,115 @@
// src/services/WebSocketManager.js
import { io } from 'socket.io-client';
class WebSocketManager {
constructor() {
this.socket = null;
this.subscribers = new Map();
this.connectionStatus = 'disconnected';
this.connectionCallbacks = new Set();
}
connect() {
if (this.socket && (this.socket.connected || this.socket.reconnecting)) {
return this.socket;
}
this.socket = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
this.socket.on('connect', () => {
this.connectionStatus = 'connected';
this.notifyConnectionStatus();
});
this.socket.on('disconnect', (reason) => {
this.connectionStatus = 'disconnected';
this.notifyConnectionStatus();
if (reason === 'io server disconnect') this.socket.connect();
});
this.socket.on('connect_error', (error) => {
this.connectionStatus = 'error';
this.notifyConnectionStatus();
setTimeout(() => this.socket.connect(), 1000);
});
this.socket.on('metrics-data', (response) => {
const callbacks = this.subscribers.get(response.metric);
if (callbacks) {
callbacks.forEach(callback => callback(response.data));
}
});
return this.socket;
}
subscribe(metricName, callback) {
this.connect();
if (!this.subscribers.has(metricName)) {
this.subscribers.set(metricName, new Set());
this.socket.emit('subscribe-metric', {
metric: metricName,
isSubscription: true // Флаг для подписки
});
}
this.subscribers.get(metricName).add(callback);
return () => this.unsubscribe(metricName, callback);
}
unsubscribe(metricName, callback) {
const callbacks = this.subscribers.get(metricName);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.subscribers.delete(metricName);
this.socket.emit('unsubscribe-metric', { metric: metricName });
}
}
}
getMetricsRange(metricName, start, end, step) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Timeout while waiting for metrics data'));
}, 10000);
// Временный обработчик для разового запроса
const tempHandler = (data) => {
clearTimeout(timer);
this.socket.off(`metrics-range-${metricName}`, tempHandler);
resolve(data);
};
this.socket.on(`metrics-range-${metricName}`, tempHandler);
this.socket.emit('get-metrics', {
metric: metricName,
start,
end,
step,
isRangeQuery: true // Флаг для разового запроса
});
});
}
onConnectionStatusChange(callback) {
this.connectionCallbacks.add(callback);
callback(this.connectionStatus);
return () => this.connectionCallbacks.delete(callback);
}
notifyConnectionStatus() {
this.connectionCallbacks.forEach(callback => callback(this.connectionStatus));
}
}
export const webSocketManager = new WebSocketManager();

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
Drawer,
List,
@ -15,6 +15,7 @@ import {
} from "@mui/icons-material";
import MenuItem from "./SidebarMenuComponents/MenuItem";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
import { statusManager1 } from "../TreeChart/dataUtils";
const SidebarResizer = styled('div')(({ theme }) => ({
width: "5px",
@ -34,6 +35,17 @@ const SidebarResizer = styled('div')(({ theme }) => ({
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
const [collapsed, setCollapsed] = useState(false);
const [hovered, setHovered] = useState(false);
const [menuData, setMenuData] = useState(data);
// Обновляем статусы при изменении данных
useEffect(() => {
if (data) {
// Создаем глубокую копию данных, чтобы не мутировать исходные
const dataCopy = JSON.parse(JSON.stringify(data));
statusManager1.updateStatuses(dataCopy);
setMenuData(dataCopy);
}
}, [data]);
const handleToggleCollapse = () => {
setCollapsed(!collapsed);
@ -126,11 +138,13 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
Меню
</Typography>
)}
<MenuItem
item={data}
onSelectItem={handleSelectItem}
collapsed={collapsed}
/>
{menuData && (
<MenuItem
item={menuData}
onSelectItem={handleSelectItem}
collapsed={collapsed}
/>
)}
</List>
{/* Футер */}

View File

@ -8,10 +8,14 @@ import {
styled
} from "@mui/material";
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
import { getStatusColor } from "../../TreeChart/dataUtils";
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
cursor: "pointer",
paddingLeft: theme.spacing(2 + level * 2),
position: 'relative', // Добавляем для позиционирования индикатора
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
@ -20,6 +24,17 @@ const StyledListItem = styled(ListItem)(({ theme, level }) => ({
},
}));
const StatusIndicator = styled('div')(({ theme, status }) => ({
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '4px',
backgroundColor: status ? getStatusColor(status) : 'transparent',
borderTopRightRadius: '4px',
borderBottomRightRadius: '4px',
}));
const IconWrapper = styled('div')(({ theme }) => ({
cursor: "pointer",
borderRadius: theme.shape.borderRadius,
@ -33,7 +48,6 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
const [isOpen, setIsOpen] = React.useState(false);
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
const handleToggle = (e) => {
e.stopPropagation();
setIsOpen(!isOpen);
@ -52,17 +66,20 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
onClick={hasChildren ? handleToggle : handleOpenTab}
level={level}
sx={{
pl: collapsed ? 2 : 2 + level * 2, // Адаптируем отступы
pl: collapsed ? 2 : 2 + level * 2,
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
{/* Индикатор статуса */}
{!collapsed && <StatusIndicator status={item.status} />}
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
<IconWrapper onClick={handleOpenTab}>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</IconWrapper>
</ListItemIcon>
{!collapsed && ( // Показываем текст только в развернутом состоянии
{!collapsed && (
<>
<ListItemText
primary={item.title}
@ -75,7 +92,7 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
)}
</StyledListItem>
{hasChildren && !collapsed && ( // Показываем детей только в развернутом состоянии
{hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.items.map((child, index) => (
@ -94,7 +111,6 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
);
};
// Вспомогательная функция (остается без изменений)
const getAllChildren = (node) => {
let children = [];
if (node.items && node.items.length > 0) {

View File

@ -0,0 +1,25 @@
import React from "react";
import { styled } from "@mui/material";
const StatusIndicator = styled('div')(({ theme, status }) => ({
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '4px',
backgroundColor: getStatusColor(status),
borderRadius: '0 2px 2px 0',
transition: 'background-color 0.3s ease'
}));
const getStatusColor = (status) => {
switch (status) {
case 'red': return '#F44336';
case 'orange': return '#FF9800';
case 'yellow': return '#cebd21';
case 'green': return '#4CAF50';
default: return 'transparent';
}
};
export default StatusIndicator;

View File

@ -0,0 +1,85 @@
// src/services/StatusService.js
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
class StatusService {
constructor() {
this.statusData = null;
this.subscribers = new Set();
this.pollingInterval = null;
}
// Подписка на обновления статусов
subscribe(callback) {
this.subscribers.add(callback);
return () => this.unsubscribe(callback);
}
unsubscribe(callback) {
this.subscribers.delete(callback);
}
// Запуск периодического обновления статусов
startPolling(interval = 30000) {
this.fetchStatuses(); // Первый запрос сразу
this.pollingInterval = setInterval(() => this.fetchStatuses(), interval);
}
stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
// Запрос статусов с бэкенда
async fetchStatuses() {
try {
const response = await fetch('/api/metrics/all-values');
const data = await response.json();
// Преобразуем данные в нужную структуру
const transformedData = this.transformData(data);
// Обновляем статусы с помощью менеджера
statusManager1.updateStatuses(transformedData);
// Сохраняем данные
this.statusData = transformedData;
// Оповещаем подписчиков
this.notifySubscribers();
} catch (error) {
console.error('Error fetching statuses:', error);
}
}
// Преобразование данных от бэкенда в древовидную структуру
transformData(apiData) {
// Здесь реализуйте преобразование под вашу структуру
// Пример:
return {
name: "Root System",
status: "0",
items: apiData.map(item => ({
id: item.metric.__name__,
title: item.metric.__name__,
status: item.data[0]?.status || "0",
items: [] // Могут быть вложенные элементы
}))
};
}
// Получение текущих данных
getStatusData() {
return this.statusData;
}
// Оповещение подписчиков
notifySubscribers() {
this.subscribers.forEach(callback => callback(this.statusData));
}
}
// Экспортируем singleton экземпляр сервиса
export const statusService = new StatusService();

View File

@ -1,4 +1,6 @@
import React, { lazy, Suspense } from "react";
import Skeleton from '@mui/material/Skeleton';
import Box from '@mui/material/Box';
const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart'));
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
@ -22,9 +24,32 @@ const getAllChildIds = (node) => {
return ids;
};
// Компонент Skeleton для графика
const ChartSkeleton = () => (
<Box sx={{ width: '100%' }}>
<Skeleton variant="text" width="60%" height={30} /> {/* Заголовок */}
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} /> {/* График */}
</Box>
);
// Компонент Skeleton для родительского контейнера
const ContainerSkeleton = () => (
<Box sx={{ width: '100%' }}>
<Skeleton variant="text" width="40%" height={40} /> {/* Заголовок */}
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} /> {/* Описание */}
{/* Место для дочерних элементов */}
<Box sx={{ mt: 2 }}>
{[...Array(3)].map((_, i) => (
<ChartSkeleton key={i} />
))}
</Box>
</Box>
);
const tabContent = (data) => {
const tabContent = {};
// Функция для рекурсивного обхода и сбора данных
// Функция для рекурсивного обхода и сбора данных
const generateContent = (nodes) => {
nodes.forEach((node) => {
@ -36,9 +61,11 @@ const tabContent = (data) => {
const content = (
<div>
<h2>{node.title}</h2>
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} />
<Suspense fallback={<ContainerSkeleton />}>
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} />
</Suspense>
<p>Контент для {node.title}.</p>
{childrenContent}
{/*childrenContent*/}
</div>
);
@ -53,7 +80,7 @@ const tabContent = (data) => {
const content = (
<div key={node.id}>
<h3>{node.title}</h3> {/* Используем title узла */}
<Suspense fallback={<div>Загрузка графика...</div>}>
<Suspense fallback={<ChartSkeleton />}>
<PrometheusChart metricName={metricName} />
</Suspense>
</div>

View File

@ -76,7 +76,7 @@ export const lightTheme = createTheme({
// Фоновые цвета
background: {
default: "#6CACE4", // Основной фон приложения
default: "#FFFFFF", // Основной фон приложения
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
},