From d83f05e2b569c13da4dbadb1104e8bbee13e108d Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 14 Apr 2025 04:40:02 -0400 Subject: [PATCH 1/5] set up an authorization session using tokens and cookies --- src/App.jsx | 103 +++++++++++++++++++++++++++---- src/Components/UI/LoginModal.jsx | 9 +-- src/Components/UI/auth.jsx | 20 ++++++ 3 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 src/Components/UI/auth.jsx diff --git a/src/App.jsx b/src/App.jsx index b0345dd..a176ae1 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,37 +1,108 @@ -import React, { useState, useMemo } from "react"; -import { ThemeProvider, CssBaseline, Switch, Box } from "@mui/material"; +import React, { useState, useMemo, useEffect } from "react"; +import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress } from "@mui/material"; import Dashboard from "./Components/Layout/Dashboard"; import LoginModal from "./Components/UI/LoginModal"; import { lightTheme, darkTheme } from "./Style/theme"; -import Logo from './assets/images/logo.svg?react'; // Импорт как компонента +import Logo from './assets/images/logo.svg?react'; +import { checkAuth } from "./Components/UI/auth"; // Убедитесь, что путь правильный function App() { - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [showLoginModal, setShowLoginModal] = useState(true); + const [authState, setAuthState] = useState({ + isAuthenticated: false, + isLoading: true, + user: null + }); + const [showLoginModal, setShowLoginModal] = useState(false); const [isDarkMode, setIsDarkMode] = useState( window.matchMedia("(prefers-color-scheme: dark)").matches ); const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]); - const handleLogin = () => { - setIsAuthenticated(true); + useEffect(() => { + const verifyAuth = async () => { + try { + const authStatus = await checkAuth(); + + setAuthState({ + isAuthenticated: authStatus.isAuthenticated, + isLoading: false, + user: authStatus.user || null + }); + + setShowLoginModal(!authStatus.isAuthenticated); + } catch (error) { + console.error('Auth verification error:', error); + setAuthState({ + isAuthenticated: false, + isLoading: false, + user: null + }); + setShowLoginModal(true); + } + }; + + verifyAuth(); + }, []); + + const handleLogin = (userData) => { + setAuthState({ + isAuthenticated: true, + isLoading: false, + user: userData + }); setShowLoginModal(false); }; + const handleLogout = async () => { + try { + await fetch('http://192.168.2.39:3000/api/auth/logout', { + method: 'POST', + credentials: 'include' + }); + localStorage.removeItem('access_token'); + setAuthState({ + isAuthenticated: false, + isLoading: false, + user: null + }); + setShowLoginModal(true); + } catch (error) { + console.error('Logout failed:', error); + } + }; + + if (authState.isLoading) { + return ( + + + + +

Проверка авторизации...

