From d83f05e2b569c13da4dbadb1104e8bbee13e108d Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 14 Apr 2025 04:40:02 -0400 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 From e47161acd1097f035c5e10084923f0269702c523 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Tue, 22 Apr 2025 08:59:35 -0400 Subject: [PATCH 06/18] fixed a bug with collapsing the menu, improved the MUI skeleton, fixed several visual bugs --- src/App.jsx | 65 +++++++-------- src/Charts/PrometheusChart.jsx | 45 ++++++++++- src/Components/Layout/Dashboard.jsx | 7 +- src/Components/Layout/SidebarMenu.jsx | 9 +-- src/Components/UI/LoginModal.jsx | 5 +- src/Components/UI/TreeTable.jsx | 2 +- src/Components/UI/auth.jsx | 27 +++---- src/Components/hooks/LazyChartBatchRender.jsx | 79 +++++++++++++++---- src/Style/theme.jsx | 2 +- 9 files changed, 156 insertions(+), 85 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index c2372e5..dfe1af0 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,11 +1,10 @@ import React, { useState, useMemo, useEffect } from "react"; -import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress } from "@mui/material"; +import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress, Typography } 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 { checkAuth } from "./Components/UI/auth"; -import axios from 'axios'; +import { checkAuth } from "./Components/UI/auth"; function App() { const [authState, setAuthState] = useState({ @@ -24,13 +23,11 @@ function App() { 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); @@ -42,7 +39,6 @@ function App() { setShowLoginModal(true); } }; - verifyAuth(); }, []); @@ -57,7 +53,7 @@ function App() { const handleLogout = async () => { try { - await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics`, { + await fetch('http://192.168.2.39:3000/api/auth/logout', { method: 'POST', credentials: 'include' }); @@ -73,20 +69,25 @@ function App() { } }; + // Полноэкранный лоадер во время проверки авторизации if (authState.isLoading) { return ( -

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

+ + Проверка авторизации... +
); @@ -95,26 +96,20 @@ function App() { return ( - {!authState.isAuthenticated && showLoginModal ? ( + {!authState.isAuthenticated ? ( <> - + - setShowLoginModal(false)} /> @@ -124,19 +119,15 @@ function App() { display: "flex", height: "100vh", overflow: "hidden", - bgcolor: "background.default", - color: "text.primary" + bgcolor: "background.default" }}> - + + setIsDarkMode(!isDarkMode)} + sx={{ position: "absolute", top: 10, right: 10 }} /> - - setIsDarkMode((prev) => !prev)} - /> - )} diff --git a/src/Charts/PrometheusChart.jsx b/src/Charts/PrometheusChart.jsx index 420fd75..0b1ae11 100755 --- a/src/Charts/PrometheusChart.jsx +++ b/src/Charts/PrometheusChart.jsx @@ -6,6 +6,37 @@ import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicato import { CurrentRangeDisplay } from './Components/CurrentRangeDisplay'; import { TIME_RANGES, COLORS, SECOND, MINUTE, HOUR, DAY } from './Components/constants'; import axios from 'axios'; +import Skeleton from '@mui/material/Skeleton'; +import Box from '@mui/material/Box'; + + +// Компонент Skeleton для графика +const ChartSkeleton = () => ( + + + + + + + + + + + + + + {[1, 2, 3, 4].map((_, i) => ( + + ))} + + +); const PrometheusChart = ({ metricName }) => { const [chartData, setChartData] = useState(null); @@ -395,11 +426,21 @@ const PrometheusChart = ({ metricName }) => { }, [selectedGraphRange, chartData, interpolateData]); if (chartData === null) { - return
Loading data...
; + return ; } if (Object.keys(chartData).length === 0) { - return
No data available
; + return ( + + No data available + + ); } return ( diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx index 9b66e58..a747672 100755 --- a/src/Components/Layout/Dashboard.jsx +++ b/src/Components/Layout/Dashboard.jsx @@ -58,10 +58,11 @@ const Content = styled(Box)(({ theme }) => ({ const Dashboard = () => { const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная"); - const { sidebarWidth, startResizing } = useSidebarResize(250); + const { sidebarWidth, startResizing } = useSidebarResize(290); const [tabContent, setTabContent] = useState({}); const [treeData1, setTreeData1] = useState(menuData); const [treeData2, setTreeData2] = useState(menuData); + const [collapsed, setCollapsed] = useState(false); const [statusHistories, setStatusHistories] = useState({ history1: [], history2: [], @@ -103,12 +104,14 @@ const Dashboard = () => { return ( {/* Сайдбар */} - + diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index 7efd192..98e7962 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -32,8 +32,7 @@ const SidebarResizer = styled('div')(({ theme }) => ({ zIndex: 2 })); -const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => { - const [collapsed, setCollapsed] = useState(false); +const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, setCollapsed }) => { const [hovered, setHovered] = useState(false); const [menuData, setMenuData] = useState(data); @@ -105,11 +104,7 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => { } }} > - {collapsed ? ( - hovered ? : - ) : ( - - )} + {collapsed ? : } diff --git a/src/Components/UI/LoginModal.jsx b/src/Components/UI/LoginModal.jsx index d38d7b7..22559d6 100755 --- a/src/Components/UI/LoginModal.jsx +++ b/src/Components/UI/LoginModal.jsx @@ -17,8 +17,7 @@ const LoginModal = ({ onLogin, onClose }) => { e.preventDefault(); try { - const response = await axios.post( - `${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: { @@ -31,7 +30,7 @@ const LoginModal = ({ onLogin, onClose }) => { if (data.success) { localStorage.setItem('access_token', data.access_token); - onLogin(data.user); // Передаем данные пользователя + onLogin(data.user); onClose(); } else { setError(data.message || "Неверный логин или пароль"); diff --git a/src/Components/UI/TreeTable.jsx b/src/Components/UI/TreeTable.jsx index bbc86b1..3087ddb 100755 --- a/src/Components/UI/TreeTable.jsx +++ b/src/Components/UI/TreeTable.jsx @@ -324,7 +324,7 @@ const TreeTable = ({ data }) => { diff --git a/src/Components/UI/auth.jsx b/src/Components/UI/auth.jsx index 7532538..6d3699c 100644 --- a/src/Components/UI/auth.jsx +++ b/src/Components/UI/auth.jsx @@ -2,24 +2,21 @@ import axios from 'axios'; export const checkAuth = async () => { try { - const response = await axios.get( - `${import.meta.env.VITE_BACK_URL}/api/auth/check`, - { - withCredentials: true, // аналог `credentials: 'include'` в fetch + 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') || ''}`, + 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`, }, - } - ); + }); - // У axios нет свойства .ok, проверяем статус 200-299 - if (response.status >= 200 && response.status < 300) { - return response.data; // Данные уже в JSON, не нужно .json() - } else { + if (!response.ok) { throw new Error('Not authenticated'); - } - } catch (err) { - console.error('Auth check failed:', err); - return { isAuthenticated: false }; + } + + return await response.json(); +} catch (err) { + console.error('Auth check failed:', err); + return { isAuthenticated: false }; } }; \ No newline at end of file diff --git a/src/Components/hooks/LazyChartBatchRender.jsx b/src/Components/hooks/LazyChartBatchRender.jsx index a12f491..d9f1dca 100644 --- a/src/Components/hooks/LazyChartBatchRender.jsx +++ b/src/Components/hooks/LazyChartBatchRender.jsx @@ -1,28 +1,73 @@ -import { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; -const LazyChartBatchRenderer = ({ charts, batchSize = 3, delay = 150 }) => { - const [visibleCharts, setVisibleCharts] = useState([]); +const LazyChartBatchRenderer = ({ charts }) => { + const [visibleIndices, setVisibleIndices] = useState(new Set()); + const placeholderRefs = useRef([]); + + const ChartSkeleton = () => ( + + + + + + + + + + + {[1, 2, 3, 4].map((_, i) => ( + + ))} + + + ); useEffect(() => { - let index = 0; - const timer = setInterval(() => { - setVisibleCharts((prev) => [ - ...prev, - ...charts.slice(index, index + batchSize), - ]); - index += batchSize; - if (index >= charts.length) clearInterval(timer); - }, delay); + const observer = new IntersectionObserver( + (entries) => { + setVisibleIndices((prev) => { + const updated = new Set(prev); + entries.forEach((entry) => { + const index = parseInt(entry.target.dataset.index, 10); + if (entry.isIntersecting) { + updated.add(index); + } else { + updated.delete(index); + } + }); + return updated; + }); + }, + { + root: null, + rootMargin: '200px', + threshold: 0.1, + } + ); - return () => clearInterval(timer); + placeholderRefs.current.forEach((ref) => { + if (ref) observer.observe(ref); + }); + + return () => { + observer.disconnect(); + }; }, [charts]); return ( - <> - {visibleCharts.map((chart, idx) => ( -
{chart}
+
+ {charts.map((chart, index) => ( +
(placeholderRefs.current[index] = el)} + data-index={index} + > + {visibleIndices.has(index) ? chart : } +
))} - +
); }; diff --git a/src/Style/theme.jsx b/src/Style/theme.jsx index 017bc36..6d33a29 100644 --- a/src/Style/theme.jsx +++ b/src/Style/theme.jsx @@ -139,7 +139,7 @@ export const darkTheme = createTheme({ // Фоновые цвета background: { - default: "#1E1E1E", // Основной фон приложения + default: "#2d2d2d", // Основной фон приложения paper: "#2d2d2d", // Фон "бумажных" поверхностей }, From 40d8046617f2ad86c24f96b7599d960ef98b0e1b Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Wed, 23 Apr 2025 08:25:15 -0400 Subject: [PATCH 07/18] modified the skeleton MUI --- src/Charts/Components/ChartSkeleton.jsx | 34 +++++ src/Charts/Components/LineChartComponent.jsx | 18 ++- src/Components/hooks/LazyChartBatchRender.jsx | 143 +++++++++++++++--- 3 files changed, 166 insertions(+), 29 deletions(-) create mode 100644 src/Charts/Components/ChartSkeleton.jsx diff --git a/src/Charts/Components/ChartSkeleton.jsx b/src/Charts/Components/ChartSkeleton.jsx new file mode 100644 index 0000000..7500023 --- /dev/null +++ b/src/Charts/Components/ChartSkeleton.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Box, Skeleton } from '@mui/material'; + +const ChartSkeleton = ({ count = 1 }) => { + return ( + <> + {Array.from({ length: count }).map((_, index) => ( + + + + + + + + {[1, 2, 3, 4].map((i) => ( + + ))} + + + ))} + + ); +}; + +export default ChartSkeleton; \ No newline at end of file diff --git a/src/Charts/Components/LineChartComponent.jsx b/src/Charts/Components/LineChartComponent.jsx index c48d46f..149f1fb 100755 --- a/src/Charts/Components/LineChartComponent.jsx +++ b/src/Charts/Components/LineChartComponent.jsx @@ -1,6 +1,8 @@ import React, { useState, useRef, useEffect } from 'react'; import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer, ReferenceArea } from 'recharts'; +import { Skeleton } from '@mui/material'; import { HOUR, DAY } from './constants'; + const TIME_FORMATS = { LONG: 'dd.MM HH:mm', // Для диапазона > 24 часов MEDIUM: 'HH:mm', // Для диапазона > 1 часа @@ -51,7 +53,6 @@ const LineChartComponent = ({ ? Object.keys(displayData[0]).filter(k => !['timestamp', 'time', 'fullTime'].includes(k)) : []; - // Функция для определения оптимального формата времени в зависимости от диапазона const getTimeFormat = () => { if (!data.length) return TIME_FORMATS.SHORT; @@ -77,7 +78,6 @@ const LineChartComponent = ({ const handleMouseDown = (e) => { if (!e) return; - // Получаем индекс точки по координатам const activeIndex = e.activeTooltipIndex; if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return; @@ -113,7 +113,6 @@ const LineChartComponent = ({ const startIndex = Math.min(selectionArea.startIndex, selectionArea.endIndex); const endIndex = Math.max(selectionArea.startIndex, selectionArea.endIndex); - // Нормализуем индексы к диапазону [0, 1] для родительского компонента const normalizedStart = startIndex / (data.length - 1); const normalizedEnd = endIndex / (data.length - 1); @@ -152,7 +151,18 @@ const LineChartComponent = ({ }; if (!data.length) { - return
Нет данных для отображения
; + return ( + + + + + ); } return ( diff --git a/src/Components/hooks/LazyChartBatchRender.jsx b/src/Components/hooks/LazyChartBatchRender.jsx index d9f1dca..ed351e7 100644 --- a/src/Components/hooks/LazyChartBatchRender.jsx +++ b/src/Components/hooks/LazyChartBatchRender.jsx @@ -1,13 +1,23 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import Box from '@mui/material/Box'; import Skeleton from '@mui/material/Skeleton'; const LazyChartBatchRenderer = ({ charts }) => { const [visibleIndices, setVisibleIndices] = useState(new Set()); const placeholderRefs = useRef([]); + const observerRef = useRef(null); + const cleanupTimeoutRef = useRef(null); const ChartSkeleton = () => ( - + @@ -15,46 +25,124 @@ const LazyChartBatchRenderer = ({ charts }) => { - + {[1, 2, 3, 4].map((_, i) => ( - + ))} ); + + const isElementFarFromViewport = useCallback((element) => { + if (!element) return true; + + const rect = element.getBoundingClientRect(); + const buffer = window.innerHeight * 1.5; + + + return rect.bottom < -buffer || rect.top > window.innerHeight + buffer; + }, []); + + + const updateVisibleIndices = useCallback(() => { + const newVisibleIndices = new Set(); + + placeholderRefs.current.forEach((ref, index) => { + if (ref && !isElementFarFromViewport(ref)) { + newVisibleIndices.add(index); + } + }); + + setVisibleIndices(prev => { + if (newVisibleIndices.size === prev.size && + Array.from(newVisibleIndices).every(i => prev.has(i))) { + return prev; + } + return newVisibleIndices; + }); + }, [isElementFarFromViewport]); + useEffect(() => { - const observer = new IntersectionObserver( + observerRef.current = new IntersectionObserver( (entries) => { - setVisibleIndices((prev) => { - const updated = new Set(prev); - entries.forEach((entry) => { - const index = parseInt(entry.target.dataset.index, 10); - if (entry.isIntersecting) { - updated.add(index); - } else { - updated.delete(index); - } - }); - return updated; + entries.forEach(entry => { + if (entry.isIntersecting) { + updateVisibleIndices(); + } }); }, { root: null, - rootMargin: '200px', - threshold: 0.1, + rootMargin: '500px 0px', + threshold: 0.01 } ); - placeholderRefs.current.forEach((ref) => { - if (ref) observer.observe(ref); + placeholderRefs.current.forEach(ref => { + if (ref) observerRef.current.observe(ref); }); - return () => { - observer.disconnect(); + const handleScroll = () => { + if (cleanupTimeoutRef.current) { + clearTimeout(cleanupTimeoutRef.current); + } + + cleanupTimeoutRef.current = setTimeout(() => { + updateVisibleIndices(); + + setVisibleIndices(prev => { + const updated = new Set(prev); + let changed = false; + + placeholderRefs.current.forEach((ref, index) => { + if (ref && isElementFarFromViewport(ref) && prev.has(index)) { + updated.delete(index); + changed = true; + } + }); + + return changed ? updated : prev; + }); + }, 150); }; - }, [charts]); + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', updateVisibleIndices, { passive: true }); + + updateVisibleIndices(); + + return () => { + if (cleanupTimeoutRef.current) clearTimeout(cleanupTimeoutRef.current); + if (observerRef.current) observerRef.current.disconnect(); + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', updateVisibleIndices); + }; + }, [updateVisibleIndices]); + + const shouldShowChart = (index) => { + return visibleIndices.has(index) || + visibleIndices.has(index - 1) || + visibleIndices.has(index + 1); + }; return (
@@ -63,12 +151,17 @@ const LazyChartBatchRenderer = ({ charts }) => { key={index} ref={(el) => (placeholderRefs.current[index] = el)} data-index={index} + style={{ + minHeight: '400px', + marginBottom: '20px', + transition: 'opacity 0.3s ease', + }} > - {visibleIndices.has(index) ? chart : } + {shouldShowChart(index) ? chart : }
))} ); }; -export default LazyChartBatchRenderer; +export default React.memo(LazyChartBatchRenderer); \ No newline at end of file From 6fd5d1aed258f540993079e60e8e5ae4ae3bada8 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Wed, 23 Apr 2025 08:48:09 -0400 Subject: [PATCH 08/18] fixed a bug with tabs --- src/Components/Layout/SidebarMenu.jsx | 14 ++++-- src/Components/TreeChart/tabContent.jsx | 45 +++++------------ src/Components/hooks/useTabs.jsx | 64 ++++++++++++++++++++----- 3 files changed, 74 insertions(+), 49 deletions(-) diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index 98e7962..af22fb1 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Drawer, List, @@ -35,11 +35,10 @@ const SidebarResizer = styled('div')(({ theme }) => ({ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, setCollapsed }) => { const [hovered, setHovered] = useState(false); const [menuData, setMenuData] = useState(data); + const contentCache = useRef({}); - // Обновляем статусы при изменении данных useEffect(() => { if (data) { - // Создаем глубокую копию данных, чтобы не мутировать исходные const dataCopy = JSON.parse(JSON.stringify(data)); statusManager1.updateStatuses(dataCopy); setMenuData(dataCopy); @@ -51,9 +50,16 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, }; const handleSelectItem = (id, title, children) => { - onOpenTab(id, title, children); + onOpenTab(id, title); + + contentCache.current = tabContent({ items: children }, contentCache.current); + if (contentCache.current[id]) { + onOpenTab(id, title, contentCache.current[id].content); + } }; + + const drawerWidth = collapsed ? 64 : sidebarWidth; return ( diff --git a/src/Components/TreeChart/tabContent.jsx b/src/Components/TreeChart/tabContent.jsx index a7845f8..15596a8 100755 --- a/src/Components/TreeChart/tabContent.jsx +++ b/src/Components/TreeChart/tabContent.jsx @@ -5,26 +5,23 @@ import Box from '@mui/material/Box'; const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart')); import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender"; -// Функция для генерации названия метрики на основе id const getMetricName = (id) => { return `zvks_apiforsnmp_measure_${id}`; }; -// Функция для рекурсивного сбора всех id потомков const getAllChildIds = (node) => { let ids = []; if (node.id) { - ids.push(node.id); // Добавляем id текущего узла + ids.push(node.id); } if (node.items && node.items.length > 0) { node.items.forEach((child) => { - ids = ids.concat(getAllChildIds(child)); // Рекурсивно собираем id потомков + ids = ids.concat(getAllChildIds(child)); }); } return ids; }; -// Компонент Skeleton для графика const ChartSkeleton = () => ( {/* Заголовок */} @@ -32,7 +29,6 @@ const ChartSkeleton = () => ( ); -// Компонент Skeleton для родительского контейнера const ContainerSkeleton = () => ( {/* Заголовок */} @@ -46,69 +42,52 @@ const ContainerSkeleton = () => ( ); -const tabContent = (data) => { - const tabContent = {}; +const tabContent = (data, existingContent = {}) => { + const tabContent = { ...existingContent }; - // Функция для рекурсивного обхода и сбора данных - // Функция для рекурсивного обхода и сбора данных const generateContent = (nodes) => { nodes.forEach((node) => { - // Если у узла есть вложенные элементы, рекурсивно обрабатываем их + if (tabContent[node.id]) return; + if (node.items && node.items.length > 0) { - // Создаем контент для родителя - const childrenContent = generateContent(node.items); + generateContent(node.items); const content = ( -
+

{node.title}

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

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

- {/*childrenContent*/}
); - // Сохраняем контент для текущего id tabContent[node.id] = { title: node.title, content: content, }; } else { - // Если у узла нет вложенных элементов, это самый нижний уровень const metricName = getMetricName(node.id); const content = (
-

{node.title}

{/* Используем title узла */} +

{node.title}

}>
); - // Сохраняем контент для текущего id tabContent[node.id] = { title: node.title, content: content, }; } }); - - // Возвращаем контент для всех потомков - return ( -
- {nodes.map((node) => ( -
{tabContent[node.id].content}
- ))} -
- ); }; - // Начинаем обработку с корневого уровня if (data.items && data.items.length > 0) { generateContent(data.items); - } else { - console.warn("Данные отсутствуют или массив items пуст"); } return tabContent; diff --git a/src/Components/hooks/useTabs.jsx b/src/Components/hooks/useTabs.jsx index 188cdf5..2df5f13 100644 --- a/src/Components/hooks/useTabs.jsx +++ b/src/Components/hooks/useTabs.jsx @@ -4,23 +4,63 @@ const useTabs = (initialTab) => { const [tabs, setTabs] = useState([]); const [activeTab, setActiveTab] = useState(initialTab); - const handleOpenTab = useCallback((id, title) => { - setTabs((prevTabs) => - prevTabs.some((tab) => tab.id === id) - ? prevTabs - : [...prevTabs, { id, title }] - ); + const handleOpenTab = useCallback((id, title, content) => { + setTabs((prevTabs) => { + const existingTabIndex = prevTabs.findIndex(tab => tab.id === id); + + if (existingTabIndex >= 0) { + return prevTabs.map((tab, index) => ({ + ...tab, + content: content || tab.content, + active: index === existingTabIndex + })); + } + + // Добавляем новую вкладку + return [ + ...prevTabs.map(tab => ({ ...tab, active: false })), + { + id, + title, + content: content ||
Loading...
, + active: true + } + ]; + }); setActiveTab(id); }, []); const handleCloseTab = useCallback((id) => { - setTabs((prevTabs) => prevTabs.filter((tab) => tab.id !== id)); - if (activeTab === id) { - setActiveTab(tabs.length > 1 ? tabs[tabs.length - 2].id : initialTab); - } - }, [activeTab, tabs, initialTab]); + setTabs((prevTabs) => { + const newTabs = prevTabs.filter((tab) => tab.id !== id); - return { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab }; + if (activeTab === id) { + setActiveTab(newTabs.length > 0 + ? newTabs[newTabs.length - 1].id + : initialTab + ); + } + + return newTabs; + }); + }, [activeTab, initialTab]); + + const updateTabContent = useCallback((id, content) => { + setTabs(prevTabs => + prevTabs.map(tab => + tab.id === id ? { ...tab, content } : tab + ) + ); + }, []); + + return { + tabs, + activeTab, + handleOpenTab, + handleCloseTab, + setActiveTab, + updateTabContent + }; }; export default useTabs; \ No newline at end of file From bbbcd932ad9b26350776178c9d3ec5b8b230737d Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Wed, 23 Apr 2025 09:54:17 -0400 Subject: [PATCH 09/18] improved the design --- src/App.jsx | 11 ++- src/Components/Layout/Dashboard.jsx | 60 ++++++++++------ src/Components/Layout/SidebarMenu.jsx | 19 +++-- .../SidebarMenuComponents/SidebarFooter.jsx | 70 +++++++++++++------ src/Components/UI/MUItabs.jsx | 2 +- src/Components/hooks/TabContent.jsx | 2 +- 6 files changed, 107 insertions(+), 57 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index dfe1af0..abbe919 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -121,12 +121,11 @@ function App() { overflow: "hidden", bgcolor: "background.default" }}> - - - setIsDarkMode(!isDarkMode)} - sx={{ position: "absolute", top: 10, right: 10 }} + )} diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx index a747672..5df3408 100755 --- a/src/Components/Layout/Dashboard.jsx +++ b/src/Components/Layout/Dashboard.jsx @@ -41,22 +41,20 @@ const MainContent = styled(Box)(({ theme }) => ({ flexGrow: 1, display: 'flex', flexDirection: 'column', - padding: theme.spacing(2.5), // 20px + padding: theme.spacing(2.5), overflow: 'auto', backgroundColor: theme.palette.background.default, })); const Content = styled(Box)(({ theme }) => ({ backgroundColor: theme.palette.custom.modalBackground, - //padding: theme.spacing(2.5), borderRadius: '10px', - //boxShadow: theme.shadows[2], maxWidth: '100%', overflow: 'auto', color: theme.palette.custom.modalText, })); -const Dashboard = () => { +const Dashboard = ({ isDarkMode, setIsDarkMode }) => { const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная"); const { sidebarWidth, startResizing } = useSidebarResize(290); const [tabContent, setTabContent] = useState({}); @@ -112,31 +110,49 @@ const Dashboard = () => { startResizing={startResizing} collapsed={collapsed} setCollapsed={setCollapsed} + isDarkMode={isDarkMode} + setIsDarkMode={setIsDarkMode} /> {/* Основной контент */} - - {/* Вкладки */} - - - {/* Контент вкладки */} - - + {/* Вкладки*/} + + - - + + + {/* Остальной контент */} + + {/* Контент вкладки */} + + + + + ); }; diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index af22fb1..ca4fc59 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -17,6 +17,7 @@ import MenuItem from "./SidebarMenuComponents/MenuItem"; import SidebarFooter from "./SidebarMenuComponents/SidebarFooter"; import { statusManager1 } from "../TreeChart/dataUtils"; + const SidebarResizer = styled('div')(({ theme }) => ({ width: "5px", cursor: "ew-resize", @@ -32,10 +33,10 @@ const SidebarResizer = styled('div')(({ theme }) => ({ zIndex: 2 })); -const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, setCollapsed }) => { +const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, setCollapsed, isDarkMode, setIsDarkMode }) => { const [hovered, setHovered] = useState(false); const [menuData, setMenuData] = useState(data); - const contentCache = useRef({}); + const contentCache = useRef({}); useEffect(() => { if (data) { @@ -51,14 +52,14 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, const handleSelectItem = (id, title, children) => { onOpenTab(id, title); - + contentCache.current = tabContent({ items: children }, contentCache.current); if (contentCache.current[id]) { onOpenTab(id, title, contentCache.current[id].content); } }; - + const drawerWidth = collapsed ? 64 : sidebarWidth; @@ -149,9 +150,13 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, {/* Футер */} - {!collapsed && ( - - )} + + + {/* Ресайзер */} diff --git a/src/Components/Layout/SidebarMenuComponents/SidebarFooter.jsx b/src/Components/Layout/SidebarMenuComponents/SidebarFooter.jsx index 0d4d4e4..54a2b7b 100644 --- a/src/Components/Layout/SidebarMenuComponents/SidebarFooter.jsx +++ b/src/Components/Layout/SidebarMenuComponents/SidebarFooter.jsx @@ -1,9 +1,13 @@ import React from "react"; +import { Brightness4, Brightness7 } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; import { List, ListItem, ListItemText, - styled + styled, + Switch, + Box } from "@mui/material"; const FooterList = styled(List)(({ theme }) => ({ @@ -18,31 +22,57 @@ const FooterListItem = styled(ListItem)(({ theme }) => ({ backgroundColor: theme.palette.custom.sidebarHover, }, padding: theme.spacing(1, 2), + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center' })); -const SidebarFooter = () => { +const SidebarFooter = ({ collapsed, isDarkMode, setIsDarkMode }) => { return ( - - - - - + {!collapsed && ( + + + + )} + + {!collapsed && ( + + )} + + + + setIsDarkMode(!isDarkMode)} + sx={{ color: 'custom.sidebarText' }} + > + {isDarkMode ? : } + + + {!collapsed && ( + setIsDarkMode(!isDarkMode)} + size="small" + /> + )} + ); }; -export default SidebarFooter; \ No newline at end of file +export default SidebarFooter; diff --git a/src/Components/UI/MUItabs.jsx b/src/Components/UI/MUItabs.jsx index 0ea89aa..4a03b60 100644 --- a/src/Components/UI/MUItabs.jsx +++ b/src/Components/UI/MUItabs.jsx @@ -16,7 +16,7 @@ const StyledTab = styled(Tab)(({ theme }) => ({ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { const handleMouseDown = (e, id) => { - if (e.button === 1) { // Middle mouse button + if (e.button === 1) { e.preventDefault(); onCloseTab(id); } diff --git a/src/Components/hooks/TabContent.jsx b/src/Components/hooks/TabContent.jsx index bdb6226..3ab4279 100644 --- a/src/Components/hooks/TabContent.jsx +++ b/src/Components/hooks/TabContent.jsx @@ -6,7 +6,7 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO if (activeTab === "Главная") { return (
-

Общий мониторинг состояния системы

+

Общий мониторинг состояния системы

From d5aa312104296e1c53c8bb7b3a918dd169f3a422 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Wed, 23 Apr 2025 10:19:29 -0400 Subject: [PATCH 10/18] Added the logo to the side menu --- src/Components/Layout/SidebarMenu.jsx | 45 +++++++++++++++++++++-- src/assets/images/system_monitor_icon.svg | 11 ++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 src/assets/images/system_monitor_icon.svg diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index ca4fc59..f0ba272 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -1,4 +1,7 @@ import React, { useState, useEffect, useRef } from "react"; +import FullLogo from '../../assets/images/logo.svg?react'; +import MiniLogo from '../../assets/images/system_monitor_icon.svg?react'; + import { Drawer, List, @@ -91,15 +94,50 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, }, }} > - {/* Кнопка сворачивания/разворачивания */} + {/* Верхняя часть с логотипом и кнопкой */} + {/* Логотип - центрируется в доступном пространстве */} + + + + + {/* Мини-логотип (только в свернутом состоянии) */} + {collapsed && ( + + + + )} + + {/* Кнопка сворачивания/разворачивания */} {collapsed ? : } diff --git a/src/assets/images/system_monitor_icon.svg b/src/assets/images/system_monitor_icon.svg new file mode 100644 index 0000000..ba02f29 --- /dev/null +++ b/src/assets/images/system_monitor_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + From b9a2be4860f3a7a420390a478066453780a4e026 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 28 Apr 2025 09:27:07 -0400 Subject: [PATCH 11/18] prepared the graph for refactoring and added an indicator --- src/Charts/Components/ChartSkeleton.jsx | 56 ++++++------ .../Components/hooks/useMetricsData.jsx | 0 .../Components/hooks/useTimeHandlers.jsx | 0 src/Charts/Components/hooks/useWebSocket.jsx | 0 src/Charts/PrometheusChart.jsx | 85 +++++++++++-------- src/Charts/WebSocketManager.jsx | 12 ++- src/Components/hooks/TabContent.jsx | 47 ++++++++++ 7 files changed, 129 insertions(+), 71 deletions(-) create mode 100644 src/Charts/Components/hooks/useMetricsData.jsx create mode 100644 src/Charts/Components/hooks/useTimeHandlers.jsx create mode 100644 src/Charts/Components/hooks/useWebSocket.jsx diff --git a/src/Charts/Components/ChartSkeleton.jsx b/src/Charts/Components/ChartSkeleton.jsx index 7500023..090f1e7 100644 --- a/src/Charts/Components/ChartSkeleton.jsx +++ b/src/Charts/Components/ChartSkeleton.jsx @@ -1,34 +1,26 @@ -import React from 'react'; -import { Box, Skeleton } from '@mui/material'; +const ChartSkeleton = () => ( + + + + -const ChartSkeleton = ({ count = 1 }) => { - return ( - <> - {Array.from({ length: count }).map((_, index) => ( - - - - - - - - {[1, 2, 3, 4].map((i) => ( - - ))} - - + + + + + + + + + {[1, 2, 3, 4].map((_, i) => ( + ))} - - ); -}; - -export default ChartSkeleton; \ No newline at end of file + + +); \ No newline at end of file diff --git a/src/Charts/Components/hooks/useMetricsData.jsx b/src/Charts/Components/hooks/useMetricsData.jsx new file mode 100644 index 0000000..e69de29 diff --git a/src/Charts/Components/hooks/useTimeHandlers.jsx b/src/Charts/Components/hooks/useTimeHandlers.jsx new file mode 100644 index 0000000..e69de29 diff --git a/src/Charts/Components/hooks/useWebSocket.jsx b/src/Charts/Components/hooks/useWebSocket.jsx new file mode 100644 index 0000000..e69de29 diff --git a/src/Charts/PrometheusChart.jsx b/src/Charts/PrometheusChart.jsx index 0b1ae11..1a8b20c 100755 --- a/src/Charts/PrometheusChart.jsx +++ b/src/Charts/PrometheusChart.jsx @@ -101,53 +101,63 @@ const PrometheusChart = ({ metricName }) => { }, []); - const processMetricsData = useCallback((response) => { + const processMetricsData = useCallback((response, replace = false) => { console.log('Processing metrics data:', response); if (response.metric !== metricName) return; const dataArray = Array.isArray(response.data) ? response.data : [response.data]; if (!dataArray.length) return; - setChartData(prev => { - const newData = { ...(prev || {}) }; - const rangeSeconds = useCustomRange - ? (endDate.getTime() - startDate.getTime()) / 1000 - : selectedRange.value; + const newData = {}; + const rangeSeconds = useCustomRange + ? (endDate.getTime() - startDate.getTime()) / 1000 + : selectedRange.value; - dataArray.forEach(item => { - const instance = item.instance || 'default'; - if (!newData[instance]) newData[instance] = []; + dataArray.forEach(item => { + const instance = item.instance || 'default'; + if (!newData[instance]) newData[instance] = []; - // Унифицированная конвертация timestamp - let timestamp; - if (typeof item.timestamp === 'number') { - // Определяем, в секундах или миллисекундах пришел timestamp - timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000; - } else { - timestamp = Date.now(); - } + let timestamp; + if (typeof item.timestamp === 'number') { + timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000; + } else { + timestamp = Date.now(); + } - const value = parseFloat(item.value); - const formattedTime = formatTime(timestamp, rangeSeconds); + const value = parseFloat(item.value); + const formattedTime = formatTime(timestamp, rangeSeconds); - newData[instance].push({ - time: formattedTime.display, - fullTime: formattedTime.fullDisplay, - value: value, - timestamp: timestamp - }); + newData[instance].push({ + time: formattedTime.display, + fullTime: formattedTime.fullDisplay, + value, + timestamp }); - - // Сортируем и ограничиваем данные - Object.keys(newData).forEach(instance => { - newData[instance] = newData[instance] - .sort((a, b) => a.timestamp - b.timestamp) - .slice(-1000); - }); - return newData; }); + + Object.keys(newData).forEach(instance => { + newData[instance] = newData[instance] + .sort((a, b) => a.timestamp - b.timestamp) + .slice(-1000); + }); + + if (replace) { + setChartData(newData); // Заменяем полностью + } else { + setChartData(prev => { + const merged = { ...(prev || {}) }; + Object.keys(newData).forEach(instance => { + if (!merged[instance]) merged[instance] = []; + merged[instance] = [...merged[instance], ...newData[instance]] + .sort((a, b) => a.timestamp - b.timestamp) + .slice(-1000); + }); + return merged; + }); + } }, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]); + const fetchData = useCallback(() => { if (isSelectingRange) return; @@ -197,7 +207,7 @@ const PrometheusChart = ({ metricName }) => { processMetricsData({ metric: metricName, data: processedData - }); + }, true); } } catch (error) { console.error('Ошибка при получении кастомных данных:', error); @@ -340,7 +350,9 @@ const PrometheusChart = ({ metricName }) => { useEffect(() => { // Обработчик данных с сервера const handleMetricsData = (data) => { - processMetricsData({ metric: metricName, data }); + if (!useCustomRange) { + processMetricsData({ metric: metricName, data }); + } }; // Подписываемся на обновления метрики @@ -359,7 +371,8 @@ const PrometheusChart = ({ metricName }) => { clearInterval(intervalRef.current); } }; - }, [metricName, processMetricsData]); + }, [metricName, useCustomRange, processMetricsData]); + useEffect(() => { if (useCustomRange && !isSelectingRange) { diff --git a/src/Charts/WebSocketManager.jsx b/src/Charts/WebSocketManager.jsx index aa4a46b..ff542a0 100644 --- a/src/Charts/WebSocketManager.jsx +++ b/src/Charts/WebSocketManager.jsx @@ -1,4 +1,3 @@ -// src/services/WebSocketManager.js import { io } from 'socket.io-client'; class WebSocketManager { @@ -7,13 +6,16 @@ class WebSocketManager { this.subscribers = new Map(); this.connectionStatus = 'disconnected'; this.connectionCallbacks = new Set(); + this.connecting = false; } connect() { - if (this.socket && (this.socket.connected || this.socket.reconnecting)) { + if (this.socket?.connected || this.connecting) { return this.socket; } + this.connecting = true; + this.socket = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, { transports: ['websocket'], reconnection: true, @@ -24,11 +26,13 @@ class WebSocketManager { this.socket.on('connect', () => { this.connectionStatus = 'connected'; + this.connecting = false; this.notifyConnectionStatus(); }); this.socket.on('disconnect', (reason) => { this.connectionStatus = 'disconnected'; + this.connecting = false; this.notifyConnectionStatus(); if (reason === 'io server disconnect') this.socket.connect(); }); @@ -50,7 +54,9 @@ class WebSocketManager { } subscribe(metricName, callback) { - this.connect(); + if (!this.socket?.connected) { + this.connect(); + } if (!this.subscribers.has(metricName)) { this.subscribers.set(metricName, new Set()); diff --git a/src/Components/hooks/TabContent.jsx b/src/Components/hooks/TabContent.jsx index 3ab4279..a1c8102 100644 --- a/src/Components/hooks/TabContent.jsx +++ b/src/Components/hooks/TabContent.jsx @@ -1,9 +1,30 @@ import SystemStatusChart from "../../Charts/SystemStatusChart"; import TreeTable from "../UI/TreeTable"; import FlowChart from "../TreeChart/FlowChart"; +import { getStatusColor } from "../TreeChart/dataUtils"; + const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleOpenTab }) => { + // Функция для подсчета количества элементов каждого статуса + const countStatuses = (data) => { + const counts = { green: 0, yellow: 0, orange: 0, red: 0 }; + + const countRecursive = (node) => { + if (node.status) { + counts[node.status]++; + } + if (node.items && node.items.length > 0) { + node.items.forEach(child => countRecursive(child)); + } + }; + + countRecursive(data); + return counts; + }; + if (activeTab === "Главная") { + const statusCounts = treeData1 ? countStatuses(treeData1) : { green: 0, yellow: 0, orange: 0, red: 0 }; + return (

Общий мониторинг состояния системы

@@ -17,6 +38,32 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO
+ + {/* Контейнер для индикаторов статусов */} +
+ {Object.entries(statusCounts).map(([status, count]) => ( +
+ {count} +
+ ))} +
+
From 4dfd972615de9ed8fd98408760e7e9dc36856284 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 5 May 2025 09:11:48 -0400 Subject: [PATCH 12/18] graph refactor --- src/Charts2/Components/DateRangeSelector.jsx | 97 +++++++ src/Charts2/Components/LineChartComponent.jsx | 68 +++++ src/Charts2/Components/metricsService.jsx | 142 +++++++++++ src/Charts2/PrometheusChart.jsx | 169 +++++++++++++ .../Layout/SidebarMenuComponents/MenuItem.jsx | 2 +- src/Components/TreeChart/FlowChart.jsx | 2 - .../FlowChartComponents/DataParser.jsx | 6 +- .../FlowChartComponents/NodeWrapper.jsx | 12 +- .../FlowChartComponents/useFlowChart.jsx | 2 +- .../FlowChartComponents/useNodeHandlers.jsx | 1 - src/Components/TreeChart/dataUtils.jsx | 24 +- src/Components/TreeChart/menuData.json | 236 +++++++----------- src/Components/TreeChart/tabContent.jsx | 43 ++-- src/main.jsx | 2 - 14 files changed, 616 insertions(+), 190 deletions(-) create mode 100644 src/Charts2/Components/DateRangeSelector.jsx create mode 100644 src/Charts2/Components/LineChartComponent.jsx create mode 100644 src/Charts2/Components/metricsService.jsx create mode 100644 src/Charts2/PrometheusChart.jsx diff --git a/src/Charts2/Components/DateRangeSelector.jsx b/src/Charts2/Components/DateRangeSelector.jsx new file mode 100644 index 0000000..4cec48e --- /dev/null +++ b/src/Charts2/Components/DateRangeSelector.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; + +const DateRangeSelector = ({ + startDate, + endDate, + onStartDateChange, + onEndDateChange, + onApply +}) => { + return ( +
+
+ Укажите диапазон дат: +
+
+
+ + } + /> +
+
+ + } + /> +
+ +
+
+ ); +}; + +export default DateRangeSelector; \ No newline at end of file diff --git a/src/Charts2/Components/LineChartComponent.jsx b/src/Charts2/Components/LineChartComponent.jsx new file mode 100644 index 0000000..4a5cea5 --- /dev/null +++ b/src/Charts2/Components/LineChartComponent.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; + +const LineChartComponent = ({ + data, + title, + description, + metaInfo, + dataKey = 'value', + lineColor = '#8884d8', + height = 400, + showLegend = true, + showGrid = true, + customTooltip, + customXAxisFormatter, + customYAxis, + additionalLines = [] +}) => { + return ( +
+ {title &&

{title}

} + {description && ( +

{description}

+ )} + {metaInfo && ( +
+ {metaInfo} +
+ )} + + + + {showGrid && } + new Date(timestamp).toLocaleTimeString())} + /> + {customYAxis || } + new Date(timestamp).toLocaleString()} + /> + {showLegend && } + + {additionalLines.map((lineProps, index) => ( + + ))} + + +
+ ); +}; + +export default LineChartComponent; \ No newline at end of file diff --git a/src/Charts2/Components/metricsService.jsx b/src/Charts2/Components/metricsService.jsx new file mode 100644 index 0000000..644c14a --- /dev/null +++ b/src/Charts2/Components/metricsService.jsx @@ -0,0 +1,142 @@ +import { io } from 'socket.io-client'; + +class MetricsService { + constructor(baseUrl) { + console.log('MetricsService constructor'); + this.baseUrl = baseUrl || window.location.origin; + this.socket = null; + this.subscriptions = new Map(); + } + + // HTTP методы - адаптированы под ваш бэкенд + async fetchMetricsRange(metric, start, end, step = 15) { + try { + // Формируем URL согласно вашему API + const url = new URL(`${this.baseUrl}/api/metrics`); + url.searchParams.append('metric', metric); + url.searchParams.append('start', start); + url.searchParams.append('end', end); + url.searchParams.append('step', step); + + console.log('Fetching metrics range from:', url.toString()); + + const response = await fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const data = await response.json(); + + // Проверяем формат данных + if (!Array.isArray(data)) { + console.error('Unexpected data format:', data); + throw new Error('Invalid data format: expected array'); + } + + return data; + } catch (error) { + console.error('Error in fetchMetricsRange:', error); + throw error; + } + } + + async fetchMetrics(metric) { + try { + // Формируем URL для текущих метрик + const url = new URL(`${this.baseUrl}/api/metrics`); + url.searchParams.append('metric', metric); + + console.log('Fetching current metrics from:', url.toString()); + + const response = await fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const data = await response.json(); + + // Проверяем формат данных + if (!Array.isArray(data)) { + console.error('Unexpected data format:', data); + throw new Error('Invalid data format: expected array'); + } + + return data; + } catch (error) { + console.error('Error in fetchMetrics:', error); + throw error; + } + } + + // WebSocket методы - остаются без изменений + connectWebSocket() { + if (this.socket && this.socket.connected) return; + console.trace('connectWebSocket called'); + this.socket = io(`${this.baseUrl}/api/metrics-ws`, { + transports: ['websocket'], + withCredentials: true, + }); + + this.socket.on('connect', () => { + console.log('Socket.IO connected'); + // Подписаться заново на все метрики + for (const [metric, callbacks] of this.subscriptions.entries()) { + this.socket.emit('subscribe-metric', { metric }); + } + }); + + this.socket.on('disconnect', () => { + console.log('Socket.IO disconnected'); + }); + + this.socket.on('metrics-data', ({ metric, data }) => { + const callbacks = this.subscriptions.get(metric) || []; + callbacks.forEach(cb => cb(data)); + }); + + this.socket.on('metrics-error', payload => { + console.error('Metrics error:', payload); + }); + } + + subscribeToMetric(metric, callback, interval = 5000) { + this.connectWebSocket(); + + if (!this.subscriptions.has(metric)) { + this.subscriptions.set(metric, []); + this.socket.emit('subscribe-metric', { metric, interval }); + } + + this.subscriptions.get(metric).push(callback); + + return () => this.unsubscribeFromMetric(metric, callback); + } + + + unsubscribeFromMetric(metric, callback) { + const callbacks = this.subscriptions.get(metric) || []; + const filtered = callbacks.filter(cb => cb !== callback); + + if (filtered.length === 0) { + this.subscriptions.delete(metric); + if (this.socket && this.socket.connected) { + this.socket.emit('unsubscribe-metric', { metric }); + } + } else { + this.subscriptions.set(metric, filtered); + } + } + + + disconnectWebSocket() { + if (this.socket) { + this.socket.close(); + } + } +} + +export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL); \ No newline at end of file diff --git a/src/Charts2/PrometheusChart.jsx b/src/Charts2/PrometheusChart.jsx new file mode 100644 index 0000000..24c37a7 --- /dev/null +++ b/src/Charts2/PrometheusChart.jsx @@ -0,0 +1,169 @@ +import React, { useState, useEffect } from 'react'; +import LineChartComponent from './Components/LineChartComponent'; +import DateRangeSelector from './Components/DateRangeSelector'; +import { metricsService } from './Components/metricsService'; +import { Button, Radio, message } from 'antd'; +import moment from 'moment'; + +const PrometheusChart = ({ metricName, chartHeight = 560 }) => { + const [chartData, setChartData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [metricInfo, setMetricInfo] = useState({}); + const [mode, setMode] = useState('realtime'); + const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate()); + const [endDate, setEndDate] = useState(moment().toDate()); + const [isLiveUpdating, setIsLiveUpdating] = useState(false); + + const fetchHistoricalData = async (start, end) => { + setIsLoading(true); + setError(null); + + try { + const startUnix = Math.floor(new Date(start).getTime() / 1000); + const endUnix = Math.floor(new Date(end).getTime() / 1000); + + const data = await metricsService.fetchMetricsRange(metricName, startUnix, endUnix, 15); + + const dataArray = Array.isArray(data) ? data : [data]; + const formattedData = dataArray.map(item => ({ + timestamp: item.timestamp, + value: parseFloat(item.value), + name: item.__name__ || metricName, + status: item.status + })); + + if (dataArray.length > 0) { + setMetricInfo({ + type: dataArray[0].type, + description: dataArray[0].description, + instance: dataArray[0].instance, + job: dataArray[0].job + }); + } + + setChartData(formattedData); + } catch (err) { + console.error(`Error loading historical data for ${metricName}:`, err); + setError(err.message); + message.error(`Failed to load historical data: ${err.message}`); + } finally { + setIsLoading(false); + } + }; + + const startRealtimeUpdates = () => { + setIsLiveUpdating(true); + setIsLoading(true); + + const end = new Date(); + const start = new Date(end.getTime() - 3600 * 1000); + fetchHistoricalData(start, end).finally(() => { + setIsLoading(false); + }); + + return metricsService.subscribeToMetric( + metricName, + (newData) => { + const newDataArray = Array.isArray(newData) ? newData : [newData]; + const formattedNewData = newDataArray.map(item => ({ + timestamp: item.timestamp, + value: parseFloat(item.value), + name: item.__name__ || metricName, + status: item.status + })); + + setChartData(prevData => [...prevData, ...formattedNewData].slice(-200)); + }, + 5000 + ); + }; + + const stopRealtimeUpdates = () => { + setIsLiveUpdating(false); + metricsService.unsubscribeFromMetric(metricName); + }; + + const handleCustomRangeApply = () => { + if (startDate && endDate) { + fetchHistoricalData(startDate, endDate); + } + }; + + useEffect(() => { + let unsubscribe; + + if (mode === 'realtime') { + unsubscribe = startRealtimeUpdates(); + } else { + stopRealtimeUpdates(); + fetchHistoricalData(startDate, endDate); + } + + return () => { + if (unsubscribe) unsubscribe(); + stopRealtimeUpdates(); + }; + }, [mode, metricName]); + + const metaInfo = [ + metricInfo.instance && `Instance: ${metricInfo.instance}`, + metricInfo.job && `Job: ${metricInfo.job}`, + metricInfo.type && `Type: ${metricInfo.type}` + ].filter(Boolean).join(' | '); + + return ( +
+
+ setMode(e.target.value)} + buttonStyle="solid" + style={{ marginBottom: 10 }} + > + Режим реального времени + Исторические данные + + + {mode === 'historical' && ( + + )} + + {mode === 'realtime' && isLiveUpdating && ( + + )} +
+ + {isLoading ? ( +
Loading chart data...
+ ) : error ? ( +
Error loading metric: {error}
+ ) : chartData.length === 0 ? ( +
No data available for {metricName}
+ ) : ( + + )} +
+ ); +}; + +export default PrometheusChart; \ No newline at end of file diff --git a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx index 6d0eba6..636a973 100644 --- a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx +++ b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx @@ -15,7 +15,7 @@ import { getStatusColor } from "../../TreeChart/dataUtils"; const StyledListItem = styled(ListItem)(({ theme, level }) => ({ cursor: "pointer", paddingLeft: theme.spacing(2 + level * 2), - position: 'relative', // Добавляем для позиционирования индикатора + position: 'relative', '&:hover': { backgroundColor: theme.palette.action.hover, }, diff --git a/src/Components/TreeChart/FlowChart.jsx b/src/Components/TreeChart/FlowChart.jsx index bf8fe4a..0b0dd09 100644 --- a/src/Components/TreeChart/FlowChart.jsx +++ b/src/Components/TreeChart/FlowChart.jsx @@ -45,12 +45,10 @@ const FlowChart = ({ data }) => { const findAndCollapseLastLevelParents = (items) => { items.forEach(item => { if (item.items && item.items.length > 0) { - // Проверяем, есть ли у детей свои дети const hasGrandchildren = item.items.some(child => child.items && child.items.length > 0 ); - // Если у детей нет своих детей - это родители последнего уровня if (!hasGrandchildren) { toggleNodeCollapse(item.id); } else { diff --git a/src/Components/TreeChart/FlowChartComponents/DataParser.jsx b/src/Components/TreeChart/FlowChartComponents/DataParser.jsx index 916bc97..92717f6 100644 --- a/src/Components/TreeChart/FlowChartComponents/DataParser.jsx +++ b/src/Components/TreeChart/FlowChartComponents/DataParser.jsx @@ -39,7 +39,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => { const baseLevelRadius = 150; const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI, parentRadius = 0) => { - if (!item || collapsedNodes[parentId]) return; // Пропускаем свёрнутые узлы + if (!item || collapsedNodes[parentId]) return; const nodeId = item.id; const items = item.items || []; @@ -58,7 +58,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => { data: { ...item, label: item.title, - style: getNodeStyle(item, isLeaf), // Переносим стили в data + style: getNodeStyle(item, isLeaf), hasChildren: items.length > 0, collapsed: collapsedNodes[nodeId] } @@ -88,7 +88,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => { const centerNode = { id: data.id, - type: 'customNode', // Добавляем тип узла + type: 'customNode', position: nodePositions[data.id] || { x: centerX, y: centerY }, style: getCenterNodeStyle(data), data: { label: data.title, hasChildren: data.items.length > 0, collapsed: collapsedNodes[data.id] } diff --git a/src/Components/TreeChart/FlowChartComponents/NodeWrapper.jsx b/src/Components/TreeChart/FlowChartComponents/NodeWrapper.jsx index d64d391..52ccd94 100644 --- a/src/Components/TreeChart/FlowChartComponents/NodeWrapper.jsx +++ b/src/Components/TreeChart/FlowChartComponents/NodeWrapper.jsx @@ -10,13 +10,13 @@ const NodeWrapper = memo(({ id, data, selected }) => { display: 'flex', alignItems: 'center', justifyContent: 'center', - overflow: 'hidden', // Чтобы текст не выходил за границы - textOverflow: 'ellipsis', // Добавляем многоточие если текст не помещается - whiteSpace: 'nowrap', // Запрещаем перенос строк - padding: '0 8px', // Горизонтальный padding для текста - boxSizing: 'border-box' // Учитываем padding в общей ширине + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + padding: '0 8px', + boxSizing: 'border-box' }} - title={data.label} // Простой tooltip при наведении + title={data.label} > {/* Хендл для входящих соединений */} { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [nodePositions, setNodePositions] = useState({}); - const [collapsedNodes, setCollapsedNodes] = useState({}); // Добавили + const [collapsedNodes, setCollapsedNodes] = useState({}); const toggleNodeCollapse = useCallback((nodeId) => { setCollapsedNodes((prev) => ({ diff --git a/src/Components/TreeChart/FlowChartComponents/useNodeHandlers.jsx b/src/Components/TreeChart/FlowChartComponents/useNodeHandlers.jsx index 80e4fd0..efc6480 100644 --- a/src/Components/TreeChart/FlowChartComponents/useNodeHandlers.jsx +++ b/src/Components/TreeChart/FlowChartComponents/useNodeHandlers.jsx @@ -2,7 +2,6 @@ import { useCallback } from 'react'; export const useNodeHandlers = (debouncedSetNodePositions) => { const onNodeDrag = useCallback((event, node) => { - // Фиксируем позицию сразу при перемещении node.position = { x: Math.round(node.position.x), y: Math.round(node.position.y) diff --git a/src/Components/TreeChart/dataUtils.jsx b/src/Components/TreeChart/dataUtils.jsx index 1a1c70d..b9cd7e0 100755 --- a/src/Components/TreeChart/dataUtils.jsx +++ b/src/Components/TreeChart/dataUtils.jsx @@ -1,48 +1,43 @@ const StatusManager = () => { const getRandomStatus = () => { const statuses = [ - ...Array(90).fill("green"), // 90% шанс - ...Array(6).fill("yellow"), // 6% шанс - ...Array(3).fill("orange"), // 3% шанс - ...Array(1).fill("red"), // 1% шанс + ...Array(90).fill("green"), + ...Array(6).fill("yellow"), + ...Array(3).fill("orange"), + ...Array(1).fill("red"), ]; return statuses[Math.floor(Math.random() * statuses.length)]; }; const getStatusWeight = (status) => { switch (status) { - case "green": return 1; // 100% здоровья + case "green": return 1; case "yellow": return 0.75; case "orange": return 0.5; - case "red": return 0.25; // 25% здоровья - default: return 1; // По умолчанию "green" + case "red": return 0.25; + default: return 1; } }; const updateStatuses = (data) => { if (!data.items || data.items.length === 0) { - // Если это элемент нижнего уровня, генерируем случайный статус data.status = getRandomStatus(); return getStatusWeight(data.status); } - // Рекурсивно обновляем статусы для всех дочерних элементов let childStatusWeights = data.items.map((child) => updateStatuses(child)); - // Проверяем, есть ли дочерние элементы (избегаем деления на 0) if (childStatusWeights.length === 0) { data.status = "green"; return 1; } - // Вычисляем среднее арифметическое значение весов статусов const averageStatusWeight = childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length; - // Определяем статус текущего элемента data.status = getStatusFromWeight(averageStatusWeight); - return Math.max(0, averageStatusWeight); // Гарантия, что не будет отрицательных значений + return Math.max(0, averageStatusWeight); }; const getStatusFromWeight = (weight) => { @@ -69,16 +64,13 @@ const StatusManager = () => { }; }; -// Создаем два независимых менеджера статусов export const statusManager1 = StatusManager(); export const statusManager2 = StatusManager(); -// Функция для расчета процентов здоровья системы export const calculateStatusPercentage = (averageStatusValue) => { return Math.max(0, Math.min(100, averageStatusValue * 100)); }; -// Экспортируем getStatusColor отдельно export const getStatusColor = (status) => { switch (status) { case "green": diff --git a/src/Components/TreeChart/menuData.json b/src/Components/TreeChart/menuData.json index 1d4c364..fcbdd2f 100755 --- a/src/Components/TreeChart/menuData.json +++ b/src/Components/TreeChart/menuData.json @@ -400,182 +400,136 @@ "items": [ { "id": "182", - "title": "Graviton S2082I (device$18)", + "title": "Graviton S2082I (device$19)", "items": [ { "id": "42", - "title": "OS Linux (module$4) АО", + "title": "OS Linux (module$6) АО", "items": [ { - "id": "1902", + "id": "371", "title": "Загрузка процессора за 1 минуту" }, { - "id": "1912", + "id": "372", "title": "Загрузка процессора за 5 минут" }, { - "id": "1922", + "id": "373", "title": "Загрузка процессора за 15 минут" }, { - "id": "1972", + "id": "378", "title": "Общий объем SWAP-файла" }, { - "id": "1982", + "id": "379", "title": "Используемый объем SWAP-файла" }, { - "id": "1992", + "id": "380", "title": "Общий объем физической оперативной памяти" }, { - "id": "2002", + "id": "381", "title": "Доступный объем физической оперативной памяти" }, { - "id": "2012", + "id": "382", "title": "Свободный объем физической и виртуальной оперативной памяти" }, { - "id": "2022", + "id": "383", "title": "Буферизованный объем оперативной памяти" }, { - "id": "2032", + "id": "384", "title": "Кэшированый объем оперативной памяти" }, { - "id": "2742", - "title": "Используемый объем SWAP-файла" - }, - { - "id": "2752", + "id": "375", "title": "Время затраченное процессором на процессы с пониженным приоритетом" }, { - "id": "2762", + "id": "376", "title": "Время затраченное процессором на процессы ядра ОС" }, { - "id": "2772", + "id": "377", "title": "Время простоя процессора" }, { - "id": "2782", + "id": "385", "title": "Общая емкость жестких дисков" }, { - "id": "2792", + "id": "386", "title": "Доступная емкость жестких дисков" } ] }, - { - "id": "52", - "title": "Vinteo (module$5) ПО", - "items": [ - { - "id": "312", - "title": "Общее количество участников" - }, - { - "id": "322", - "title": "Ожидание соединения" - }, - { - "id": "332", - "title": "Зарегистрированные абоненты" - }, - { - "id": "342", - "title": "Количество пользоватей HLS" - }, - { - "id": "352", - "title": "Общее количество P2P комнат" - }, - { - "id": "362", - "title": "Общее количество конференций" - }, - { - "id": "372", - "title": "Общее количество активных конференций" - }, - { - "id": "382", - "title": "Статус записи" - }, - { - "id": "392", - "title": "Общее количество сохранённых записей" - } - ] - }, { "id": "2802", "title": "Сетевой адаптер №1 (port$261) Eth_1", "items": [ { - "id": "2072", + "id": "388", "title": "Скорость порта Eth_1" }, { - "id": "2092", + "id": "390", "title": "Административное состояние порта Eth_1" }, { - "id": "2102", + "id": "391", "title": "Оперативное состояние порта Eth_1" }, { - "id": "2112", + "id": "392", "title": "Общее количество отправленных октетов Eth_1" }, { - "id": "2122", + "id": "393", "title": "Количество входящих Multicast пакетов Eth_1" }, { - "id": "2132", + "id": "394", "title": "Количество иcходящих Multiicast пакетов Eth_1" }, { - "id": "2142", + "id": "395", "title": "Количество входящих Broadcast пакетов Eth_1" }, { - "id": "2152", + "id": "396", "title": "Количество иcходящих Broadcast пакетов Eth_1" }, { - "id": "2162", + "id": "397", "title": "Количество входящих Unicast пакетов Eth_1" }, { - "id": "2172", + "id": "398", "title": "Количество иcходящих Unicast пакетов Eth_1" }, { - "id": "2182", + "id": "399", "title": "Количество входящих пакетов помеченные как отброшенные Eth_1" }, { - "id": "2192", + "id": "400", "title": "Количество иcходящих пакетов помеченные как отброшенные Eth_1" }, { - "id": "2202", + "id": "401", "title": "Количество входящих пакетов с ошибкой Eth_1" }, { - "id": "2212", + "id": "402", "title": "Количество исходящих пакетов с ошибкой Eth_1" }, { - "id": "2222", + "id": "403", "title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_1" } ] @@ -585,63 +539,63 @@ "title": "Сетевой адаптер №2 (port$262) Eth_2", "items": [ { - "id": "2242", + "id": "405", "title": "Скорость порта Eth_2" }, { - "id": "2262", + "id": "407", "title": "Административное состояние порта Eth_2" }, { - "id": "2272", + "id": "408", "title": "Оперативное состояние порта Eth_2" }, { - "id": "2282", + "id": "409", "title": "Общее количество отправленных октетов Eth_2" }, { - "id": "2292", + "id": "410", "title": "Количество входящих Multicast пакетов Eth_2" }, { - "id": "2302", + "id": "411", "title": "Количество иcходящих Multiicast пакетов Eth_2" }, { - "id": "2312", + "id": "412", "title": "Количество входящих Broadcast пакетов Eth_2" }, { - "id": "2322", + "id": "413", "title": "Количество иcходящих Broadcast пакетов Eth_2" }, { - "id": "2332", + "id": "414", "title": "Количество входящих Unicast пакетов Eth_2" }, { - "id": "2342", + "id": "415", "title": "Количество иcходящих Unicast пакетов Eth_2" }, { - "id": "2352", + "id": "416", "title": "Количество входящих пакетов помеченные как отброшенные Eth_2" }, { - "id": "2362", + "id": "417", "title": "Количество иcходящих пакетов помеченные как отброшенные Eth_2" }, { - "id": "2372", + "id": "418", "title": "Количество входящих пакетов с ошибкой Eth_2" }, { - "id": "2382", + "id": "419", "title": "Количество исходящих пакетов с ошибкой Eth_2" }, { - "id": "2392", + "id": "420", "title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_2" } ] @@ -651,63 +605,63 @@ "title": "Сетевой адаптер №3 (port$263) Eth_3", "items": [ { - "id": "2412", + "id": "422", "title": "Скорость порта Eth_3" }, { - "id": "2432", + "id": "424", "title": "Административное состояние порта Eth_3" }, { - "id": "2442", + "id": "425", "title": "Оперативное состояние порта Eth_3" }, { - "id": "2452", + "id": "426", "title": "Общее количество отправленных октетов Eth_3" }, { - "id": "2462", + "id": "427", "title": "Количество входящих Multicast пакетов Eth_3" }, { - "id": "2472", + "id": "428", "title": "Количество иcходящих Multiicast пакетов Eth_3" }, { - "id": "2482", + "id": "429", "title": "Количество входящих Broadcast пакетов Eth_3" }, { - "id": "2492", + "id": "430", "title": "Количество иcходящих Broadcast пакетов Eth_3" }, { - "id": "2502", + "id": "431", "title": "Количество входящих Unicast пакетов Eth_3" }, { - "id": "2512", + "id": "432", "title": "Количество иcходящих Unicast пакетов Eth_3" }, { - "id": "2522", + "id": "433", "title": "Количество входящих пакетов помеченные как отброшенные Eth_3" }, { - "id": "2532", + "id": "434", "title": "Количество иcходящих пакетов помеченные как отброшенные Eth_3" }, { - "id": "2542", + "id": "435", "title": "Количество входящих пакетов с ошибкой Eth_3" }, { - "id": "2552", + "id": "436", "title": "Количество исходящих пакетов с ошибкой Eth_3" }, { - "id": "2562", + "id": "437", "title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_3" } ] @@ -717,63 +671,63 @@ "title": "Сетевой адаптер №4 (port$264) Eth_4", "items": [ { - "id": "2582", + "id": "439", "title": "Скорость порта Eth_4" }, { - "id": "2602", + "id": "441", "title": "Административное состояние порта Eth_4" }, { - "id": "2612", + "id": "442", "title": "Оперативное состояние порта Eth_4" }, { - "id": "2622", + "id": "443", "title": "Общее количество отправленных октетов Eth_4" }, { - "id": "2632", + "id": "444", "title": "Количество входящих Multicast пакетов Eth_4" }, { - "id": "2642", + "id": "445", "title": "Количество иcходящих Multiicast пакетов Eth_4" }, { - "id": "2652", + "id": "446", "title": "Количество входящих Broadcast пакетов Eth_4" }, { - "id": "2662", + "id": "447", "title": "Количество иcходящих Broadcast пакетов Eth_4" }, { - "id": "2672", + "id": "448", "title": "Количество входящих Unicast пакетов Eth_4" }, { - "id": "2682", + "id": "449", "title": "Количество иcходящих Unicast пакетов Eth_4" }, { - "id": "2692", + "id": "450", "title": "Количество входящих пакетов помеченные как отброшенные Eth_4" }, { - "id": "2702", + "id": "451", "title": "Количество иcходящих пакетов помеченные как отброшенные Eth_4" }, { - "id": "2712", + "id": "452", "title": "Количество входящих пакетов с ошибкой Eth_4" }, { - "id": "2722", + "id": "453", "title": "Количество исходящих пакетов с ошибкой Eth_4" }, { - "id": "2732", + "id": "454", "title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_4" } ] @@ -889,15 +843,15 @@ "title": "Общее количество конференций" }, { - "id": "373", + "id": "373000", "title": "Общее количество активных конференций" }, { - "id": "383", + "id": "38300", "title": "Статус записи" }, { - "id": "393", + "id": "39300", "title": "Общее количество сохранённых записей" } ] @@ -1281,11 +1235,11 @@ "title": "Общее количество активных конференций" }, { - "id": "384", + "id": "38400", "title": "Статус записи" }, { - "id": "394", + "id": "39400", "title": "Общее количество сохранённых записей" } ] @@ -1671,7 +1625,7 @@ "title": "Общее количество конференций" }, { - "id": "379", + "id": "37900", "title": "Общее количество активных конференций" }, { @@ -1679,7 +1633,7 @@ "title": "Статус записи" }, { - "id": "399", + "id": "39900", "title": "Общее количество сохранённых записей" } ] @@ -2447,15 +2401,15 @@ "title": "Общее количество конференций" }, { - "id": "378", + "id": "37800", "title": "Общее количество активных конференций" }, { - "id": "388", + "id": "38800", "title": "Статус записи" }, { - "id": "398", + "id": "39800", "title": "Общее количество сохранённых записей" } ] @@ -2841,15 +2795,15 @@ "title": "Общее количество конференций" }, { - "id": "375", + "id": "37500", "title": "Общее количество активных конференций" }, { - "id": "385", + "id": "38500", "title": "Статус записи" }, { - "id": "395", + "id": "39500", "title": "Общее количество сохранённых записей" } ] @@ -3229,15 +3183,15 @@ "title": "Общее количество конференций" }, { - "id": "376", + "id": "37600", "title": "Общее количество активных конференций" }, { - "id": "386", + "id": "38600", "title": "Статус записи" }, { - "id": "396", + "id": "39600", "title": "Общее количество сохранённых записей" } ] @@ -3617,7 +3571,7 @@ "title": "Общее количество конференций" }, { - "id": "377", + "id": "37700", "title": "Общее количество активных конференций" }, { @@ -3625,7 +3579,7 @@ "title": "Статус записи" }, { - "id": "397", + "id": "39700", "title": "Общее количество сохранённых записей" } ] diff --git a/src/Components/TreeChart/tabContent.jsx b/src/Components/TreeChart/tabContent.jsx index 15596a8..8b6ce63 100755 --- a/src/Components/TreeChart/tabContent.jsx +++ b/src/Components/TreeChart/tabContent.jsx @@ -2,9 +2,10 @@ import React, { lazy, Suspense } from "react"; import Skeleton from '@mui/material/Skeleton'; import Box from '@mui/material/Box'; -const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart')); +const PrometheusChart = lazy(() => import('../../Charts2/PrometheusChart')); import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender"; +// Функция для генерации названия метрики на основе id const getMetricName = (id) => { return `zvks_apiforsnmp_measure_${id}`; }; @@ -12,28 +13,29 @@ const getMetricName = (id) => { const getAllChildIds = (node) => { let ids = []; if (node.id) { - ids.push(node.id); + ids.push(node.id); } if (node.items && node.items.length > 0) { node.items.forEach((child) => { - ids = ids.concat(getAllChildIds(child)); + ids = ids.concat(getAllChildIds(child)); }); } return ids; }; +// Компонент Skeleton для графика const ChartSkeleton = () => ( - {/* Заголовок */} - {/* График */} + + ); +// Компонент Skeleton для родительского контейнера const ContainerSkeleton = () => ( {/* Заголовок */} - {/* Описание */} - {/* Место для дочерних элементов */} + {[...Array(3)].map((_, i) => ( @@ -42,24 +44,22 @@ const ContainerSkeleton = () => ( ); -const tabContent = (data, existingContent = {}) => { - const tabContent = { ...existingContent }; +const tabContent = (data) => { + const tabContent = {}; + // Функция для рекурсивного обхода и сбора данных const generateContent = (nodes) => { nodes.forEach((node) => { - if (tabContent[node.id]) return; - if (node.items && node.items.length > 0) { - generateContent(node.items); + const childrenContent = generateContent(node.items); const content = ( -
+

{node.title}

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

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

); @@ -77,17 +77,26 @@ const tabContent = (data, existingContent = {}) => {
); - tabContent[node.id] = { title: node.title, content: content, }; } }); + + return ( +
+ {nodes.map((node) => ( +
{tabContent[node.id].content}
+ ))} +
+ ); }; if (data.items && data.items.length > 0) { generateContent(data.items); + } else { + console.warn("Данные отсутствуют или массив items пуст"); } return tabContent; diff --git a/src/main.jsx b/src/main.jsx index e373476..b9a1a6d 100755 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,8 +2,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.jsx' -//import './Style/light-theme.css'; // Подключаем светлую тему по умолчанию -//import './Style/dark-theme.css'; // Подключаем темную тему createRoot(document.getElementById('root')).render( From 2b79159d35129b9be0f10d03dbbe59bad9034ae2 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Thu, 22 May 2025 06:29:58 -0400 Subject: [PATCH 13/18] Created a new chart --- src/Charts2/Components/metricsService.jsx | 149 +++++++++------------- src/Components/Layout/SidebarMenu.jsx | 1 + src/Components/TreeChart/tabContent.jsx | 11 +- 3 files changed, 69 insertions(+), 92 deletions(-) diff --git a/src/Charts2/Components/metricsService.jsx b/src/Charts2/Components/metricsService.jsx index 644c14a..4a0dd48 100644 --- a/src/Charts2/Components/metricsService.jsx +++ b/src/Charts2/Components/metricsService.jsx @@ -2,125 +2,102 @@ import { io } from 'socket.io-client'; class MetricsService { constructor(baseUrl) { - console.log('MetricsService constructor'); this.baseUrl = baseUrl || window.location.origin; this.socket = null; this.subscriptions = new Map(); + this.pendingRequests = new Map(); } - // HTTP методы - адаптированы под ваш бэкенд - async fetchMetricsRange(metric, start, end, step = 15) { - try { - // Формируем URL согласно вашему API - const url = new URL(`${this.baseUrl}/api/metrics`); - url.searchParams.append('metric', metric); - url.searchParams.append('start', start); - url.searchParams.append('end', end); - url.searchParams.append('step', step); - - console.log('Fetching metrics range from:', url.toString()); - - const response = await fetch(url.toString()); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP ${response.status}: ${errorText}`); - } - - const data = await response.json(); - - // Проверяем формат данных - if (!Array.isArray(data)) { - console.error('Unexpected data format:', data); - throw new Error('Invalid data format: expected array'); - } - - return data; - } catch (error) { - console.error('Error in fetchMetricsRange:', error); - throw error; - } - } - - async fetchMetrics(metric) { - try { - // Формируем URL для текущих метрик - const url = new URL(`${this.baseUrl}/api/metrics`); - url.searchParams.append('metric', metric); - - console.log('Fetching current metrics from:', url.toString()); - - const response = await fetch(url.toString()); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP ${response.status}: ${errorText}`); - } - - const data = await response.json(); - - // Проверяем формат данных - if (!Array.isArray(data)) { - console.error('Unexpected data format:', data); - throw new Error('Invalid data format: expected array'); - } - - return data; - } catch (error) { - console.error('Error in fetchMetrics:', error); - throw error; - } - } - - // WebSocket методы - остаются без изменений + // Инициализация WebSocket соединения connectWebSocket() { if (this.socket && this.socket.connected) return; - console.trace('connectWebSocket called'); - this.socket = io(`${this.baseUrl}/api/metrics-ws`, { + + this.socket = io(`${this.baseUrl.replace('http', 'ws')}/api/metrics-ws`, { transports: ['websocket'], withCredentials: true, }); - + this.socket.on('connect', () => { - console.log('Socket.IO connected'); - // Подписаться заново на все метрики - for (const [metric, callbacks] of this.subscriptions.entries()) { + console.log('WebSocket connected'); + // Восстанавливаем подписки при переподключении + this.subscriptions.forEach((_, metric) => { this.socket.emit('subscribe-metric', { metric }); - } + }); }); - + this.socket.on('disconnect', () => { - console.log('Socket.IO disconnected'); + console.log('WebSocket disconnected'); }); - - this.socket.on('metrics-data', ({ metric, data }) => { + + this.socket.on('metrics-data', ({ metric, data, requestId }) => { + // Обработка исторических данных + if (requestId && this.pendingRequests.has(requestId)) { + const { resolve } = this.pendingRequests.get(requestId); + resolve(data); + this.pendingRequests.delete(requestId); + return; + } + + // Обработка реального времени const callbacks = this.subscriptions.get(metric) || []; callbacks.forEach(cb => cb(data)); }); - - this.socket.on('metrics-error', payload => { - console.error('Metrics error:', payload); + + this.socket.on('metrics-error', ({ error, requestId }) => { + if (requestId && this.pendingRequests.has(requestId)) { + const { reject } = this.pendingRequests.get(requestId); + reject(new Error(error)); + this.pendingRequests.delete(requestId); + } }); } + // Запрос исторических данных через WebSocket + async fetchMetricsRange(metric, start, end, step = 15) { + return new Promise((resolve, reject) => { + this.connectWebSocket(); + + const requestId = `range-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + this.pendingRequests.set(requestId, { resolve, reject }); + + this.socket.emit('get-metrics', { + metric, + start, + end, + step, + isRangeQuery: true, + requestId + }); + + // Таймаут запроса + setTimeout(() => { + if (this.pendingRequests.has(requestId)) { + this.pendingRequests.delete(requestId); + reject(new Error('Request timeout')); + } + }, 30000); + }); + } + + // Подписка на обновления в реальном времени subscribeToMetric(metric, callback, interval = 5000) { this.connectWebSocket(); - + if (!this.subscriptions.has(metric)) { this.subscriptions.set(metric, []); this.socket.emit('subscribe-metric', { metric, interval }); } - + this.subscriptions.get(metric).push(callback); - + return () => this.unsubscribeFromMetric(metric, callback); } - + // Отписка от метрики unsubscribeFromMetric(metric, callback) { const callbacks = this.subscriptions.get(metric) || []; const filtered = callbacks.filter(cb => cb !== callback); - + if (filtered.length === 0) { this.subscriptions.delete(metric); if (this.socket && this.socket.connected) { @@ -130,8 +107,8 @@ class MetricsService { this.subscriptions.set(metric, filtered); } } - + // Закрытие соединения disconnectWebSocket() { if (this.socket) { this.socket.close(); diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index f0ba272..0735dda 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -19,6 +19,7 @@ import { import MenuItem from "./SidebarMenuComponents/MenuItem"; import SidebarFooter from "./SidebarMenuComponents/SidebarFooter"; import { statusManager1 } from "../TreeChart/dataUtils"; +import tabContent from "../TreeChart/tabContent"; const SidebarResizer = styled('div')(({ theme }) => ({ diff --git a/src/Components/TreeChart/tabContent.jsx b/src/Components/TreeChart/tabContent.jsx index 8b6ce63..6021f3a 100755 --- a/src/Components/TreeChart/tabContent.jsx +++ b/src/Components/TreeChart/tabContent.jsx @@ -5,7 +5,6 @@ import Box from '@mui/material/Box'; const PrometheusChart = lazy(() => import('../../Charts2/PrometheusChart')); import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender"; -// Функция для генерации названия метрики на основе id const getMetricName = (id) => { return `zvks_apiforsnmp_measure_${id}`; }; @@ -13,11 +12,11 @@ const getMetricName = (id) => { const getAllChildIds = (node) => { let ids = []; if (node.id) { - ids.push(node.id); + ids.push(node.id); } if (node.items && node.items.length > 0) { node.items.forEach((child) => { - ids = ids.concat(getAllChildIds(child)); + ids = ids.concat(getAllChildIds(child)); }); } return ids; @@ -26,8 +25,8 @@ const getAllChildIds = (node) => { // Компонент Skeleton для графика const ChartSkeleton = () => ( - - + + ); @@ -35,7 +34,7 @@ const ChartSkeleton = () => ( const ContainerSkeleton = () => ( {/* Заголовок */} - + {[...Array(3)].map((_, i) => ( From 069cea21b05f591bdb776c75cdae652c08fbec01 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Tue, 27 May 2025 20:45:30 -0400 Subject: [PATCH 14/18] automatic formation of the side menu --- src/Charts2/Components/LineChartComponent.jsx | 16 +- src/Charts2/Components/metricsService.jsx | 54 ++-- src/Charts2/PrometheusChart.jsx | 179 +++++++++---- src/Components/Layout/Dashboard.jsx | 83 +++--- src/Components/Layout/SidebarMenu.jsx | 239 +++++++++--------- .../Layout/SidebarMenuComponents/MenuItem.jsx | 121 +++++---- src/Components/Layout/SidebarMenuWrapper.jsx | 104 ++++++++ src/Components/TreeChart/menuData.json | 24 +- src/Components/TreeChart/tabContent.jsx | 205 +++++++++------ src/Components/UI/MUItabs.jsx | 89 ++++--- 10 files changed, 709 insertions(+), 405 deletions(-) create mode 100644 src/Components/Layout/SidebarMenuWrapper.jsx diff --git a/src/Charts2/Components/LineChartComponent.jsx b/src/Charts2/Components/LineChartComponent.jsx index 4a5cea5..2f990f6 100644 --- a/src/Charts2/Components/LineChartComponent.jsx +++ b/src/Charts2/Components/LineChartComponent.jsx @@ -1,12 +1,12 @@ import React from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; -const LineChartComponent = ({ - data, - title, - description, - metaInfo, - dataKey = 'value', +const LineChartComponent = ({ + data, + title, + description, + metaInfo, + dataKey = 'value', lineColor = '#8884d8', height = 400, showLegend = true, @@ -17,7 +17,7 @@ const LineChartComponent = ({ additionalLines = [] }) => { return ( -
+
{title &&

{title}

} {description && (

{description}

@@ -27,7 +27,7 @@ const LineChartComponent = ({ {metaInfo}
)} - + { + this.cleanupAll(); + }); } - // Инициализация WebSocket соединения connectWebSocket() { - if (this.socket && this.socket.connected) return; + if (this.socket) { + console.log('WebSocket already exists'); + return; + } + console.log('Connecting WebSocket...'); this.socket = io(`${this.baseUrl.replace('http', 'ws')}/api/metrics-ws`, { transports: ['websocket'], withCredentials: true, @@ -19,7 +26,6 @@ class MetricsService { this.socket.on('connect', () => { console.log('WebSocket connected'); - // Восстанавливаем подписки при переподключении this.subscriptions.forEach((_, metric) => { this.socket.emit('subscribe-metric', { metric }); }); @@ -27,10 +33,10 @@ class MetricsService { this.socket.on('disconnect', () => { console.log('WebSocket disconnected'); + this.socket = null; }); this.socket.on('metrics-data', ({ metric, data, requestId }) => { - // Обработка исторических данных if (requestId && this.pendingRequests.has(requestId)) { const { resolve } = this.pendingRequests.get(requestId); resolve(data); @@ -38,7 +44,6 @@ class MetricsService { return; } - // Обработка реального времени const callbacks = this.subscriptions.get(metric) || []; callbacks.forEach(cb => cb(data)); }); @@ -52,12 +57,11 @@ class MetricsService { }); } - // Запрос исторических данных через WebSocket - async fetchMetricsRange(metric, start, end, step = 15) { + async fetchMetricsRange(metric, start, end, step = 15, filters = {}) { return new Promise((resolve, reject) => { this.connectWebSocket(); - const requestId = `range-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const requestId = `range-${Date.now()}`; this.pendingRequests.set(requestId, { resolve, reject }); this.socket.emit('get-metrics', { @@ -65,35 +69,35 @@ class MetricsService { start, end, step, + filters, isRangeQuery: true, requestId }); - // Таймаут запроса setTimeout(() => { if (this.pendingRequests.has(requestId)) { - this.pendingRequests.delete(requestId); reject(new Error('Request timeout')); + this.pendingRequests.delete(requestId); } }, 30000); }); } - // Подписка на обновления в реальном времени - subscribeToMetric(metric, callback, interval = 5000) { + subscribeToMetric(metric, callback, interval = 5000, filters = {}) { this.connectWebSocket(); - if (!this.subscriptions.has(metric)) { - this.subscriptions.set(metric, []); - this.socket.emit('subscribe-metric', { metric, interval }); - } + const alreadySubscribed = this.subscriptions.has(metric); + const callbacks = this.subscriptions.get(metric) || []; + callbacks.push(callback); + this.subscriptions.set(metric, callbacks); - this.subscriptions.get(metric).push(callback); + if (!alreadySubscribed) { + this.socket.emit('subscribe-metric', { metric, interval, filters }); + } return () => this.unsubscribeFromMetric(metric, callback); } - // Отписка от метрики unsubscribeFromMetric(metric, callback) { const callbacks = this.subscriptions.get(metric) || []; const filtered = callbacks.filter(cb => cb !== callback); @@ -108,12 +112,20 @@ class MetricsService { } } - // Закрытие соединения + cleanupAll() { + if (this.socket && this.socket.connected) { + this.socket.emit('unsubscribe-all'); + } + this.subscriptions.clear(); + this.disconnectWebSocket(); + } + disconnectWebSocket() { if (this.socket) { - this.socket.close(); + this.socket.disconnect(); + this.socket = null; } } } -export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL); \ No newline at end of file +export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL); diff --git a/src/Charts2/PrometheusChart.jsx b/src/Charts2/PrometheusChart.jsx index 24c37a7..33b711e 100644 --- a/src/Charts2/PrometheusChart.jsx +++ b/src/Charts2/PrometheusChart.jsx @@ -2,43 +2,79 @@ import React, { useState, useEffect } from 'react'; import LineChartComponent from './Components/LineChartComponent'; import DateRangeSelector from './Components/DateRangeSelector'; import { metricsService } from './Components/metricsService'; -import { Button, Radio, message } from 'antd'; +import { Button, Radio, message, Tag } from 'antd'; import moment from 'moment'; -const PrometheusChart = ({ metricName, chartHeight = 560 }) => { +const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { + const { + name: metricName, + filters = {}, + title = metricName, + description, + context = {} // Добавляем контекст из path + } = metricInfo || {}; + + console.log("⚙️ PrometheusChart -> metricInfo:", metricInfo); + console.log("📌 Контекст -> device:", context.device, "source_id:", context.source_id, "deviceId:", context.deviceId); + + // Получаем полный контекст из родительских элементов + const { device, source_id: module, deviceId, parent } = context; + const [chartData, setChartData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [metricInfo, setMetricInfo] = useState({}); + const [metricMeta, setMetricMeta] = useState({}); const [mode, setMode] = useState('realtime'); const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate()); const [endDate, setEndDate] = useState(moment().toDate()); const [isLiveUpdating, setIsLiveUpdating] = useState(false); - const fetchHistoricalData = async (start, end) => { - setIsLoading(true); - setError(null); - - try { - const startUnix = Math.floor(new Date(start).getTime() / 1000); - const endUnix = Math.floor(new Date(end).getTime() / 1000); - - const data = await metricsService.fetchMetricsRange(metricName, startUnix, endUnix, 15); - - const dataArray = Array.isArray(data) ? data : [data]; - const formattedData = dataArray.map(item => ({ + // Генерация уникального ключа для подписки + const getSubscriptionKey = () => { + return `${metricName}_${device || 'all'}_${module || 'all'}_${deviceId || 'all'}`; + }; + + const formatMetricData = (dataArray) => { + return dataArray + .map(item => ({ timestamp: item.timestamp, value: parseFloat(item.value), name: item.__name__ || metricName, - status: item.status - })); + status: item.status, + device: item.device?.trim() || null, + source_id: item.source_id || null + })) + .sort((a, b) => a.timestamp - b.timestamp); + }; - if (dataArray.length > 0) { - setMetricInfo({ - type: dataArray[0].type, - description: dataArray[0].description, - instance: dataArray[0].instance, - job: dataArray[0].job + const fetchHistoricalData = async (start, end) => { + setIsLoading(true); + setError(null); + + try { + const extendedFilters = { + ...filters, + ...(device && { device: device.toString() }), // убедитесь, что device строка + ...(source_id && { source_id: source_id.toString() }) + }; + + console.log('Fetching with filters:', extendedFilters); // для отладки + + const data = await metricsService.fetchMetricsRange( + metricName, + Math.floor(start.getTime() / 1000), + Math.floor(end.getTime() / 1000), + 15, + extendedFilters + ); + + const formattedData = formatMetricData(data); + if (formattedData.length > 0) { + setMetricMeta({ + type: data[0]?.type, + description: data[0]?.description || description, + instance: data[0]?.instance, + job: data[0]?.job }); } @@ -55,33 +91,43 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => { const startRealtimeUpdates = () => { setIsLiveUpdating(true); setIsLoading(true); - + const end = new Date(); const start = new Date(end.getTime() - 3600 * 1000); - fetchHistoricalData(start, end).finally(() => { - setIsLoading(false); - }); + fetchHistoricalData(start, end).finally(() => setIsLoading(false)); return metricsService.subscribeToMetric( - metricName, + getSubscriptionKey(), // Уникальный ключ для подписки (newData) => { - const newDataArray = Array.isArray(newData) ? newData : [newData]; - const formattedNewData = newDataArray.map(item => ({ - timestamp: item.timestamp, - value: parseFloat(item.value), - name: item.__name__ || metricName, - status: item.status - })); + const filteredData = newData.filter(item => { + // Строгая проверка всех доступных фильтров + if (device && item.device?.trim() !== device) return false; + if (module && item.source_id !== module) return false; + return true; + }); - setChartData(prevData => [...prevData, ...formattedNewData].slice(-200)); + if (filteredData.length > 0) { + const formattedData = formatMetricData(filteredData); + setChartData(prev => { + const newChartData = [...prev, ...formattedData] + .filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i) + .slice(-200); + return newChartData; + }); + } }, - 5000 + 5000, + { + ...filters, + ...(device && { device }), + ...(module && { source_id: module }) + } ); }; const stopRealtimeUpdates = () => { setIsLiveUpdating(false); - metricsService.unsubscribeFromMetric(metricName); + metricsService.unsubscribeFromMetric(getSubscriptionKey()); }; const handleCustomRangeApply = () => { @@ -92,7 +138,6 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => { useEffect(() => { let unsubscribe; - if (mode === 'realtime') { unsubscribe = startRealtimeUpdates(); } else { @@ -104,19 +149,30 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => { if (unsubscribe) unsubscribe(); stopRealtimeUpdates(); }; - }, [mode, metricName]); + }, [mode, metricName, device, module]); + + // Рекурсивно собираем путь для отображения + const getFullPath = () => { + const path = []; + let current = parent; + while (current) { + path.unshift(current.title); + current = current.parent; + } + return path.join(' > '); + }; const metaInfo = [ - metricInfo.instance && `Instance: ${metricInfo.instance}`, - metricInfo.job && `Job: ${metricInfo.job}`, - metricInfo.type && `Type: ${metricInfo.type}` + metricMeta.instance && `Instance: ${metricMeta.instance}`, + metricMeta.job && `Job: ${metricMeta.job}`, + metricMeta.type && `Type: ${metricMeta.type}` ].filter(Boolean).join(' | '); return (
- setMode(e.target.value)} buttonStyle="solid" style={{ marginBottom: 10 }} @@ -124,7 +180,7 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => { Режим реального времени Исторические данные - + {mode === 'historical' && ( { onApply={handleCustomRangeApply} /> )} - + {mode === 'realtime' && isLiveUpdating && ( - )}
- + + {/* Отображаем полный путь к метрике */} + {parent && ( +
+ Путь: {getFullPath()} + {device && Устройство: {device}} + {module && Модуль: {module.split('$')[1]}} +
+ )} + {isLoading ? ( -
Loading chart data...
+
Загрузка графика...
) : error ? ( -
Error loading metric: {error}
+
Ошибка: {error}
) : chartData.length === 0 ? ( -
No data available for {metricName}
+
Нет данных для метрики: {metricName}
) : ( )}
diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx index 5df3408..74565b0 100755 --- a/src/Components/Layout/Dashboard.jsx +++ b/src/Components/Layout/Dashboard.jsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from "react"; import { Box, styled } from "@mui/material"; -import SidebarMenu from "./SidebarMenu"; import { statusManager1, statusManager2 } from "../TreeChart/dataUtils"; import generateTabContent from "../TreeChart/tabContent"; import CustomTabs from "../UI/MUItabs"; @@ -8,6 +7,7 @@ import useTabs from "../hooks/useTabs"; import useSidebarResize from "../hooks/useSidebarResize"; import TabContent from "../hooks/TabContent"; import menuData from "../TreeChart/menuData.json"; +import SidebarMenuWrapper from "./SidebarMenuWrapper"; // Создаем стилизованные компоненты const DashboardContainer = styled(Box)(({ theme }) => ({ @@ -19,24 +19,6 @@ const DashboardContainer = styled(Box)(({ theme }) => ({ color: theme.palette.text.primary, })); -const SidebarWrapper = styled(Box)(({ theme }) => ({ - position: 'relative', - backgroundColor: theme.palette.custom.sidebar, - color: theme.palette.custom.sidebarText, -})); - -const SidebarResizer = styled(Box)(({ theme }) => ({ - position: 'absolute', - right: 0, - top: 0, - bottom: 0, - width: '4px', - cursor: 'col-resize', - '&:hover': { - backgroundColor: theme.palette.primary.main, - }, -})); - const MainContent = styled(Box)(({ theme }) => ({ flexGrow: 1, display: 'flex', @@ -56,11 +38,9 @@ const Content = styled(Box)(({ theme }) => ({ const Dashboard = ({ isDarkMode, setIsDarkMode }) => { const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная"); - const { sidebarWidth, startResizing } = useSidebarResize(290); const [tabContent, setTabContent] = useState({}); const [treeData1, setTreeData1] = useState(menuData); const [treeData2, setTreeData2] = useState(menuData); - const [collapsed, setCollapsed] = useState(false); const [statusHistories, setStatusHistories] = useState({ history1: [], history2: [], @@ -99,22 +79,53 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => { return () => clearInterval(interval); }, [treeData1, treeData2]); + const handleMenuSelect = (item) => { + const tabId = `tab_${item.id}`; + const tabTitle = item.title || 'Новая вкладка'; + + const generateTabContentForItem = (item) => { + return ( + + {item.title} + {item.description && ( + {item.description} + )} + {item.metric && ( + + Метрика: {item.metric} + {/* Здесь можно добавить визуализацию метрики */} + + )} + + ); + }; + + // Проверяем, существует ли уже такая вкладка + const existingTab = tabs.find(tab => tab.id === tabId); + + if (!existingTab) { + const newTab = { + id: tabId, + title: tabTitle, + content: generateTabContentForItem(item), + type: 'menuItem', + itemData: item // Сохраняем данные элемента для возможного обновления + }; + + handleOpenTab(newTab); + } else { + setActiveTab(tabId); + } + }; + return ( - {/* Сайдбар */} - - - - + {/* Сайдбар - теперь используется SidebarMenuWrapper */} + {/* Основной контент */} { flexGrow: 1, overflow: 'hidden' }}> - {/* Вкладки*/} + {/* Вкладки */} ({ - width: "5px", - cursor: "ew-resize", - backgroundColor: 'transparent', - height: "100%", - position: "absolute", - right: 0, - top: 0, - transition: 'background-color 0.2s', - '&:hover': { - backgroundColor: theme.palette.primary.main, - }, - zIndex: 2 -})); - -const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, setCollapsed, isDarkMode, setIsDarkMode }) => { +const SidebarMenu = ({ + data, + isDarkMode, + setIsDarkMode, + onEditItem, + onSelectItem, + editModalOpen, + editingItem, + onCloseEditModal, + onSaveChanges +}) => { + const [collapsed, setCollapsed] = useState(false); + const { sidebarWidth, startResizing } = useSidebarResize(290); const [hovered, setHovered] = useState(false); - const [menuData, setMenuData] = useState(data); - const contentCache = useRef({}); - - useEffect(() => { - if (data) { - const dataCopy = JSON.parse(JSON.stringify(data)); - statusManager1.updateStatuses(dataCopy); - setMenuData(dataCopy); - } - }, [data]); const handleToggleCollapse = () => { setCollapsed(!collapsed); }; - const handleSelectItem = (id, title, children) => { - onOpenTab(id, title); - - contentCache.current = tabContent({ items: children }, contentCache.current); - if (contentCache.current[id]) { - onOpenTab(id, title, contentCache.current[id].content); - } - }; - - - - const drawerWidth = collapsed ? 64 : sidebarWidth; + const SidebarResizer = styled('div')(({ theme }) => ({ + width: '4px', + cursor: 'ew-resize', + backgroundColor: 'transparent', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + height: '100%', + position: 'absolute', + top: 0, + right: 0, + zIndex: 1000, + })); return ( setHovered(false)} sx={{ position: 'relative', - width: drawerWidth, + width: collapsed ? 64 : sidebarWidth, transition: 'width 0.3s ease', }} > - {/* Верхняя часть с логотипом и кнопкой */} + {/* Заголовок и кнопка сворачивания */} - {/* Логотип - центрируется в доступном пространстве */} - - - - - {/* Мини-логотип (только в свернутом состоянии) */} - {collapsed && ( - - - + {!collapsed && ( + + Меню + )} - {/* Кнопка сворачивания/разворачивания */} {collapsed ? : } @@ -156,56 +113,94 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, - {/* Содержимое меню */} - - - {!collapsed && ( - - Меню - - )} - {menuData && ( + {/* Основное содержимое меню */} + + + {data && ( )} - {/* Футер */} - - - {/* Ресайзер */} {!collapsed && ( )} + + {/* Модальное окно редактирования */} + ); }; +const EditMenuItemDialog = ({ open, item, onClose, onSave }) => { + const [formData, setFormData] = useState(item || {}); + + useEffect(() => { + setFormData(item || {}); + }, [item]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = () => { + onSave(formData); + }; + + if (!item) return null; + + return ( + + Редактирование элемента меню + + + + + {/* Дополнительные поля для редактирования */} + + + + + + + + ); +}; + export default SidebarMenu; \ No newline at end of file diff --git a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx index 636a973..9b3e01e 100644 --- a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx +++ b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx @@ -1,21 +1,24 @@ -import React from "react"; +// MenuItem.jsx +import React, { useState } from "react"; import { ListItem, ListItemIcon, ListItemText, Collapse, List, - styled + styled, + IconButton, + Menu, + MenuItem as MuiMenuItem } from "@mui/material"; -import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material"; +import { ExpandLess, ExpandMore, Folder, FolderOpen, Edit } from "@mui/icons-material"; import { getStatusColor } from "../../TreeChart/dataUtils"; - - +import StatusIndicator from "./StatusIndicator" const StyledListItem = styled(ListItem)(({ theme, level }) => ({ cursor: "pointer", paddingLeft: theme.spacing(2 + level * 2), - position: 'relative', + position: 'relative', '&:hover': { backgroundColor: theme.palette.action.hover, }, @@ -24,59 +27,64 @@ 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, - padding: theme.spacing(0.5), - '&:hover': { - backgroundColor: theme.palette.action.selected, - }, -})); - -const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => { - const [isOpen, setIsOpen] = React.useState(false); +const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => { + const [isOpen, setIsOpen] = useState(false); + const [contextMenu, setContextMenu] = useState(null); const hasChildren = Array.isArray(item.items) && item.items.length > 0; + const handleContextMenu = (e) => { + e.preventDefault(); + setContextMenu( + contextMenu === null + ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } + : null + ); + }; + + const handleCloseContextMenu = () => { + setContextMenu(null); + }; + + const handleEditClick = () => { + onEdit(item); + handleCloseContextMenu(); + }; + const handleToggle = (e) => { e.stopPropagation(); setIsOpen(!isOpen); }; - const handleOpenTab = (e) => { + const handleItemClick = (e) => { e.stopPropagation(); - const allChildren = getAllChildren(item); - onSelectItem(item.id, item.title, allChildren); + + // Если есть обработчик выбора и элемент можно выбрать + if (onSelectItem && (!item.items || item.items.length === 0)) { + onSelectItem(item); + } + + // Если есть подэлементы - переключаем раскрытие + if (item.items && item.items.length > 0) { + setIsOpen(!isOpen); + } }; return ( <> - {/* Индикатор статуса */} {!collapsed && } - - {hasChildren ? (isOpen ? : ) : } - + {hasChildren ? (isOpen ? : ) : } {!collapsed && ( @@ -88,10 +96,39 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => { }} /> {hasChildren && (isOpen ? : )} + + {level > 0 && ( + { + e.stopPropagation(); + handleEditClick(); + }} + sx={{ ml: 1 }} + > + + + )} )} + {/* Контекстное меню */} + + + Редактировать + + + {hasChildren && !collapsed && ( @@ -100,6 +137,7 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => { key={index} item={child} onSelectItem={onSelectItem} + onEdit={onEdit} level={level + 1} collapsed={collapsed} /> @@ -111,15 +149,4 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => { ); }; -const getAllChildren = (node) => { - let children = []; - if (node.items && node.items.length > 0) { - node.items.forEach((child) => { - children.push(child); - children = children.concat(getAllChildren(child)); - }); - } - return children; -}; - export default MenuItem; \ No newline at end of file diff --git a/src/Components/Layout/SidebarMenuWrapper.jsx b/src/Components/Layout/SidebarMenuWrapper.jsx new file mode 100644 index 0000000..22255f9 --- /dev/null +++ b/src/Components/Layout/SidebarMenuWrapper.jsx @@ -0,0 +1,104 @@ +import React, { useState, useEffect } from 'react'; +import SidebarMenu from './SidebarMenu'; +import { Box, CircularProgress, Typography } from '@mui/material'; +import axios from 'axios'; + +const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect }) => { + const [menuData, setMenuData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingItem, setEditingItem] = useState(null); + const [editModalOpen, setEditModalOpen] = useState(false); + + useEffect(() => { + const fetchMenuData = async () => { + try { + const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`); + setMenuData(response.data); // axios хранит данные в response.data + } catch (err) { + console.error('Error fetching menu data:', err); + setError(err.message || 'Failed to fetch menu data'); + } finally { + setLoading(false); + } + }; + + fetchMenuData(); + }, []); + + const handleSaveChanges = async (updatedItem) => { + try { + const response = await axios.put( + `${import.meta.env.VITE_BACK_URL}/api/menu/${updatedItem.id}`, + updatedItem, + { + headers: { + 'Content-Type': 'application/json', + } + } + ); + + // Обновляем локальное состояние + const updateItemInTree = (items) => { + return items.map(item => { + if (item.id === updatedItem.id) { + return { ...item, ...updatedItem }; + } + if (item.items) { + return { ...item, items: updateItemInTree(item.items) }; + } + return item; + }); + }; + + setMenuData(prev => ({ + ...prev, + items: updateItemInTree(prev.items), + })); + + setEditModalOpen(false); + } catch (err) { + console.error('Error updating menu item:', err); + setError(err.response?.data?.message || err.message || 'Failed to update menu item'); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + Error loading menu: {error} + + ); + } + + if (!menuData) { + return null; + } + + return ( + { + setEditingItem(item); + setEditModalOpen(true); + }} + onSelectItem={onMenuSelect} + editModalOpen={editModalOpen} + editingItem={editingItem} + onCloseEditModal={() => setEditModalOpen(false)} + onSaveChanges={handleSaveChanges} + /> + ); +}; + +export default SidebarMenuWrapper; \ No newline at end of file diff --git a/src/Components/TreeChart/menuData.json b/src/Components/TreeChart/menuData.json index fcbdd2f..b2526a2 100755 --- a/src/Components/TreeChart/menuData.json +++ b/src/Components/TreeChart/menuData.json @@ -11,16 +11,18 @@ "id": "media_server_1", "items": [ { - "id": "18", + "id": "device$18", "title": "Graviton S2082I (device$18)", "items": [ { - "id": "4", - "title": "OS Linux (module$4) АО", + "id": "module$11", + "title": "OS Linux (module$11) АО", "items": [ { - "id": "190", - "title": "Загрузка процессора за 1 минуту" + "id": "zvks_cpu1min", + "title": "Загрузка процессора за 1 минуту", + "metric": "zvks_cpu1min", + "description": "Загрузка процессора за 1 минуту" }, { "id": "191", @@ -399,16 +401,18 @@ "id": "media_server_2", "items": [ { - "id": "182", + "id": "device$19", "title": "Graviton S2082I (device$19)", "items": [ { - "id": "42", - "title": "OS Linux (module$6) АО", + "id": "module$13", + "title": "OS Linux (module$13) АО", "items": [ { - "id": "371", - "title": "Загрузка процессора за 1 минуту" + "id": "zvks_cpu1min", + "title": "Загрузка процессора за 1 минуту", + "metric": "zvks_cpu1min", + "description": "Загрузка процессора за 1 минуту" }, { "id": "372", diff --git a/src/Components/TreeChart/tabContent.jsx b/src/Components/TreeChart/tabContent.jsx index 6021f3a..f21b002 100755 --- a/src/Components/TreeChart/tabContent.jsx +++ b/src/Components/TreeChart/tabContent.jsx @@ -1,39 +1,22 @@ import React, { lazy, Suspense } from "react"; -import Skeleton from '@mui/material/Skeleton'; -import Box from '@mui/material/Box'; +import Skeleton from "@mui/material/Skeleton"; +import Box from "@mui/material/Box"; -const PrometheusChart = lazy(() => import('../../Charts2/PrometheusChart')); +const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart")); import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender"; -const getMetricName = (id) => { - return `zvks_apiforsnmp_measure_${id}`; -}; - -const getAllChildIds = (node) => { - let ids = []; - if (node.id) { - ids.push(node.id); - } - if (node.items && node.items.length > 0) { - node.items.forEach((child) => { - ids = ids.concat(getAllChildIds(child)); - }); - } - return ids; -}; - // Компонент Skeleton для графика const ChartSkeleton = () => ( - + ); -// Компонент Skeleton для родительского контейнера +// Компонент Skeleton для контейнера const ContainerSkeleton = () => ( - - {/* Заголовок */} + + {[...Array(3)].map((_, i) => ( @@ -43,62 +26,130 @@ const ContainerSkeleton = () => ( ); -const tabContent = (data) => { - const tabContent = {}; +// Утилита для извлечения контекста из пути +const parseContextFromPath = (node) => { + const context = {}; + let current = node; - // Функция для рекурсивного обхода и сбора данных - const generateContent = (nodes) => { - nodes.forEach((node) => { - if (node.items && node.items.length > 0) { - const childrenContent = generateContent(node.items); - - const content = ( -
-

{node.title}

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

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

-
- ); - - tabContent[node.id] = { - title: node.title, - content: content, - }; - } else { - const metricName = getMetricName(node.id); - const content = ( -
-

{node.title}

- }> - - -
- ); - tabContent[node.id] = { - title: node.title, - content: content, - }; - } - }); - - return ( -
- {nodes.map((node) => ( -
{tabContent[node.id].content}
- ))} -
- ); - }; - - if (data.items && data.items.length > 0) { - generateContent(data.items); - } else { - console.warn("Данные отсутствуют или массив items пуст"); + while (current) { + if (current.id.startsWith("device$")) { + context.device = current.id.split("$")[1]; + context.deviceId = current.id; + } + if (current.id.startsWith("module$")) { + context.module = current.id; + context.source_id = current.id; + } + current = current.parent; } - return tabContent; + return context; +}; + +// Основная функция построения контента вкладок +const tabContent = (data, cache = {}) => { + const tabContentMap = { ...cache }; + + if (!data || !data.items || data.items.length === 0) { + console.warn("Данные отсутствуют или массив items пуст", data); + return tabContentMap; + } + + const processNode = (node, parentContext = {}) => { + // Получаем полный контекст из всей цепочки родителей + const pathContext = parseContextFromPath(node); + const currentContext = { ...parentContext, ...pathContext }; + + // Генерируем уникальный ключ на основе пути + const path = []; + let current = node; + while (current) { + path.unshift(current.id); + current = current.parent; + } + const pathId = path.join('_'); + + if (Array.isArray(node.items) && node.items.length > 0) { + const children = node.items + .map((child) => processNode(child, currentContext)) + .filter(Boolean); + + const content = ( +
+

