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/App.jsx b/src/App.jsx
index b0345dd..798e186 100755
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,48 +1,115 @@
-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, 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 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 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) {
+ return (
+
+
+
+
+
+ Проверка авторизации...
+
+
+
+ );
+ }
+
return (
- {!isAuthenticated && showLoginModal ? (
+ {!authState.isAuthenticated ? (
<>
- {/* Логотип */}
-
+
-
setShowLoginModal(false)}
/>
@@ -52,13 +119,14 @@ function App() {
display: "flex",
height: "100vh",
overflow: "hidden",
- bgcolor: "background.default",
- color: "text.primary"
+ bgcolor: "background.default"
}}>
-
-
- setIsDarkMode((prev) => !prev)} />
-
+
)}
diff --git a/src/Charts/Components/ChartSkeleton.jsx b/src/Charts/Components/ChartSkeleton.jsx
new file mode 100644
index 0000000..090f1e7
--- /dev/null
+++ b/src/Charts/Components/ChartSkeleton.jsx
@@ -0,0 +1,26 @@
+const ChartSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((_, i) => (
+
+ ))}
+
+
+);
\ No newline at end of file
diff --git a/src/Charts/Components/LineChartComponent.jsx b/src/Charts/Components/LineChartComponent.jsx
index 3c548ac..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 (
@@ -203,7 +213,7 @@ const LineChartComponent = ({
{instanceKeys.map((instance, index) => (
(
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((_, i) => (
+
+ ))}
+
+
+);
const PrometheusChart = ({ metricName }) => {
const [chartData, setChartData] = useState(null);
@@ -19,7 +50,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,8 +100,65 @@ const PrometheusChart = ({ metricName }) => {
return 1800; // > 24 часов
}, []);
- const fetchData = useCallback(() => {
+ const processMetricsData = useCallback((response, replace = false) => {
+ console.log('Processing metrics data:', response);
+ if (response.metric !== metricName) return;
+
+ const dataArray = Array.isArray(response.data) ? response.data : [response.data];
+ if (!dataArray.length) return;
+
+ const newData = {};
+ const rangeSeconds = useCustomRange
+ ? (endDate.getTime() - startDate.getTime()) / 1000
+ : selectedRange.value;
+
+ dataArray.forEach(item => {
+ const instance = item.instance || 'default';
+ if (!newData[instance]) newData[instance] = [];
+
+ let timestamp;
+ if (typeof item.timestamp === 'number') {
+ timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000;
+ } else {
+ timestamp = Date.now();
+ }
+
+ const value = parseFloat(item.value);
+ const formattedTime = formatTime(timestamp, rangeSeconds);
+
+ newData[instance].push({
+ time: formattedTime.display,
+ fullTime: formattedTime.fullDisplay,
+ value,
+ timestamp
+ });
+ });
+
+ Object.keys(newData).forEach(instance => {
+ newData[instance] = newData[instance]
+ .sort((a, b) => a.timestamp - b.timestamp)
+ .slice(-1000);
+ });
+
+ if (replace) {
+ setChartData(newData); // Заменяем полностью
+ } else {
+ setChartData(prev => {
+ const merged = { ...(prev || {}) };
+ Object.keys(newData).forEach(instance => {
+ if (!merged[instance]) merged[instance] = [];
+ merged[instance] = [...merged[instance], ...newData[instance]]
+ .sort((a, b) => a.timestamp - b.timestamp)
+ .slice(-1000);
+ });
+ return merged;
+ });
+ }
+ }, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]);
+
+
+ const fetchData = useCallback(() => {
if (isSelectingRange) return;
const now = Math.floor(Date.now() / 1000);
@@ -79,116 +166,24 @@ const PrometheusChart = ({ metricName }) => {
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()
+ webSocketManager.getMetricsRange(metricName, start, end, step)
+ .then(data => {
+ processMetricsData({ metric: metricName, data });
+ })
+ .catch(error => {
+ console.error('Error fetching metrics:', error);
});
- }
- }, [metricName, selectedRange.value, isSelectingRange]);
-
- const processMetricsData = useCallback((response) => {
- 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;
-
- 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();
- }
-
- const value = parseFloat(item.value);
- const formattedTime = formatTime(timestamp, rangeSeconds);
-
- newData[instance].push({
- time: formattedTime.display,
- fullTime: formattedTime.fullDisplay,
- value: value,
- timestamp: timestamp
- });
- });
-
- // Сортируем и ограничиваем данные
- Object.keys(newData).forEach(instance => {
- newData[instance] = newData[instance]
- .sort((a, b) => a.timestamp - b.timestamp)
- .slice(-1000);
- });
- return newData;
- });
- }, [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,
- });
-
- socketRef.current = socket;
-
- 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;
- }, []);
+ }, [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,17 +197,17 @@ 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({
metric: metricName,
data: processedData
- });
+ }, true);
}
} catch (error) {
console.error('Ошибка при получении кастомных данных:', error);
@@ -220,8 +215,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 +224,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 +235,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 +253,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 +306,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 +331,55 @@ 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) => {
+ if (!useCustomRange) {
+ processMetricsData({ metric: metricName, data });
+ }
+ };
+
+ // Подписываемся на обновления метрики
+ const unsubscribe = webSocketManager.subscribe(metricName, handleMetricsData);
+
+ // Подписываемся на изменения статуса соединения
+ const unsubscribeStatus = webSocketManager.onConnectionStatusChange(setConnectionStatus);
+
+ return () => {
+ // Отписываемся при размонтировании компонента
+ unsubscribe();
+ unsubscribeStatus();
+
+ // Очищаем интервал обновления
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ };
+ }, [metricName, useCustomRange, processMetricsData]);
+
- // Обновим useEffect для кастомного диапазона
useEffect(() => {
if (useCustomRange && !isSelectingRange) {
- // Очищаем предыдущий таймер
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
- // Устанавливаем новый таймер с задержкой 500 мс
debounceRef.current = setTimeout(() => {
fetchCustomRangeData();
}, 500);
@@ -406,12 +403,11 @@ const PrometheusChart = ({ metricName }) => {
}
};
- // Очищаем предыдущий интервал
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
- // Запускаем сразу и затем по интервалу
+
fetchDataWrapper();
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
@@ -443,11 +439,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 (
@@ -490,5 +496,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..ff542a0
--- /dev/null
+++ b/src/Charts/WebSocketManager.jsx
@@ -0,0 +1,121 @@
+import { io } from 'socket.io-client';
+
+class WebSocketManager {
+ constructor() {
+ this.socket = null;
+ this.subscribers = new Map();
+ this.connectionStatus = 'disconnected';
+ this.connectionCallbacks = new Set();
+ this.connecting = false;
+ }
+
+ connect() {
+ 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,
+ reconnectionAttempts: Infinity,
+ reconnectionDelay: 1000,
+ reconnectionDelayMax: 5000,
+ });
+
+ 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();
+ });
+
+ 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) {
+ if (!this.socket?.connected) {
+ 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/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..2f990f6
--- /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..90d9261
--- /dev/null
+++ b/src/Charts2/Components/metricsService.jsx
@@ -0,0 +1,155 @@
+import { io } from 'socket.io-client';
+
+class MetricsService {
+ constructor(baseUrl) {
+ this.baseUrl = baseUrl || window.location.origin;
+ this.socket = null;
+ this.subscriptions = new Map();
+ this.pendingRequests = new Map();
+
+ window.addEventListener('beforeunload', () => {
+ this.cleanupAll();
+ });
+ }
+
+ connectWebSocket() {
+ 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,
+ });
+
+ this.socket.on('connect', () => {
+ console.log('WebSocket connected');
+ // Восстанавливаем подписки при переподключении
+ this.subscriptions.forEach((_, metricKey) => {
+ const [metric, query] = metricKey.split('?');
+ const filters = this.parseFiltersFromKey(metricKey);
+ this.socket.emit('subscribe-metric', { metric, filters });
+ });
+ });
+
+ 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);
+ this.pendingRequests.delete(requestId);
+ return;
+ }
+
+ const callbacks = this.subscriptions.get(metric) || [];
+ callbacks.forEach(cb => cb(data));
+ });
+
+ 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);
+ }
+ });
+ }
+
+ async fetchMetricsRange(metric, start, end, step = 15, filters = {}) {
+ return new Promise((resolve, reject) => {
+ this.connectWebSocket();
+
+ const requestId = `range-${Date.now()}`;
+ this.pendingRequests.set(requestId, { resolve, reject });
+
+ this.socket.emit('get-metrics', {
+ metric,
+ start,
+ end,
+ step,
+ filters,
+ isRangeQuery: true,
+ requestId
+ });
+
+ setTimeout(() => {
+ if (this.pendingRequests.has(requestId)) {
+ reject(new Error('Request timeout'));
+ this.pendingRequests.delete(requestId);
+ }
+ }, 30000);
+ });
+ }
+
+ subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) {
+ this.connectWebSocket();
+
+ const alreadySubscribed = this.subscriptions.has(metricKey);
+ const callbacks = this.subscriptions.get(metricKey) || [];
+ callbacks.push(callback);
+ this.subscriptions.set(metricKey, callbacks);
+
+ if (!alreadySubscribed) {
+ // Разделяем metricKey на метрику и фильтры
+ const [metric] = metricKey.split('?');
+ this.socket.emit('subscribe-metric', {
+ metric,
+ interval,
+ filters
+ });
+ }
+
+ return () => this.unsubscribeFromMetric(metricKey, callback);
+ }
+
+ unsubscribeFromMetric(metricKey, callback) {
+ const callbacks = this.subscriptions.get(metricKey) || [];
+ const filtered = callbacks.filter(cb => cb !== callback);
+
+ if (filtered.length === 0) {
+ this.subscriptions.delete(metricKey);
+ if (this.socket && this.socket.connected) {
+ const [metric] = metricKey.split('?');
+ this.socket.emit('unsubscribe-metric', { metric });
+ }
+ } else {
+ 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');
+ }
+ this.subscriptions.clear();
+ this.disconnectWebSocket();
+ }
+
+ disconnectWebSocket() {
+ if (this.socket) {
+ this.socket.disconnect();
+ this.socket = null;
+ }
+ }
+}
+
+// Создаем экземпляр сервиса
+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
new file mode 100644
index 0000000..5d7fe46
--- /dev/null
+++ b/src/Charts2/PrometheusChart.jsx
@@ -0,0 +1,208 @@
+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, Tag } from 'antd';
+import moment from 'moment';
+
+const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
+ const {
+ name: metricName,
+ filters = {},
+ title = metricName,
+ description,
+ context = {}
+ } = metricInfo || {};
+
+ const { device, source_id: module } = context;
+
+ const [chartData, setChartData] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ 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 getSubscriptionKey = () => {
+ const filterParts = [];
+ if (device) filterParts.push(`device=${device}`);
+ if (module) filterParts.push(`source_id=${module}`);
+ return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
+ };
+
+ const formatMetricData = (dataArray) => {
+ return dataArray
+ .map(item => ({
+ timestamp: item.timestamp,
+ value: parseFloat(item.value),
+ name: item.__name__ || metricName,
+ status: item.status,
+ device: item.device?.trim() || null,
+ source_id: item.source_id || null
+ }))
+ .sort((a, b) => a.timestamp - b.timestamp);
+ };
+
+ const fetchHistoricalData = async (start, end) => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const extendedFilters = {
+ ...filters,
+ ...(device && { device: device.toString() }),
+ ...(module && { source_id: module.toString() })
+ };
+
+ 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
+ });
+ }
+
+ 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(
+ getSubscriptionKey(),
+ (newData) => {
+ 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;
+ });
+ },
+ 5000,
+ {
+ ...filters,
+ ...(device && { device }),
+ ...(module && { source_id: module })
+ }
+ );
+ };
+
+ const stopRealtimeUpdates = () => {
+ setIsLiveUpdating(false);
+ metricsService.unsubscribeFromMetric(getSubscriptionKey());
+ };
+
+ 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, device, module]);
+
+ const metaInfo = [
+ 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 }}
+ >
+ Режим реального времени
+ Исторические данные
+
+
+ {mode === 'historical' && (
+
+ )}
+
+ {mode === 'realtime' && isLiveUpdating && (
+
+ )}
+
+
+ {device &&
Устройство: {device}}
+ {module &&
Модуль: {module.split('$')[1]}}
+
+ {isLoading ? (
+
Загрузка графика...
+ ) : error ? (
+
Ошибка: {error}
+ ) : chartData.length === 0 ? (
+
Нет данных для метрики: {metricName}
+ ) : (
+
+ )}
+
+ );
+};
+
+export default PrometheusChart;
\ No newline at end of file
diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx
index c979b4c..8841bc7 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,8 +7,10 @@ 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";
+import MetricTabContent from "./MetricTabContent"
-// Создаем стилизованные компоненты
+// Стилизованные компоненты
const DashboardContainer = styled(Box)(({ theme }) => ({
display: 'flex',
height: '100vh',
@@ -19,46 +20,25 @@ 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',
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(250);
const [tabContent, setTabContent] = useState({});
const [treeData1, setTreeData1] = useState(menuData);
const [treeData2, setTreeData2] = useState(menuData);
@@ -100,40 +80,103 @@ const Dashboard = () => {
return () => clearInterval(interval);
}, [treeData1, treeData2]);
+ const handleMenuSelect = (item) => {
+ const tabId = `tab_${item.id}`;
+ const tabTitle = item.title || 'Новая вкладка';
+
+ // Если это метрика, создаём специальный контент с графиком
+ const tabContent = item.metric
+ ?
+ : Контент для {item.title}
;
+
+ const existingTab = tabs.find(tab => tab.id === tabId);
+
+ if (!existingTab) {
+ const newTab = {
+ id: tabId,
+ title: tabTitle,
+ content: tabContent,
+ type: item.metric ? 'metric' : 'menuItem',
+ metric: item.metric,
+ filters: item.filters
+ };
+ handleOpenTab(newTab);
+ } 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 (
{/* Сайдбар */}
-
-
-
-
+
{/* Основной контент */}
-
+
{/* Вкладки */}
-
-
- {/* Контент вкладки */}
-
-
+
-
-
+
+
+ {/* Остальной контент */}
+
+ {/* Контент вкладки */}
+
+
+
+
+
);
};
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/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx
index 379d7db..e37f326 100644
--- a/src/Components/Layout/SidebarMenu.jsx
+++ b/src/Components/Layout/SidebarMenu.jsx
@@ -1,4 +1,5 @@
-import React, { useState } from "react";
+// SidebarMenu.jsx
+import React, { useState, useEffect } from "react";
import {
Drawer,
List,
@@ -6,44 +7,54 @@ import {
styled,
IconButton,
Tooltip,
- Box
+ Box,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ TextField
} from "@mui/material";
-import {
- ChevronLeft,
- ChevronRight,
- Menu as MenuIcon
-} from "@mui/icons-material";
import MenuItem from "./SidebarMenuComponents/MenuItem";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
+import useSidebarResize from "../hooks/useSidebarResize";
+import ChevronLeft from '@mui/icons-material/ChevronLeft';
+import ChevronRight from '@mui/icons-material/ChevronRight';
+import LogoFull from '../../assets/images/logo.svg?react';
+import LogoSmall from '../../assets/images/system_monitor_icon.svg?react';
-const SidebarResizer = styled('div')(({ theme }) => ({
- 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 }) => {
+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 handleToggleCollapse = () => {
setCollapsed(!collapsed);
};
- const handleSelectItem = (id, title, children) => {
- onOpenTab(id, title, children);
- };
-
- 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 (
{
onMouseLeave={() => setHovered(false)}
sx={{
position: 'relative',
- width: drawerWidth,
+ width: collapsed ? 64 : sidebarWidth,
transition: 'width 0.3s ease',
}}
>
{
},
}}
>
- {/* Кнопка сворачивания/разворачивания */}
+ {/* Заголовок с логотипом */}
+ {/* Логотип (занимает все пространство) */}
+
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Кнопка сворачивания (абсолютное позиционирование) */}
- {collapsed ? (
- hovered ? :
- ) : (
-
- )}
+ {collapsed ? : }
- {/* Содержимое меню */}
-
-
- {!collapsed && (
-
- Меню
-
+ {/* Основное содержимое меню */}
+
+
+ {data && (
+
)}
-
- {/* Футер */}
- {!collapsed && (
-
- )}
+
- {/* Ресайзер */}
{!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 69a105c..6fd6607 100644
--- a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx
+++ b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx
@@ -1,17 +1,23 @@
-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 StatusIndicator from "./StatusIndicator";
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
cursor: "pointer",
paddingLeft: theme.spacing(2 + level * 2),
+ position: 'relative',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
@@ -20,49 +26,59 @@ const StyledListItem = styled(ListItem)(({ theme, level }) => ({
},
}));
-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) => {
- e.stopPropagation();
- const allChildren = getAllChildren(item);
- onSelectItem(item.id, item.title, allChildren);
+ const handleClick = () => {
+ if (onSelectItem) {
+ onSelectItem(item);
+ }
};
return (
<>
+ {!collapsed && }
+
-
- {hasChildren ? (isOpen ? : ) : }
-
+ {hasChildren ? (isOpen ? : ) : }
- {!collapsed && ( // Показываем текст только в развернутом состоянии
+ {!collapsed && (
<>
{
}}
/>
{hasChildren && (isOpen ? : )}
+
+ {level > 0 && (
+ {
+ e.stopPropagation();
+ handleEditClick();
+ }}
+ sx={{ ml: 1 }}
+ >
+
+
+ )}
>
)}
- {hasChildren && !collapsed && ( // Показываем детей только в развернутом состоянии
+
+
+ {hasChildren && !collapsed && (
{item.items.map((child, index) => (
@@ -94,16 +139,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
+export default MenuItem;
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/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/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/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..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,118 +401,74 @@
"id": "media_server_2",
"items": [
{
- "id": "182",
- "title": "Graviton S2082I (device$18)",
+ "id": "device$19",
+ "title": "Graviton S2082I (device$19)",
"items": [
{
- "id": "42",
- "title": "OS Linux (module$4) АО",
+ "id": "module$13",
+ "title": "OS Linux (module$13) АО",
"items": [
{
- "id": "1902",
- "title": "Загрузка процессора за 1 минуту"
- },
- {
- "id": "1912",
- "title": "Загрузка процессора за 5 минут"
- },
- {
- "id": "1922",
- "title": "Загрузка процессора за 15 минут"
- },
- {
- "id": "1972",
- "title": "Общий объем SWAP-файла"
- },
- {
- "id": "1982",
- "title": "Используемый объем SWAP-файла"
- },
- {
- "id": "1992",
- "title": "Общий объем физической оперативной памяти"
- },
- {
- "id": "2002",
- "title": "Доступный объем физической оперативной памяти"
- },
- {
- "id": "2012",
- "title": "Свободный объем физической и виртуальной оперативной памяти"
- },
- {
- "id": "2022",
- "title": "Буферизованный объем оперативной памяти"
- },
- {
- "id": "2032",
- "title": "Кэшированый объем оперативной памяти"
- },
- {
- "id": "2742",
- "title": "Используемый объем SWAP-файла"
- },
- {
- "id": "2752",
- "title": "Время затраченное процессором на процессы с пониженным приоритетом"
- },
- {
- "id": "2762",
- "title": "Время затраченное процессором на процессы ядра ОС"
- },
- {
- "id": "2772",
- "title": "Время простоя процессора"
- },
- {
- "id": "2782",
- "title": "Общая емкость жестких дисков"
- },
- {
- "id": "2792",
- "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": "zvks_cpu1min",
+ "title": "Загрузка процессора за 1 минуту",
+ "metric": "zvks_cpu1min",
+ "description": "Загрузка процессора за 1 минуту"
},
{
"id": "372",
- "title": "Общее количество активных конференций"
+ "title": "Загрузка процессора за 5 минут"
+ },
+ {
+ "id": "373",
+ "title": "Загрузка процессора за 15 минут"
+ },
+ {
+ "id": "378",
+ "title": "Общий объем SWAP-файла"
+ },
+ {
+ "id": "379",
+ "title": "Используемый объем SWAP-файла"
+ },
+ {
+ "id": "380",
+ "title": "Общий объем физической оперативной памяти"
+ },
+ {
+ "id": "381",
+ "title": "Доступный объем физической оперативной памяти"
},
{
"id": "382",
- "title": "Статус записи"
+ "title": "Свободный объем физической и виртуальной оперативной памяти"
},
{
- "id": "392",
- "title": "Общее количество сохранённых записей"
+ "id": "383",
+ "title": "Буферизованный объем оперативной памяти"
+ },
+ {
+ "id": "384",
+ "title": "Кэшированый объем оперативной памяти"
+ },
+ {
+ "id": "375",
+ "title": "Время затраченное процессором на процессы с пониженным приоритетом"
+ },
+ {
+ "id": "376",
+ "title": "Время затраченное процессором на процессы ядра ОС"
+ },
+ {
+ "id": "377",
+ "title": "Время простоя процессора"
+ },
+ {
+ "id": "385",
+ "title": "Общая емкость жестких дисков"
+ },
+ {
+ "id": "386",
+ "title": "Доступная емкость жестких дисков"
}
]
},
@@ -519,63 +477,63 @@
"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 +543,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 +609,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 +675,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 +847,15 @@
"title": "Общее количество конференций"
},
{
- "id": "373",
+ "id": "373000",
"title": "Общее количество активных конференций"
},
{
- "id": "383",
+ "id": "38300",
"title": "Статус записи"
},
{
- "id": "393",
+ "id": "39300",
"title": "Общее количество сохранённых записей"
}
]
@@ -1281,11 +1239,11 @@
"title": "Общее количество активных конференций"
},
{
- "id": "384",
+ "id": "38400",
"title": "Статус записи"
},
{
- "id": "394",
+ "id": "39400",
"title": "Общее количество сохранённых записей"
}
]
@@ -1671,7 +1629,7 @@
"title": "Общее количество конференций"
},
{
- "id": "379",
+ "id": "37900",
"title": "Общее количество активных конференций"
},
{
@@ -1679,7 +1637,7 @@
"title": "Статус записи"
},
{
- "id": "399",
+ "id": "39900",
"title": "Общее количество сохранённых записей"
}
]
@@ -2447,15 +2405,15 @@
"title": "Общее количество конференций"
},
{
- "id": "378",
+ "id": "37800",
"title": "Общее количество активных конференций"
},
{
- "id": "388",
+ "id": "38800",
"title": "Статус записи"
},
{
- "id": "398",
+ "id": "39800",
"title": "Общее количество сохранённых записей"
}
]
@@ -2841,15 +2799,15 @@
"title": "Общее количество конференций"
},
{
- "id": "375",
+ "id": "37500",
"title": "Общее количество активных конференций"
},
{
- "id": "385",
+ "id": "38500",
"title": "Статус записи"
},
{
- "id": "395",
+ "id": "39500",
"title": "Общее количество сохранённых записей"
}
]
@@ -3229,15 +3187,15 @@
"title": "Общее количество конференций"
},
{
- "id": "376",
+ "id": "37600",
"title": "Общее количество активных конференций"
},
{
- "id": "386",
+ "id": "38600",
"title": "Статус записи"
},
{
- "id": "396",
+ "id": "39600",
"title": "Общее количество сохранённых записей"
}
]
@@ -3617,7 +3575,7 @@
"title": "Общее количество конференций"
},
{
- "id": "377",
+ "id": "37700",
"title": "Общее количество активных конференций"
},
{
@@ -3625,7 +3583,7 @@
"title": "Статус записи"
},
{
- "id": "397",
+ "id": "39700",
"title": "Общее количество сохранённых записей"
}
]
diff --git a/src/Components/TreeChart/tabContent.jsx b/src/Components/TreeChart/tabContent.jsx
index 4072401..f21b002 100755
--- a/src/Components/TreeChart/tabContent.jsx
+++ b/src/Components/TreeChart/tabContent.jsx
@@ -1,90 +1,155 @@
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}`;
+// Компонент Skeleton для графика
+const ChartSkeleton = () => (
+
+
+
+
+);
+
+// Компонент Skeleton для контейнера
+const ContainerSkeleton = () => (
+
+
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+);
+
+// Утилита для извлечения контекста из пути
+const parseContextFromPath = (node) => {
+ const context = {};
+ let current = node;
+
+ 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 context;
};
-// Функция для рекурсивного сбора всех id потомков
-const getAllChildIds = (node) => {
- let ids = [];
- if (node.id) {
- ids.push(node.id); // Добавляем id текущего узла
+// Основная функция построения контента вкладок
+const tabContent = (data, cache = {}) => {
+ const tabContentMap = { ...cache };
+
+ if (!data || !data.items || data.items.length === 0) {
+ console.warn("Данные отсутствуют или массив items пуст", data);
+ return tabContentMap;
}
- if (node.items && node.items.length > 0) {
- node.items.forEach((child) => {
- ids = ids.concat(getAllChildIds(child)); // Рекурсивно собираем id потомков
- });
- }
- return ids;
-};
-const tabContent = (data) => {
- const tabContent = {};
+ const processNode = (node, parentContext = {}) => {
+ // Получаем полный контекст из всей цепочки родителей
+ const pathContext = parseContextFromPath(node);
+ const currentContext = { ...parentContext, ...pathContext };
- // Функция для рекурсивного обхода и сбора данных
- const generateContent = (nodes) => {
- nodes.forEach((node) => {
- // Если у узла есть вложенные элементы, рекурсивно обрабатываем их
- if (node.items && node.items.length > 0) {
- // Создаем контент для родителя
- const childrenContent = generateContent(node.items);
+ // Генерируем уникальный ключ на основе пути
+ const path = [];
+ let current = node;
+ while (current) {
+ path.unshift(current.id);
+ current = current.parent;
+ }
+ const pathId = path.join('_');
- const content = (
-
-
{node.title}
-
tabContent[child.id].content)} />
- Контент для {node.title}.
- {childrenContent}
-
- );
+ if (Array.isArray(node.items) && node.items.length > 0) {
+ const children = node.items
+ .map((child) => processNode(child, currentContext))
+ .filter(Boolean);
- // Сохраняем контент для текущего id
- tabContent[node.id] = {
- title: node.title,
- content: content,
- };
- } else {
- // Если у узла нет вложенных элементов, это самый нижний уровень
- const metricName = getMetricName(node.id);
- const content = (
-
-
{node.title}
{/* Используем title узла */}
- Загрузка графика...}>
-
-
-
- );
+ const content = (
+
+
{node.title}
+ }>
+ c.content)} />
+
+
+ );
- // Сохраняем контент для текущего id
- tabContent[node.id] = {
- title: node.title,
- content: content,
- };
- }
- });
+ tabContentMap[pathId] = {
+ title: node.title,
+ content,
+ context: currentContext,
+ };
- // Возвращаем контент для всех потомков
- return (
-
- {nodes.map((node) => (
-
{tabContent[node.id].content}
- ))}
+ 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 };
};
- // Начинаем обработку с корневого уровня
- if (data.items && data.items.length > 0) {
- generateContent(data.items);
- } else {
- console.warn("Данные отсутствуют или массив items пуст");
+ try {
+ processNode(data);
+ } catch (error) {
+ console.error("Ошибка обработки данных:", error);
}
- return tabContent;
+ return tabContentMap;
};
export default tabContent;
\ No newline at end of file
diff --git a/src/Components/UI/LoginModal.jsx b/src/Components/UI/LoginModal.jsx
index 3e29de2..f76b7b3 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,26 +17,27 @@ const LoginModal = ({ onLogin, onClose }) => {
e.preventDefault();
try {
- // Отправляем данные на бэкенд
- const response = await fetch(`${import.meta.env.VITE_BACK_URL}/api/auth/login`, {
- method: 'POST',
- 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) {
- onLogin(); // Успешная авторизация
- onClose(); // Закрыть модальное окно
+ localStorage.setItem('access_token', data.access_token);
+ 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/MUItabs.jsx b/src/Components/UI/MUItabs.jsx
index 0ea89aa..173b492 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) { // Middle mouse button
+ if (e.button === 1) { // Средняя кнопка мыши
e.preventDefault();
onCloseTab(id);
}
@@ -26,6 +62,12 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
onTabClick(newValue);
};
+ // Статические вкладки (сохраняем оригинальные id)
+ 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)}
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
new file mode 100644
index 0000000..c0beca8
--- /dev/null
+++ b/src/Components/UI/auth.jsx
@@ -0,0 +1,20 @@
+import axios from 'axios';
+
+export const checkAuth = async () => {
+ try {
+ const { data } = await axios.get(
+ `${import.meta.env.VITE_BACK_URL}/api/auth/check`,
+ {
+ withCredentials: true,
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
+ },
+ }
+ );
+
+ return data;
+ } 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..ed351e7 100644
--- a/src/Components/hooks/LazyChartBatchRender.jsx
+++ b/src/Components/hooks/LazyChartBatchRender.jsx
@@ -1,29 +1,167 @@
-import { useEffect, 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, batchSize = 3, delay = 150 }) => {
- const [visibleCharts, setVisibleCharts] = useState([]);
+const LazyChartBatchRenderer = ({ charts }) => {
+ const [visibleIndices, setVisibleIndices] = useState(new Set());
+ const placeholderRefs = useRef([]);
+ const observerRef = useRef(null);
+ const cleanupTimeoutRef = useRef(null);
+
+ const ChartSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+ {[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(() => {
- let index = 0;
- const timer = setInterval(() => {
- setVisibleCharts((prev) => [
- ...prev,
- ...charts.slice(index, index + batchSize),
- ]);
- index += batchSize;
- if (index >= charts.length) clearInterval(timer);
- }, delay);
+ observerRef.current = new IntersectionObserver(
+ (entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ updateVisibleIndices();
+ }
+ });
+ },
+ {
+ root: null,
+ rootMargin: '500px 0px',
+ threshold: 0.01
+ }
+ );
- return () => clearInterval(timer);
- }, [charts]);
+ placeholderRefs.current.forEach(ref => {
+ if (ref) observerRef.current.observe(ref);
+ });
+
+ 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);
+ };
+
+ 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 (
- <>
- {visibleCharts.map((chart, idx) => (
- {chart}
+
+ {charts.map((chart, index) => (
+
(placeholderRefs.current[index] = el)}
+ data-index={index}
+ style={{
+ minHeight: '400px',
+ marginBottom: '20px',
+ transition: 'opacity 0.3s ease',
+ }}
+ >
+ {shouldShowChart(index) ? chart : }
+
))}
- >
+
);
};
-export default LazyChartBatchRenderer;
+export default React.memo(LazyChartBatchRenderer);
\ No newline at end of file
diff --git a/src/Components/hooks/TabContent.jsx b/src/Components/hooks/TabContent.jsx
index bdb6226..cfc050b 100644
--- a/src/Components/hooks/TabContent.jsx
+++ b/src/Components/hooks/TabContent.jsx
@@ -1,12 +1,33 @@
import SystemStatusChart from "../../Charts/SystemStatusChart";
import TreeTable from "../UI/TreeTable";
import FlowChart from "../TreeChart/FlowChart";
+import { getStatusColor } from "../TreeChart/dataUtils";
+
+
+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]++;
+ }
+ if (node.items && node.items.length > 0) {
+ node.items.forEach(child => countRecursive(child));
+ }
+ };
+
+ countRecursive(data);
+ return counts;
+ };
-const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleOpenTab }) => {
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}
+
+ ))}
+
+
@@ -24,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 188cdf5..6c403fd 100644
--- a/src/Components/hooks/useTabs.jsx
+++ b/src/Components/hooks/useTabs.jsx
@@ -4,23 +4,43 @@ 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 }]
- );
- setActiveTab(id);
+ const handleOpenTab = useCallback((newTab) => {
+ setTabs((prevTabs) => {
+ const exists = prevTabs.some((tab) => tab.id === newTab.id);
+ if (!exists) {
+ return [...prevTabs, newTab];
+ }
+ return prevTabs;
+ });
+ setActiveTab(newTab.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);
+ if (activeTab === id) {
+ setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : initialTab);
+ }
+ return newTabs;
+ });
+ }, [activeTab, initialTab]);
- return { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab };
+ 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
+export default useTabs;
diff --git a/src/Style/theme.jsx b/src/Style/theme.jsx
index 40c405d..6d33a29 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", // Фон "бумажных" поверхностей (карточек, панелей)
},
@@ -139,7 +139,7 @@ export const darkTheme = createTheme({
// Фоновые цвета
background: {
- default: "#1E1E1E", // Основной фон приложения
+ default: "#2d2d2d", // Основной фон приложения
paper: "#2d2d2d", // Фон "бумажных" поверхностей
},
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 @@
+
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(