+
+
+ ); + } + return ( - {!isAuthenticated && showLoginModal ? ( + {!authState.isAuthenticated && showLoginModal ? ( <> - {/* Логотип */} - + - setIsDarkMode((prev) => !prev)} /> + setIsDarkMode((prev) => !prev)} + /> )} diff --git a/src/Components/UI/LoginModal.jsx b/src/Components/UI/LoginModal.jsx index 3e29de2..5a3bfc8 100755 --- a/src/Components/UI/LoginModal.jsx +++ b/src/Components/UI/LoginModal.jsx @@ -16,9 +16,9 @@ const LoginModal = ({ onLogin, onClose }) => { e.preventDefault(); try { - // Отправляем данные на бэкенд - const response = await fetch(`${import.meta.env.VITE_BACK_URL}/api/auth/login`, { + const response = await fetch('http://192.168.2.39:3000/api/auth/login', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', }, @@ -28,8 +28,9 @@ const LoginModal = ({ onLogin, onClose }) => { const data = await response.json(); if (data.success) { - onLogin(); // Успешная авторизация - onClose(); // Закрыть модальное окно + localStorage.setItem('access_token', data.access_token); + onLogin(data.user); // Передаем данные пользователя + onClose(); } else { setError(data.message || "Неверный логин или пароль"); } diff --git a/src/Components/UI/auth.jsx b/src/Components/UI/auth.jsx new file mode 100644 index 0000000..9c8e0a9 --- /dev/null +++ b/src/Components/UI/auth.jsx @@ -0,0 +1,20 @@ +export const checkAuth = async () => { + try { + const response = await fetch('http://192.168.2.39:3000/api/auth/check', { + method: 'GET', + credentials: 'include', // Важно для отправки cookies + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`, + }, + }); + + if (!response.ok) { + throw new Error('Not authenticated'); + } + + return await response.json(); + } catch (err) { + console.error('Auth check failed:', err); + return { isAuthenticated: false }; + } +}; \ No newline at end of file From c7ebbcaf5c7ba6b19d78e7d58f676423e448db76 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 21 Apr 2025 02:55:11 -0400 Subject: [PATCH 2/5] fixed a bug with multiple web socket connection --- package.json | 3 +- src/Charts/Components/LineChartComponent.jsx | 2 +- src/Charts/Components/hooks.jsx | 153 +++++++++ src/Charts/MetricChart.jsx | 291 ++++++++++++++++++ src/Charts/PrometheusChart.jsx | 195 +++++------- src/Charts/WebSocketManager.jsx | 115 +++++++ src/Components/Layout/SidebarMenu.jsx | 26 +- .../Layout/SidebarMenuComponents/MenuItem.jsx | 26 +- .../SidebarMenuComponents/StatusIndicator.jsx | 25 ++ src/Components/TreeChart/StatusService.jsx | 85 +++++ src/Components/TreeChart/tabContent.jsx | 33 +- src/Style/theme.jsx | 2 +- 12 files changed, 817 insertions(+), 139 deletions(-) create mode 100644 src/Charts/Components/hooks.jsx create mode 100644 src/Charts/MetricChart.jsx create mode 100644 src/Charts/WebSocketManager.jsx create mode 100644 src/Components/Layout/SidebarMenuComponents/StatusIndicator.jsx create mode 100644 src/Components/TreeChart/StatusService.jsx diff --git a/package.json b/package.json index 74c5e26..f803480 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Charts/Components/LineChartComponent.jsx b/src/Charts/Components/LineChartComponent.jsx index 3c548ac..c48d46f 100755 --- a/src/Charts/Components/LineChartComponent.jsx +++ b/src/Charts/Components/LineChartComponent.jsx @@ -203,7 +203,7 @@ const LineChartComponent = ({ {instanceKeys.map((instance, index) => ( 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; +} \ No newline at end of file diff --git a/src/Charts/MetricChart.jsx b/src/Charts/MetricChart.jsx new file mode 100644 index 0000000..bf3fd7d --- /dev/null +++ b/src/Charts/MetricChart.jsx @@ -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 ( +
+

{title}

+ + + + + + + {!isLive && ( + + )} + + + {error && } + {loading && } + + + + + + + [ + `${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; + }} + /> + + + {refAreaLeft && refAreaRight && ( + + )} + + +
+ ); +}; + +export default React.memo(MetricChart); \ No newline at end of file diff --git a/src/Charts/PrometheusChart.jsx b/src/Charts/PrometheusChart.jsx index 04c9e20..420fd75 100755 --- a/src/Charts/PrometheusChart.jsx +++ b/src/Charts/PrometheusChart.jsx @@ -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); \ No newline at end of file diff --git a/src/Charts/WebSocketManager.jsx b/src/Charts/WebSocketManager.jsx new file mode 100644 index 0000000..aa4a46b --- /dev/null +++ b/src/Charts/WebSocketManager.jsx @@ -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(); \ No newline at end of file diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index 379d7db..7efd192 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -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 }) => { Меню )} - + {menuData && ( + + )} {/* Футер */} diff --git a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx index 69a105c..6d0eba6 100644 --- a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx +++ b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx @@ -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 && } + {hasChildren ? (isOpen ? : ) : } - {!collapsed && ( // Показываем текст только в развернутом состоянии + {!collapsed && ( <> { )} - {hasChildren && !collapsed && ( // Показываем детей только в развернутом состоянии + {hasChildren && !collapsed && ( {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) { diff --git a/src/Components/Layout/SidebarMenuComponents/StatusIndicator.jsx b/src/Components/Layout/SidebarMenuComponents/StatusIndicator.jsx new file mode 100644 index 0000000..7a291db --- /dev/null +++ b/src/Components/Layout/SidebarMenuComponents/StatusIndicator.jsx @@ -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; \ No newline at end of file diff --git a/src/Components/TreeChart/StatusService.jsx b/src/Components/TreeChart/StatusService.jsx new file mode 100644 index 0000000..1f4d6a8 --- /dev/null +++ b/src/Components/TreeChart/StatusService.jsx @@ -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(); \ No newline at end of file diff --git a/src/Components/TreeChart/tabContent.jsx b/src/Components/TreeChart/tabContent.jsx index 4072401..a7845f8 100755 --- a/src/Components/TreeChart/tabContent.jsx +++ b/src/Components/TreeChart/tabContent.jsx @@ -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 = () => ( + + {/* Заголовок */} + {/* График */} + +); + +// Компонент Skeleton для родительского контейнера +const ContainerSkeleton = () => ( + + {/* Заголовок */} + {/* Описание */} + {/* Место для дочерних элементов */} + + {[...Array(3)].map((_, i) => ( + + ))} + + +); + const tabContent = (data) => { const tabContent = {}; + // Функция для рекурсивного обхода и сбора данных // Функция для рекурсивного обхода и сбора данных const generateContent = (nodes) => { nodes.forEach((node) => { @@ -36,9 +61,11 @@ const tabContent = (data) => { const content = (

{node.title}

- tabContent[child.id].content)} /> + }> + tabContent[child.id].content)} /> +

Контент для {node.title}.

- {childrenContent} + {/*childrenContent*/}
); @@ -53,7 +80,7 @@ const tabContent = (data) => { const content = (

{node.title}

{/* Используем title узла */} - Загрузка графика...
}> + }> diff --git a/src/Style/theme.jsx b/src/Style/theme.jsx index 40c405d..017bc36 100644 --- a/src/Style/theme.jsx +++ b/src/Style/theme.jsx @@ -76,7 +76,7 @@ export const lightTheme = createTheme({ // Фоновые цвета background: { - default: "#6CACE4", // Основной фон приложения + default: "#FFFFFF", // Основной фон приложения paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей) }, From f38c8825feaaa4d09e3a87d94f10e9788cec444a Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 21 Apr 2025 03:28:14 -0400 Subject: [PATCH 3/5] removed unnecessary components --- src/Charts/Components/hooks.jsx | 153 --------------- src/Charts/MetricChart.jsx | 291 ---------------------------- src/Components/Layout/Dashboard.jsx | 4 +- 3 files changed, 2 insertions(+), 446 deletions(-) delete mode 100644 src/Charts/Components/hooks.jsx delete mode 100644 src/Charts/MetricChart.jsx diff --git a/src/Charts/Components/hooks.jsx b/src/Charts/Components/hooks.jsx deleted file mode 100644 index a9aabf7..0000000 --- a/src/Charts/Components/hooks.jsx +++ /dev/null @@ -1,153 +0,0 @@ -// 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; -} \ No newline at end of file diff --git a/src/Charts/MetricChart.jsx b/src/Charts/MetricChart.jsx deleted file mode 100644 index bf3fd7d..0000000 --- a/src/Charts/MetricChart.jsx +++ /dev/null @@ -1,291 +0,0 @@ -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 ( -
-

{title}

- - - - - - - {!isLive && ( - - )} - - - {error && } - {loading && } - - - - - - - [ - `${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; - }} - /> - - - {refAreaLeft && refAreaRight && ( - - )} - - -
- ); -}; - -export default React.memo(MetricChart); \ No newline at end of file diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx index c979b4c..9b66e58 100755 --- a/src/Components/Layout/Dashboard.jsx +++ b/src/Components/Layout/Dashboard.jsx @@ -48,9 +48,9 @@ const MainContent = styled(Box)(({ theme }) => ({ const Content = styled(Box)(({ theme }) => ({ backgroundColor: theme.palette.custom.modalBackground, - padding: theme.spacing(2.5), + //padding: theme.spacing(2.5), borderRadius: '10px', - boxShadow: theme.shadows[2], + //boxShadow: theme.shadows[2], maxWidth: '100%', overflow: 'auto', color: theme.palette.custom.modalText, From 54cf5504a408f673fe5bac7f5adfe67910d42c7a Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 21 Apr 2025 03:32:30 -0400 Subject: [PATCH 4/5] removed unnecessary components #2 --- src/App.jsx | 2 +- src/Components/TreeChart/StatusService.jsx | 85 ---------------------- 2 files changed, 1 insertion(+), 86 deletions(-) delete mode 100644 src/Components/TreeChart/StatusService.jsx diff --git a/src/App.jsx b/src/App.jsx index a176ae1..73fbf52 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,7 +4,7 @@ import Dashboard from "./Components/Layout/Dashboard"; import LoginModal from "./Components/UI/LoginModal"; import { lightTheme, darkTheme } from "./Style/theme"; import Logo from './assets/images/logo.svg?react'; -import { checkAuth } from "./Components/UI/auth"; // Убедитесь, что путь правильный +import { checkAuth } from "./Components/UI/auth"; function App() { const [authState, setAuthState] = useState({ diff --git a/src/Components/TreeChart/StatusService.jsx b/src/Components/TreeChart/StatusService.jsx deleted file mode 100644 index 1f4d6a8..0000000 --- a/src/Components/TreeChart/StatusService.jsx +++ /dev/null @@ -1,85 +0,0 @@ -// 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(); \ No newline at end of file From b4d653f3a6abfcdc5735e03ac9609e14bd502fe4 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 21 Apr 2025 09:01:36 -0400 Subject: [PATCH 5/5] corrections after the code review --- src/App.jsx | 3 ++- src/Components/UI/LoginModal.jsx | 4 +++- src/Components/UI/auth.jsx | 37 ++++++++++++++++++-------------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 73fbf52..c2372e5 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import LoginModal from "./Components/UI/LoginModal"; import { lightTheme, darkTheme } from "./Style/theme"; import Logo from './assets/images/logo.svg?react'; import { checkAuth } from "./Components/UI/auth"; +import axios from 'axios'; function App() { const [authState, setAuthState] = useState({ @@ -56,7 +57,7 @@ function App() { const handleLogout = async () => { try { - await fetch('http://192.168.2.39:3000/api/auth/logout', { + await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics`, { method: 'POST', credentials: 'include' }); diff --git a/src/Components/UI/LoginModal.jsx b/src/Components/UI/LoginModal.jsx index 5a3bfc8..d38d7b7 100755 --- a/src/Components/UI/LoginModal.jsx +++ b/src/Components/UI/LoginModal.jsx @@ -3,6 +3,7 @@ import Modal from "./Modal"; import "../../Style/LoginModal.css"; import Logo from '../../assets/images/logo.svg?react'; import TextField from '@mui/material/TextField'; +import axios from 'axios'; const LoginModal = ({ onLogin, onClose }) => { const [username, setUsername] = useState(""); @@ -16,7 +17,8 @@ const LoginModal = ({ onLogin, onClose }) => { e.preventDefault(); try { - const response = await fetch('http://192.168.2.39:3000/api/auth/login', { + const response = await axios.post( + `${import.meta.env.VITE_BACK_URL}/api/auth/login`, { method: 'POST', credentials: 'include', headers: { diff --git a/src/Components/UI/auth.jsx b/src/Components/UI/auth.jsx index 9c8e0a9..7532538 100644 --- a/src/Components/UI/auth.jsx +++ b/src/Components/UI/auth.jsx @@ -1,20 +1,25 @@ +import axios from 'axios'; + export const checkAuth = async () => { - try { - const response = await fetch('http://192.168.2.39:3000/api/auth/check', { - method: 'GET', - credentials: 'include', // Важно для отправки cookies - headers: { - 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`, - }, - }); + try { + const response = await axios.get( + `${import.meta.env.VITE_BACK_URL}/api/auth/check`, + { + withCredentials: true, // аналог `credentials: 'include'` в fetch + headers: { + Authorization: `Bearer ${localStorage.getItem('access_token') || ''}`, + }, + } + ); - if (!response.ok) { - throw new Error('Not authenticated'); - } - - return await response.json(); - } catch (err) { - console.error('Auth check failed:', err); - return { isAuthenticated: false }; + // У axios нет свойства .ok, проверяем статус 200-299 + if (response.status >= 200 && response.status < 300) { + return response.data; // Данные уже в JSON, не нужно .json() + } else { + throw new Error('Not authenticated'); } + } catch (err) { + console.error('Auth check failed:', err); + return { isAuthenticated: false }; + } }; \ No newline at end of file