{node.title}

+ }> + c.content)} /> + +
+ ); + + tabContentMap[pathId] = { + title: node.title, + content, + context: currentContext, + }; + + return { content, context: currentContext }; + } + + if (node.metric) { + const chartKey = `${node.metric}-${currentContext.device || "all"}-${currentContext.module || "all"}-${pathId}`; + + const content = ( +
+

{node.title}

+ {currentContext.device &&

Устройство: {currentContext.device}

} + {currentContext.module &&

Модуль: {currentContext.module}

} + }> + + +
+ ); + + tabContentMap[pathId] = { + title: node.title, + content, + context: currentContext, + }; + + return { content, context: currentContext }; + } + + // Узел без метрики и без вложенных — просто заголовок + const content = ( +
+

{node.title}

+
+ ); + + tabContentMap[pathId] = { + title: node.title, + content, + context: currentContext, + }; + + return { content, context: currentContext }; + }; + + try { + processNode(data); + } catch (error) { + console.error("Ошибка обработки данных:", error); + } + + return tabContentMap; }; export default tabContent; \ No newline at end of file diff --git a/src/Components/UI/MUItabs.jsx b/src/Components/UI/MUItabs.jsx index 4a03b60..d4ae078 100644 --- a/src/Components/UI/MUItabs.jsx +++ b/src/Components/UI/MUItabs.jsx @@ -1,9 +1,11 @@ import React from "react"; -import { Tabs, Tab, Box, styled } from "@mui/material"; +import { Tabs, Tab, Box, styled, Typography } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; const StyledTab = styled(Tab)(({ theme }) => ({ minHeight: 48, + padding: theme.spacing(1, 2), + textTransform: 'none', '&.Mui-selected': { color: theme.palette.primary.main, fontWeight: theme.typography.fontWeightMedium, @@ -14,9 +16,43 @@ const StyledTab = styled(Tab)(({ theme }) => ({ }, })); +const TabLabel = ({ title, onClose }) => { + return ( + + + {title} + + + + ); +}; + const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { const handleMouseDown = (e, id) => { - if (e.button === 1) { + if (e.button === 1) { // Средняя кнопка мыши e.preventDefault(); onCloseTab(id); } @@ -26,6 +62,12 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { onTabClick(newValue); }; + // Статические вкладки + const staticTabs = [ + { id: "Главная", title: "Главная" }, + { id: "Визуализация", title: "Визуализация" } + ]; + return ( { onChange={handleChange} variant="scrollable" scrollButtons="auto" + allowScrollButtonsMobile aria-label="tabs" > {/* Статические вкладки */} - handleMouseDown(e, "Главная")} - /> - handleMouseDown(e, "Визуализация")} - /> + {staticTabs.map(tab => ( + handleMouseDown(e, tab.id)} + /> + ))} {/* Динамические вкладки */} {tabs.map((tab) => ( - {tab.title} - { - e.stopPropagation(); - onCloseTab(tab.id); - }} - /> - + { + e.stopPropagation(); + onCloseTab(tab.id); + }} + /> } value={tab.id} onMouseDown={(e) => handleMouseDown(e, tab.id)} From efd8532ac38ba282f4d427a28996d4271e3f06ac Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Tue, 27 May 2025 21:40:59 -0400 Subject: [PATCH 15/18] automatic generation of tabs for the sidebar menu --- src/Components/Layout/Dashboard.jsx | 48 +++++++------------ src/Components/Layout/SidebarMenu.jsx | 2 +- .../Layout/SidebarMenuComponents/MenuItem.jsx | 22 +++------ src/Components/hooks/TabContent.jsx | 18 +++---- src/Components/hooks/useTabs.jsx | 4 +- 5 files changed, 36 insertions(+), 58 deletions(-) diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx index 74565b0..8658bc3 100755 --- a/src/Components/Layout/Dashboard.jsx +++ b/src/Components/Layout/Dashboard.jsx @@ -9,7 +9,7 @@ import TabContent from "../hooks/TabContent"; import menuData from "../TreeChart/menuData.json"; import SidebarMenuWrapper from "./SidebarMenuWrapper"; -// Создаем стилизованные компоненты +// Стилизованные компоненты const DashboardContainer = styled(Box)(({ theme }) => ({ display: 'flex', height: '100vh', @@ -82,49 +82,36 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => { const handleMenuSelect = (item) => { const tabId = `tab_${item.id}`; const tabTitle = item.title || 'Новая вкладка'; + const tabContent =
Контент для {item.title}
; - const generateTabContentForItem = (item) => { - return ( - - {item.title} - {item.description && ( - {item.description} - )} - {item.metric && ( - - Метрика: {item.metric} - {/* Здесь можно добавить визуализацию метрики */} - - )} - - ); - }; - - // Проверяем, существует ли уже такая вкладка const existingTab = tabs.find(tab => tab.id === tabId); if (!existingTab) { - const newTab = { - id: tabId, - title: tabTitle, - content: generateTabContentForItem(item), - type: 'menuItem', - itemData: item // Сохраняем данные элемента для возможного обновления - }; - - handleOpenTab(newTab); + handleOpenTab(tabId, tabTitle, tabContent); // Передаем аргументы отдельно } else { setActiveTab(tabId); } }; + // Вспомогательная функция для получения всех дочерних элементов + const getAllChildren = (node) => { + let children = []; + if (node.items && node.items.length > 0) { + node.items.forEach((child) => { + children.push(child); + children = children.concat(getAllChildren(child)); + }); + } + return children; + }; + return ( - {/* Сайдбар - теперь используется SidebarMenuWrapper */} + {/* Сайдбар */} {/* Основной контент */} @@ -160,6 +147,7 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => { treeData1={treeData1} tabContent={tabContent} handleOpenTab={handleOpenTab} + tabs={tabs} /> diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index a9aad6c..579ce0c 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -122,7 +122,7 @@ const SidebarMenu = ({ collapsed={collapsed} level={0} onEdit={onEditItem} - onSelect={onSelectItem} + onSelectItem={onSelectItem} /> )}
diff --git a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx index 9b3e01e..6fd6607 100644 --- a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx +++ b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx @@ -12,8 +12,7 @@ import { MenuItem as MuiMenuItem } from "@mui/material"; import { ExpandLess, ExpandMore, Folder, FolderOpen, Edit } from "@mui/icons-material"; -import { getStatusColor } from "../../TreeChart/dataUtils"; -import StatusIndicator from "./StatusIndicator" +import StatusIndicator from "./StatusIndicator"; const StyledListItem = styled(ListItem)(({ theme, level }) => ({ cursor: "pointer", @@ -55,25 +54,17 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => { setIsOpen(!isOpen); }; - const handleItemClick = (e) => { - e.stopPropagation(); - - // Если есть обработчик выбора и элемент можно выбрать - if (onSelectItem && (!item.items || item.items.length === 0)) { + const handleClick = () => { + if (onSelectItem) { onSelectItem(item); } - - // Если есть подэлементы - переключаем раскрытие - if (item.items && item.items.length > 0) { - setIsOpen(!isOpen); - } }; return ( <> { )} - {/* Контекстное меню */} { {item.items.map((child, index) => ( { ); }; -export default MenuItem; \ No newline at end of file +export default MenuItem; diff --git a/src/Components/hooks/TabContent.jsx b/src/Components/hooks/TabContent.jsx index a1c8102..cfc050b 100644 --- a/src/Components/hooks/TabContent.jsx +++ b/src/Components/hooks/TabContent.jsx @@ -4,11 +4,11 @@ import FlowChart from "../TreeChart/FlowChart"; import { getStatusColor } from "../TreeChart/dataUtils"; -const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleOpenTab }) => { +const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, handleOpenTab }) => { // Функция для подсчета количества элементов каждого статуса const countStatuses = (data) => { const counts = { green: 0, yellow: 0, orange: 0, red: 0 }; - + const countRecursive = (node) => { if (node.status) { counts[node.status]++; @@ -17,7 +17,7 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO node.items.forEach(child => countRecursive(child)); } }; - + countRecursive(data); return counts; }; @@ -38,11 +38,11 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO
- + {/* Контейнер для индикаторов статусов */} -
@@ -63,7 +63,7 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO
))}
- + @@ -71,7 +71,7 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO } else if (activeTab === "Визуализация") { return handleOpenTab(id, title)} />; } else { - const tabData = tabContent[activeTab]; + const tabData = tabs.find(t => t.id === activeTab); return tabData ? tabData.content :

Нет данных

; } }; diff --git a/src/Components/hooks/useTabs.jsx b/src/Components/hooks/useTabs.jsx index 2df5f13..23a6570 100644 --- a/src/Components/hooks/useTabs.jsx +++ b/src/Components/hooks/useTabs.jsx @@ -11,11 +11,11 @@ const useTabs = (initialTab) => { if (existingTabIndex >= 0) { return prevTabs.map((tab, index) => ({ ...tab, - content: content || tab.content, + title: title || tab.title, + content: content || tab.content, active: index === existingTabIndex })); } - // Добавляем новую вкладку return [ ...prevTabs.map(tab => ({ ...tab, active: false })), From 08e2c24a63835dd6065054ab27e432ede93f0cf4 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Tue, 27 May 2025 22:14:48 -0400 Subject: [PATCH 16/18] tabs and charts update --- src/Charts2/Components/metricsService.jsx | 50 +++++++++--- src/Charts2/PrometheusChart.jsx | 92 ++++++++-------------- src/Components/Layout/Dashboard.jsx | 29 ++++++- src/Components/Layout/MetricTabContent.jsx | 25 ++++++ src/Components/UI/MUItabs.jsx | 8 +- src/Components/hooks/useTabs.jsx | 36 ++------- 6 files changed, 132 insertions(+), 108 deletions(-) create mode 100644 src/Components/Layout/MetricTabContent.jsx diff --git a/src/Charts2/Components/metricsService.jsx b/src/Charts2/Components/metricsService.jsx index 159d3a3..90d9261 100644 --- a/src/Charts2/Components/metricsService.jsx +++ b/src/Charts2/Components/metricsService.jsx @@ -26,8 +26,11 @@ class MetricsService { this.socket.on('connect', () => { console.log('WebSocket connected'); - this.subscriptions.forEach((_, metric) => { - this.socket.emit('subscribe-metric', { metric }); + // Восстанавливаем подписки при переподключении + this.subscriptions.forEach((_, metricKey) => { + const [metric, query] = metricKey.split('?'); + const filters = this.parseFiltersFromKey(metricKey); + this.socket.emit('subscribe-metric', { metric, filters }); }); }); @@ -83,35 +86,53 @@ class MetricsService { }); } - subscribeToMetric(metric, callback, interval = 5000, filters = {}) { + subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) { this.connectWebSocket(); - const alreadySubscribed = this.subscriptions.has(metric); - const callbacks = this.subscriptions.get(metric) || []; + const alreadySubscribed = this.subscriptions.has(metricKey); + const callbacks = this.subscriptions.get(metricKey) || []; callbacks.push(callback); - this.subscriptions.set(metric, callbacks); + this.subscriptions.set(metricKey, callbacks); if (!alreadySubscribed) { - this.socket.emit('subscribe-metric', { metric, interval, filters }); + // Разделяем metricKey на метрику и фильтры + const [metric] = metricKey.split('?'); + this.socket.emit('subscribe-metric', { + metric, + interval, + filters + }); } - return () => this.unsubscribeFromMetric(metric, callback); + return () => this.unsubscribeFromMetric(metricKey, callback); } - unsubscribeFromMetric(metric, callback) { - const callbacks = this.subscriptions.get(metric) || []; + unsubscribeFromMetric(metricKey, callback) { + const callbacks = this.subscriptions.get(metricKey) || []; const filtered = callbacks.filter(cb => cb !== callback); if (filtered.length === 0) { - this.subscriptions.delete(metric); + this.subscriptions.delete(metricKey); if (this.socket && this.socket.connected) { + const [metric] = metricKey.split('?'); this.socket.emit('unsubscribe-metric', { metric }); } } else { - this.subscriptions.set(metric, filtered); + this.subscriptions.set(metricKey, filtered); } } + parseFiltersFromKey(metricKey) { + const parts = metricKey.split('?'); + if (parts.length < 2) return {}; + + return parts[1].split('&').reduce((acc, pair) => { + const [key, value] = pair.split('='); + if (key && value) acc[key] = value; + return acc; + }, {}); + } + cleanupAll() { if (this.socket && this.socket.connected) { this.socket.emit('unsubscribe-all'); @@ -128,4 +149,7 @@ class MetricsService { } } -export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL); +// Создаем экземпляр сервиса +const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL); + +export default metricsService; \ No newline at end of file diff --git a/src/Charts2/PrometheusChart.jsx b/src/Charts2/PrometheusChart.jsx index 33b711e..5d7fe46 100644 --- a/src/Charts2/PrometheusChart.jsx +++ b/src/Charts2/PrometheusChart.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import LineChartComponent from './Components/LineChartComponent'; import DateRangeSelector from './Components/DateRangeSelector'; -import { metricsService } from './Components/metricsService'; +import metricsService from './Components/metricsService'; import { Button, Radio, message, Tag } from 'antd'; import moment from 'moment'; @@ -11,14 +11,10 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { filters = {}, title = metricName, description, - context = {} // Добавляем контекст из path + context = {} } = metricInfo || {}; - console.log("⚙️ PrometheusChart -> metricInfo:", metricInfo); - console.log("📌 Контекст -> device:", context.device, "source_id:", context.source_id, "deviceId:", context.deviceId); - - // Получаем полный контекст из родительских элементов - const { device, source_id: module, deviceId, parent } = context; + const { device, source_id: module } = context; const [chartData, setChartData] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -29,9 +25,11 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { const [endDate, setEndDate] = useState(moment().toDate()); const [isLiveUpdating, setIsLiveUpdating] = useState(false); - // Генерация уникального ключа для подписки const getSubscriptionKey = () => { - return `${metricName}_${device || 'all'}_${module || 'all'}_${deviceId || 'all'}`; + const filterParts = []; + if (device) filterParts.push(`device=${device}`); + if (module) filterParts.push(`source_id=${module}`); + return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`; }; const formatMetricData = (dataArray) => { @@ -48,25 +46,23 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { }; const fetchHistoricalData = async (start, end) => { - setIsLoading(true); - setError(null); + setIsLoading(true); + setError(null); - try { - const extendedFilters = { - ...filters, - ...(device && { device: device.toString() }), // убедитесь, что device строка - ...(source_id && { source_id: source_id.toString() }) - }; + try { + const extendedFilters = { + ...filters, + ...(device && { device: device.toString() }), + ...(module && { source_id: module.toString() }) + }; - console.log('Fetching with filters:', extendedFilters); // для отладки - - const data = await metricsService.fetchMetricsRange( - metricName, - Math.floor(start.getTime() / 1000), - Math.floor(end.getTime() / 1000), - 15, - extendedFilters - ); + const data = await metricsService.fetchMetricsRange( + metricName, + Math.floor(start.getTime() / 1000), + Math.floor(end.getTime() / 1000), + 15, + extendedFilters + ); const formattedData = formatMetricData(data); if (formattedData.length > 0) { @@ -97,24 +93,15 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { fetchHistoricalData(start, end).finally(() => setIsLoading(false)); return metricsService.subscribeToMetric( - getSubscriptionKey(), // Уникальный ключ для подписки + getSubscriptionKey(), (newData) => { - const filteredData = newData.filter(item => { - // Строгая проверка всех доступных фильтров - if (device && item.device?.trim() !== device) return false; - if (module && item.source_id !== module) return false; - return true; + const formattedData = formatMetricData(newData); + setChartData(prev => { + const newChartData = [...prev, ...formattedData] + .filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i) + .slice(-200); + return newChartData; }); - - if (filteredData.length > 0) { - const formattedData = formatMetricData(filteredData); - setChartData(prev => { - const newChartData = [...prev, ...formattedData] - .filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i) - .slice(-200); - return newChartData; - }); - } }, 5000, { @@ -151,17 +138,6 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { }; }, [mode, metricName, device, module]); - // Рекурсивно собираем путь для отображения - const getFullPath = () => { - const path = []; - let current = parent; - while (current) { - path.unshift(current.title); - current = current.parent; - } - return path.join(' > '); - }; - const metaInfo = [ metricMeta.instance && `Instance: ${metricMeta.instance}`, metricMeta.job && `Job: ${metricMeta.job}`, @@ -203,14 +179,8 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { )} - {/* Отображаем полный путь к метрике */} - {parent && ( -
- Путь: {getFullPath()} - {device && Устройство: {device}} - {module && Модуль: {module.split('$')[1]}} -
- )} + {device && Устройство: {device}} + {module && Модуль: {module.split('$')[1]}} {isLoading ? (
Загрузка графика...
diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx index 8658bc3..4c6d179 100755 --- a/src/Components/Layout/Dashboard.jsx +++ b/src/Components/Layout/Dashboard.jsx @@ -8,6 +8,7 @@ import useSidebarResize from "../hooks/useSidebarResize"; import TabContent from "../hooks/TabContent"; import menuData from "../TreeChart/menuData.json"; import SidebarMenuWrapper from "./SidebarMenuWrapper"; +import MetricTabContent from "./MetricTabContent" // Стилизованные компоненты const DashboardContainer = styled(Box)(({ theme }) => ({ @@ -82,12 +83,36 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => { const handleMenuSelect = (item) => { const tabId = `tab_${item.id}`; const tabTitle = item.title || 'Новая вкладка'; - const tabContent =
Контент для {item.title}
; + + // Если это метрика, создаём специальный контент с графиком + const tabContent = item.metric + ? + :
Контент для {item.title}
; const existingTab = tabs.find(tab => tab.id === tabId); if (!existingTab) { - handleOpenTab(tabId, tabTitle, tabContent); // Передаем аргументы отдельно + const newTab = { + id: tabId, + title: tabTitle, + content: tabContent, + type: item.metric ? 'metric' : 'menuItem', + metric: item.metric, + filters: item.filters + }; + handleOpenTab(newTab); } else { setActiveTab(tabId); } diff --git a/src/Components/Layout/MetricTabContent.jsx b/src/Components/Layout/MetricTabContent.jsx new file mode 100644 index 0000000..13fff6c --- /dev/null +++ b/src/Components/Layout/MetricTabContent.jsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react'; +import PrometheusChart from '../../Charts2/PrometheusChart'; +import metricsService from '../../Charts2/Components/metricsService'; + +const MetricTabContent = ({ metricInfo }) => { + // Очистка подписок при закрытии вкладки + useEffect(() => { + return () => { + if (metricInfo?.name) { + metricsService.unsubscribeFromMetric(metricInfo.name); + } + }; + }, [metricInfo?.name]); + + return ( +
+ +
+ ); +}; + +export default MetricTabContent; \ No newline at end of file diff --git a/src/Components/UI/MUItabs.jsx b/src/Components/UI/MUItabs.jsx index d4ae078..173b492 100644 --- a/src/Components/UI/MUItabs.jsx +++ b/src/Components/UI/MUItabs.jsx @@ -62,7 +62,7 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { onTabClick(newValue); }; - // Статические вкладки + // Статические вкладки (сохраняем оригинальные id) const staticTabs = [ { id: "Главная", title: "Главная" }, { id: "Визуализация", title: "Визуализация" } @@ -87,9 +87,9 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { {/* Статические вкладки */} {staticTabs.map(tab => ( handleMouseDown(e, tab.id)} /> ))} @@ -97,7 +97,7 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { {/* Динамические вкладки */} {tabs.map((tab) => ( { const [tabs, setTabs] = useState([]); const [activeTab, setActiveTab] = useState(initialTab); - const handleOpenTab = useCallback((id, title, content) => { + const handleOpenTab = useCallback((newTab) => { setTabs((prevTabs) => { - const existingTabIndex = prevTabs.findIndex(tab => tab.id === id); - - if (existingTabIndex >= 0) { - return prevTabs.map((tab, index) => ({ - ...tab, - title: title || tab.title, - content: content || tab.content, - active: index === existingTabIndex - })); + const exists = prevTabs.some((tab) => tab.id === newTab.id); + if (!exists) { + return [...prevTabs, newTab]; } - // Добавляем новую вкладку - return [ - ...prevTabs.map(tab => ({ ...tab, active: false })), - { - id, - title, - content: content ||
Loading...
, - active: true - } - ]; + return prevTabs; }); - setActiveTab(id); + setActiveTab(newTab.id); }, []); const handleCloseTab = useCallback((id) => { setTabs((prevTabs) => { const newTabs = prevTabs.filter((tab) => tab.id !== id); - if (activeTab === id) { - setActiveTab(newTabs.length > 0 - ? newTabs[newTabs.length - 1].id - : initialTab - ); + setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : initialTab); } - return newTabs; }); }, [activeTab, initialTab]); @@ -63,4 +43,4 @@ const useTabs = (initialTab) => { }; }; -export default useTabs; \ No newline at end of file +export default useTabs; From 4088dacba45bfa2478df9affa2e48c77e3bd17f4 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Wed, 28 May 2025 01:36:11 -0400 Subject: [PATCH 17/18] added logo --- src/Components/Layout/Dashboard.jsx | 2 +- src/Components/Layout/SidebarMenu.jsx | 45 +++++++++++++++++++++------ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx index 4c6d179..8841bc7 100755 --- a/src/Components/Layout/Dashboard.jsx +++ b/src/Components/Layout/Dashboard.jsx @@ -152,7 +152,7 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => { borderColor: 'divider', backgroundColor: 'background.default', zIndex: 1, - transform: 'translateY(3px)' + transform: 'translateY(31px)' }}> - {/* Заголовок и кнопка сворачивания */} + {/* Заголовок с логотипом */} - {!collapsed && ( - - Меню - - )} + {/* Логотип (занимает все пространство) */} + + {collapsed ? ( + + ) : ( + + )} + + {/* Кнопка сворачивания (абсолютное позиционирование) */} {collapsed ? : } From 8223cc4a27ae72dfde91e26f797fb70491c36cf0 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Wed, 28 May 2025 01:47:18 -0400 Subject: [PATCH 18/18] Added environment variables --- src/App.jsx | 32 ++++++++++++++++---------------- src/Components/UI/LoginModal.jsx | 24 ++++++++++++------------ src/Components/UI/auth.jsx | 24 +++++++++++------------- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index abbe919..798e186 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -52,22 +52,22 @@ function App() { }; 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); - } - }; + try { + await axios.post(`${import.meta.env.VITE_BACK_URL}/api/auth/logout`, null, { + withCredentials: true, // чтобы отправлялись куки + }); + + localStorage.removeItem('access_token'); + setAuthState({ + isAuthenticated: false, + isLoading: false, + user: null, + }); + setShowLoginModal(true); + } catch (error) { + console.error('Logout failed:', error); + } +}; // Полноэкранный лоадер во время проверки авторизации if (authState.isLoading) { diff --git a/src/Components/UI/LoginModal.jsx b/src/Components/UI/LoginModal.jsx index 22559d6..f76b7b3 100755 --- a/src/Components/UI/LoginModal.jsx +++ b/src/Components/UI/LoginModal.jsx @@ -17,27 +17,27 @@ const LoginModal = ({ onLogin, onClose }) => { e.preventDefault(); try { - const response = await fetch('http://192.168.2.39:3000/api/auth/login', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ login: username, password }), - }); - - const data = await response.json(); + const { data } = await axios.post( + `${import.meta.env.VITE_BACK_URL}/api/auth/login`, + { login: username, password }, + { + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, + } + ); if (data.success) { localStorage.setItem('access_token', data.access_token); - onLogin(data.user); + onLogin(data.user); onClose(); } else { setError(data.message || "Неверный логин или пароль"); } } catch (err) { console.error('Ошибка при отправке запроса:', err); - setError("Ошибка при подключении к серверу"); + setError(err.response?.data?.message || "Ошибка при подключении к серверу"); } }; diff --git a/src/Components/UI/auth.jsx b/src/Components/UI/auth.jsx index 6d3699c..c0beca8 100644 --- a/src/Components/UI/auth.jsx +++ b/src/Components/UI/auth.jsx @@ -2,21 +2,19 @@ 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 + const { data } = await axios.get( + `${import.meta.env.VITE_BACK_URL}/api/auth/check`, + { + withCredentials: true, headers: { - 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`, + '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 }; + return data; + } catch (err) { + console.error('Auth check failed:', err); + return { isAuthenticated: false }; } }; \ No newline at end of file